Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-30 21:10:07 +00:00
parent 1abf48c10c
commit cc803c04b8
27 changed files with 581 additions and 199 deletions

View file

@ -1,11 +1,11 @@
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton, GlLink } from '@gitlab/ui';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
components: { GlButton }, components: { GlButton, GlLink },
props: { props: {
displayText: { displayText: {
type: String, type: String,
@ -37,6 +37,42 @@ export default {
required: false, required: false,
default: undefined, default: undefined,
}, },
triggerElement: {
type: String,
required: false,
default: 'button',
},
event: {
type: String,
required: false,
default: '',
},
label: {
type: String,
required: false,
default: '',
},
},
computed: {
isButton() {
return this.triggerElement === 'button';
},
componentAttributes() {
const baseAttributes = {
class: this.classes,
'data-qa-selector': 'invite_members_button',
};
if (this.event && this.label) {
return {
...baseAttributes,
'data-track-event': this.event,
'data-track-label': this.label,
};
}
return baseAttributes;
},
}, },
mounted() { mounted() {
this.trackExperimentOnShow(); this.trackExperimentOnShow();
@ -57,12 +93,15 @@ export default {
<template> <template>
<gl-button <gl-button
:class="classes" v-if="isButton"
:icon="icon" v-bind="componentAttributes"
:variant="variant" :variant="variant"
data-qa-selector="invite_members_button" :icon="icon"
@click="openModal" @click="openModal"
> >
{{ displayText }} {{ displayText }}
</gl-button> </gl-button>
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }}
</gl-link>
</template> </template>

View file

@ -2,6 +2,7 @@
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import getTableHeaders from '../utils'; import getTableHeaders from '../utils';
import PackageTypeToken from './tokens/package_type_token.vue'; import PackageTypeToken from './tokens/package_type_token.vue';
@ -16,7 +17,7 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }], operators: [{ value: '=', description: __('is'), default: 'true' }],
}, },
], ],
components: { RegistrySearch }, components: { RegistrySearch, UrlSync },
computed: { computed: {
...mapState({ ...mapState({
isGroupPage: (state) => state.config.isGroupPage, isGroupPage: (state) => state.config.isGroupPage,
@ -38,13 +39,18 @@ export default {
</script> </script>
<template> <template>
<registry-search <url-sync>
:filter="filter" <template #default="{ updateQuery }">
:sorting="sorting" <registry-search
:tokens="$options.tokens" :filter="filter"
:sortable-fields="sortableFields" :sorting="sorting"
@sorting:changed="updateSorting" :tokens="$options.tokens"
@filter:changed="setFilter" :sortable-fields="sortableFields"
@filter:submit="$emit('update')" @sorting:changed="updateSorting"
/> @filter:changed="setFilter"
@filter:submit="$emit('update')"
@query:changed="updateQuery"
/>
</template>
</url-sync>
</template> </template>

View file

@ -6,6 +6,7 @@ import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageSearch from './package_search.vue'; import PackageSearch from './package_search.vue';
import PackageTitle from './package_title.vue'; import PackageTitle from './package_title.vue';
@ -42,11 +43,21 @@ export default {
}, },
}, },
mounted() { mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.setSorting(sorting);
this.setFilter(filters);
this.requestPackagesList(); this.requestPackagesList();
this.checkDeleteAlert(); this.checkDeleteAlert();
}, },
methods: { methods: {
...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']), ...mapActions([
'requestPackagesList',
'requestDeletePackage',
'setSelectedType',
'setSorting',
'setFilter',
]),
onPageChanged(page) { onPageChanged(page) {
return this.requestPackagesList({ page }); return this.requestPackagesList({ page });
}, },

View file

@ -7,3 +7,23 @@ export const keyValueToFilterToken = (type, data) => ({ type, value: { data } })
export const searchArrayToFilterTokens = (search) => export const searchArrayToFilterTokens = (search) =>
search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s)); search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
export const extractFilterAndSorting = (queryObject) => {
const { type, search, sort, orderBy } = queryObject;
const filters = [];
const sorting = {};
if (type) {
filters.push(keyValueToFilterToken('type', type));
}
if (search) {
filters.push(...searchArrayToFilterTokens(search));
}
if (sort) {
sorting.sort = sort;
}
if (orderBy) {
sorting.orderBy = orderBy;
}
return { filters, sorting };
};

View file

@ -4,6 +4,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issuable_show/constants'; import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue'; import Issue from '~/issue';
import '~/notes/index'; import '~/notes/index';
@ -36,6 +37,7 @@ export default function initShowIssue() {
initSentryErrorStackTraceApp(); initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp(); initRelatedMergeRequestsApp();
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger();
import(/* webpackChunkName: 'design_management' */ '~/design_management') import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default()) .then((module) => module.default())

View file

@ -6,6 +6,7 @@ import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { handleLocationHash } from '~/lib/utils/common_utils'; import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue'; import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph'; import initSourcegraph from '~/sourcegraph';
@ -22,6 +23,7 @@ export default function initMergeRequestShow() {
initInviteMemberModal(); initInviteMemberModal();
initInviteMemberTrigger(); initInviteMemberTrigger();
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box'); const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new // eslint-disable-next-line no-new

View file

@ -12,6 +12,7 @@ import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue'; import DeleteImage from '../components/delete_image.vue';
@ -82,6 +83,9 @@ export default {
searchConfig: SORT_FIELDS, searchConfig: SORT_FIELDS,
apollo: { apollo: {
baseImages: { baseImages: {
skip() {
return !this.fetchBaseQuery;
},
query: getContainerRepositoriesQuery, query: getContainerRepositoriesQuery,
variables() { variables() {
return this.queryVariables; return this.queryVariables;
@ -125,15 +129,19 @@ export default {
sorting: { orderBy: 'UPDATED', sort: 'desc' }, sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null, name: null,
mutationLoading: false, mutationLoading: false,
fetchBaseQuery: false,
fetchAdditionalDetails: false, fetchAdditionalDetails: false,
}; };
}, },
computed: { computed: {
images() { images() {
return this.baseImages.map((image, index) => ({ if (this.baseImages) {
...image, return this.baseImages.map((image, index) => ({
...get(this.additionalDetails, index, {}), ...image,
})); ...get(this.additionalDetails, index, {}),
}));
}
return [];
}, },
graphqlResource() { graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project'; return this.config.isGroupPage ? 'group' : 'project';
@ -172,8 +180,15 @@ export default {
}, },
}, },
mounted() { mounted() {
const { sorting, filters } = extractFilterAndSorting(this.$route.query);
this.filter = [...filters];
this.name = filters[0]?.value.data;
this.sorting = { ...this.sorting, ...sorting };
// If the two graphql calls - which are not batched - resolve togheter we will have a race // If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart // condition when apollo sets the cache, with this we give the 'base' call an headstart
this.fetchBaseQuery = true;
setTimeout(() => { setTimeout(() => {
this.fetchAdditionalDetails = true; this.fetchAdditionalDetails = true;
}, 200); }, 200);
@ -245,6 +260,9 @@ export default {
const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM); const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data; this.name = search?.value?.data;
}, },
updateUrlQueryString(query) {
this.$router.push({ query });
},
}, },
}; };
</script> </script>
@ -304,6 +322,7 @@ export default {
@sorting:changed="updateSorting" @sorting:changed="updateSorting"
@filter:changed="filter = $event" @filter:changed="filter = $event"
@filter:submit="doFilter" @filter:submit="doFilter"
@query:changed="updateUrlQueryString"
/> />
<div v-if="isLoading" class="gl-mt-5"> <div v-if="isLoading" class="gl-mt-5">

View file

@ -53,10 +53,10 @@
%ul.dropdown-footer-list %ul.dropdown-footer-list
%li %li
- if directly_invite_members? - if directly_invite_members?
= link_to invite_text, .js-invite-members-trigger{ data: { trigger_element: 'anchor',
project_project_members_path(@project), display_text: invite_text,
title: invite_text, event: 'click_invite_members',
data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': track_label } label: track_label } }
- else - else
.js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } } .js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } }
- else - else

View file

@ -0,0 +1,5 @@
---
title: Change assignee dropdown invite to utilize invite modal
merge_request: 57002
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Connect Registries searches to URL
merge_request: 57251
author:
type: added

View file

@ -6,8 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM SELF)** # PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM SELF)**
This document focuses on configuration supported with [GitLab Premium](https://about.gitlab.com/pricing/), using the Omnibus GitLab package. If you're a Free user of GitLab self-managed, consider using a cloud-hosted solution.
If you're a Community Edition or Starter user, consider using a cloud hosted solution.
This document doesn't cover installations from source. This document doesn't cover installations from source.
If a setup with replication and failover isn't what you were looking for, see If a setup with replication and failover isn't what you were looking for, see

View file

@ -215,7 +215,7 @@ Example response:
## Update an issue board ## Update an issue board
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.1. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in GitLab 11.1.
Updates a project issue board. Updates a project issue board.
@ -228,10 +228,10 @@ PUT /projects/:id/boards/:board_id
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `board_id` | integer | yes | The ID of a board | | `board_id` | integer | yes | The ID of a board |
| `name` | string | no | The new name of the board | | `name` | string | no | The new name of the board |
| `assignee_id` **(STARTER)** | integer | no | The assignee the board should be scoped to | | `assignee_id` **(PREMIUM)** | integer | no | The assignee the board should be scoped to |
| `milestone_id` **(STARTER)** | integer | no | The milestone the board should be scoped to | | `milestone_id` **(PREMIUM)** | integer | no | The milestone the board should be scoped to |
| `labels` **(STARTER)** | string | no | Comma-separated list of label names which the board should be scoped to | | `labels` **(PREMIUM)** | string | no | Comma-separated list of label names which the board should be scoped to |
| `weight` **(STARTER)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to | | `weight` **(PREMIUM)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to |
```shell ```shell
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/boards/1?name=new_name&milestone_id=43&assignee_id=1&labels=Doing&weight=4" curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/boards/1?name=new_name&milestone_id=43&assignee_id=1&labels=Doing&weight=4"

View file

@ -183,20 +183,52 @@ file.
To add a redirect: To add a redirect:
1. Create a merge request in one of the internal docs projects (`gitlab`, 1. In the repository (`gitlab`, `gitlab-runner`, `omnibus-gitlab`, or `charts`),
`gitlab-runner`, `omnibus-gitlab`, or `charts`), depending on the location of create a new documentation file. Don't delete the old one. The easiest
the file that's being moved, renamed, or removed. way is to copy it. For example:
1. To move or rename the documentation file, create a new file with the new
name or location, but don't delete the existing documentation file. ```shell
1. In the original documentation file, add the redirect code for cp doc/user/search/old_file.md doc/api/new_file.md
`/help`. Use the following template exactly, and change only the links and ```
date. Use relative paths and `.md` for a redirect to another documentation
page. Use the full URL (with `https://`) to redirect to a different project or 1. Add the redirect code to the old documentation file by running the
site: following Rake task. The first argument is the path of the old file,
and the second argument is the path of the new file:
- To redirect to a page in the same project, use relative paths and
the `.md` extension. Both old and new paths start from the same location.
In the following example, both paths are relative to `doc/`:
```shell
bundle exec rake "gitlab:docs:redirect[doc/user/search/old_file.md, doc/api/new_file.md]"
```
- To redirect to a page in a different project or site, use the full URL (with `https://`) :
```shell
bundle exec rake "gitlab:docs:redirect[doc/user/search/old_file.md, https://example.com]"
```
Alternatively, you can omit the arguments, and you'll be asked to enter
their values:
```shell
bundle exec rake gitlab:docs:redirect
```
If you don't want to use the Rake task, you can use the following template.
However, the file paths must be relative to the `doc` or `docs` directory.
Replace the value of `redirect_to` with the new file path and `YYYY-MM-DD`
with the date the file should be removed.
Redirect files that link to docs in internal documentation projects
are removed after three months. Redirect files that link to external sites are
removed after one year:
```markdown ```markdown
--- ---
redirect_to: '../path/to/file/index.md' redirect_to: '../newpath/to/file/index.md'
--- ---
This document was moved to [another location](../path/to/file/index.md). This document was moved to [another location](../path/to/file/index.md).
@ -205,27 +237,24 @@ To add a redirect:
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> <!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->
``` ```
Redirect files linking to docs in any of the internal documentations projects
are removed after three months. Redirect files linking to external sites are
removed after one year.
1. If the documentation page being moved has any Disqus comments, follow the steps 1. If the documentation page being moved has any Disqus comments, follow the steps
described in [Redirections for pages with Disqus comments](#redirections-for-pages-with-disqus-comments). described in [Redirections for pages with Disqus comments](#redirections-for-pages-with-disqus-comments).
1. If a documentation page you're removing includes images that aren't used 1. Open a merge request with your changes. If a documentation page
you're removing includes images that aren't used
with any other documentation pages, be sure to use your merge request to delete with any other documentation pages, be sure to use your merge request to delete
those images from the repository. those images from the repository.
1. Assign the merge request to a technical writer for review and merge. 1. Assign the merge request to a technical writer for review and merge.
1. Search for links to the original documentation file. You must find and update all 1. Search for links to the old documentation file. You must find and update all
links that point to the original documentation file: links that point to the old documentation file:
- In <https://gitlab.com/gitlab-com/www-gitlab-com>, search for full URLs: - In <https://gitlab.com/gitlab-com/www-gitlab-com>, search for full URLs:
`grep -r "docs.gitlab.com/ee/path/to/file.html" .` `grep -r "docs.gitlab.com/ee/path/to/file.html" .`
- In <https://gitlab.com/gitlab-org/gitlab-docs/-/tree/master/content/_data>, - In <https://gitlab.com/gitlab-org/gitlab-docs/-/tree/master/content/_data>,
search the navigation bar configuration files for the path with `.html`: search the navigation bar configuration files for the path with `.html`:
`grep -r "path/to/file.html" .` `grep -r "path/to/file.html" .`
- In any of the four internal projects. This includes searching for links in the docs - In any of the four internal projects, search for links in the docs
and codebase. Search for all variations, including full URL and just the path. and codebase. Search for all variations, including full URL and just the path.
In macOS for example, go to the root directory of the `gitlab` project and run: For example, go to the root directory of the `gitlab` project and run:
```shell ```shell
grep -r "docs.gitlab.com/ee/path/to/file.html" . grep -r "docs.gitlab.com/ee/path/to/file.html" .

View file

@ -302,7 +302,7 @@ end
Adding foreign key to `projects`: Adding foreign key to `projects`:
We can use the `add_concurrenct_foreign_key` method in this case, as this helper method We can use the `add_concurrent_foreign_key` method in this case, as this helper method
has the lock retries built into it. has the lock retries built into it.
```ruby ```ruby

View file

@ -6,93 +6,93 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Jira DVCS connector # Jira DVCS connector
If you're using GitLab.com and Jira Cloud, use the Use the Jira DVCS (distributed version control system) connector if you self-host
[GitLab for Jira app](connect-app.md) unless you have a specific need for the DVCS Connector. either your Jira instance or your GitLab instance, and you want to sync information
between them. If you use Jira Cloud and GitLab.com, you should use the
[GitLab for Jira app](connect-app.md) unless you specifically need the DVCS connector.
When configuring Jira DVCS Connector: When you configure the Jira DVCS connector, make sure your GitLab and Jira instances
are accessible.
- If you are using self-managed GitLab, make sure your GitLab instance is accessible by Jira. - **Self-managed GitLab**: Your GitLab instance must be accessible by Jira.
- If you're connecting to Jira Cloud, ensure your instance is accessible through the internet. - **Jira Cloud**: Your instance must be accessible through the internet.
- If you are using Jira Server, make sure your instance is accessible however your network is set up. - **Jira Server**: Your network must allow access to your instance.
## GitLab account configuration for DVCS ## Configure a GitLab application for DVCS
NOTE: We recommend you create and use a `jira` user in GitLab, and use the account only
To ensure that regular user account maintenance doesn't impact your integration, for integration work. A separate account ensures regular account maintenance does not affect
create and use a single-purpose `jira` user in GitLab. your integration.
1. In GitLab, create a new application to allow Jira to connect with your GitLab account. 1. In GitLab, [create a user](../../user/profile/account/create_accounts.md) for Jira to
1. Sign in to the GitLab account that you want Jira to use to connect to GitLab. use to connect to GitLab. For Jira to access all projects,
1. In the top-right corner, select your avatar. a user with [Administrator](../../user/permissions.md) permissions must
1. Select **Edit profile**. create the user.
1. In the top right corner, click the account's avatar, and select **Edit profile**.
1. In the left sidebar, select **Applications**. 1. In the left sidebar, select **Applications**.
1. In the **Name** field, enter a descriptive name for the integration, such as `Jira`. 1. In the **Name** field, enter a descriptive name for the integration, such as `Jira`.
1. In the **Redirect URI** field, enter `https://<gitlab.example.com>/login/oauth/callback`, 1. In the **Redirect URI** field, enter the URI appropriate for your version of GitLab,
replacing `<gitlab.example.com>` with your GitLab instance domain. For example, if you are using GitLab.com, replacing `<gitlab.example.com>` with your GitLab instance domain:
this would be `https://gitlab.com/login/oauth/callback`. - *For GitLab versions 11.3 and later,* use `https://<gitlab.example.com>/login/oauth/callback`.
If you use GitLab.com, the URL is `https://gitlab.com/login/oauth/callback`.
- *For GitLab versions 11.2 and earlier,* use
`https://<gitlab.example.com>/-/jira/login/oauth/callback`.
NOTE: 1. For **Scopes**, select `api` and clear any other checkboxes.
If using a GitLab version earlier than 11.3, the `Redirect URI` must be 1. Select **Submit**.
`https://<gitlab.example.com>/-/jira/login/oauth/callback`. If you want Jira 1. GitLab displays the generated **Application ID**
to have access to all projects, GitLab recommends that an administrator create the and **Secret** values. Copy these values, as you need them to configure Jira.
application.
![GitLab application setup](img/jira_dev_panel_gl_setup_1.png) ## Configure Jira for DVCS
1. Check **API** in the **Scopes** section, and clear any other checkboxes. If you use Jira Cloud and GitLab.com, use the [GitLab for Jira app](connect-app.md)
1. Click **Save application**. GitLab displays the generated **Application ID** unless you specifically need the DVCS Connector.
and **Secret** values. Copy these values, which you use in Jira.
## Jira DVCS Connector setup Configure this connection when you want to import all GitLab commits and branches,
for the groups you specify, into Jira. This import takes a few minutes and, after
If you're using GitLab.com and Jira Cloud, use the it completes, refreshes every 60 minutes:
[GitLab for Jira app](connect-app.md) unless you have a specific need for the DVCS Connector.
1. Ensure you have completed the [GitLab configuration](#gitlab-account-configuration-for-dvcs).
1. If you're using Jira Server, go to **Settings (gear) > Applications > DVCS accounts**.
If you're using Jira Cloud, go to **Settings (gear) > Products > DVCS accounts**.
1. Click **Link GitHub Enterprise account** to start creating a new integration.
(We're pretending to be GitHub in this integration, until there's additional platform support in Jira.)
1. Complete the form:
1. Select **GitHub Enterprise** for the **Host** field.
1. In the **Team or User Account** field, enter either:
1. Ensure you have completed the [GitLab configuration](#configure-a-gitlab-application-for-dvcs).
1. Go to your DVCS account:
- *For Jira Server,* go to **Settings (gear) > Applications > DVCS accounts**.
- *For Jira Cloud,* go to **Settings (gear) > Products > DVCS accounts**.
1. To create a new integration, select the appropriate value for **Host**:
- *For Jira versions 8.14 and later:* Select **GitLab** or
<!-- vale gitlab.Substitutions = NO -->
**GitLab Self-Hosted**.
<!-- vale gitlab.Substitutions = YES -->
- *For Jira versions 8.13 and earlier:* Select **GitHub Enterprise**.
1. For **Team or User Account**, enter either:
- The relative path of a top-level GitLab group that you have access to. - The relative path of a top-level GitLab group that you have access to.
- The relative path of your personal namespace. - The relative path of your personal namespace.
![Creation of Jira DVCS integration](img/jira_dev_panel_jira_setup_2.png) 1. In the **Host URL** field, enter the URI appropriate for your version of GitLab,
replacing `<gitlab.example.com>` with your GitLab instance domain:
1. In the **Host URL** field, enter `https://<gitlab.example.com>/`, - *For GitLab versions 11.3 and later,* use `https://<gitlab.example.com>/`.
replacing `<gitlab.example.com>` with your GitLab instance domain. For example, if you are using GitLab.com, - *For GitLab versions 11.2 and earlier,* use
this would be `https://gitlab.com/`. `https://<gitlab.example.com>/-/jira`.
NOTE:
If using a GitLab version earlier than 11.3 the **Host URL** value should be `https://<gitlab.example.com>/-/jira`
1. For the **Client ID** field, use the **Application ID** value from the previous section.
1. For the **Client Secret** field, use the **Secret** value from the previous section.
1. For **Client ID**, use the **Application ID** value from the previous section.
1. For **Client Secret**, use the **Secret** value from the previous section.
1. Ensure that the rest of the checkboxes are checked. 1. Ensure that the rest of the checkboxes are checked.
1. Select **Add** to complete and create the integration.
1. Click **Add** to complete and create the integration. To connect additional GitLab projects from other GitLab top-level groups, or
personal namespaces, repeat the previous steps with additional Jira DVCS accounts.
Jira takes up to a few minutes to know about (import behind the scenes) all the commits and branches After you configure the integration, read more about [how to test and use it](index.md#usage).
for all the projects in the GitLab group you specified in the previous step. These are refreshed
every 60 minutes.
In the future, we plan on implementing real-time integration. If you need ## Refresh data imported to Jira
to refresh the data manually, you can do this from the `Applications -> DVCS
accounts` screen where you initially set up the integration:
![Refresh GitLab information in Jira](img/jira_dev_panel_manual_refresh.png) Jira imports the commits and branches every 60 minutes for your projects. You
can refresh the data manually from the Jira interface:
To connect additional GitLab projects from other GitLab top-level groups (or personal namespaces), repeat the previous 1. Sign in to your Jira instance as the user you configured the integration with.
steps with additional Jira DVCS accounts. 1. Go to **Settings (gear) > Applications**.
1. Select **DVCS accounts**.
Now that the integration is configured, read more about how to test and use it in [Usage](index.md#usage). 1. In the table, for the repository you want to refresh, in the **Last Activity**
column, select the icon:
![Refresh GitLab information in Jira](img/jira_dev_panel_manual_refresh.png)
## Troubleshooting your DVCS connection ## Troubleshooting your DVCS connection
@ -100,39 +100,46 @@ Refer to the items in this section if you're having problems with your DVCS conn
### Jira cannot access GitLab server ### Jira cannot access GitLab server
If you complete the **Add New Account** form, authorize access, and you receive
this error, Jira and GitLab cannot connect. No other error messages
appear in any logs:
```plaintext ```plaintext
Error obtaining access token. Cannot access https://gitlab.example.com from Jira. Error obtaining access token. Cannot access https://gitlab.example.com from Jira.
``` ```
This error message is generated in Jira, after completing the **Add New Account** ### SSL and TLS problems
form and authorizing access. It indicates a connectivity issue from Jira to
GitLab. No other error messages appear in any logs.
If there was an issue with SSL/TLS, this error message is generated. Problems with SSL and TLS can cause this error message:
- The [GitLab Jira integration](../../user/project/integrations/jira.md) requires GitLab to connect to Jira. Any ```plaintext
TLS issues that arise from a private certificate authority or self-signed Error obtaining access token. Cannot access https://gitlab.example.com from Jira.
certificate [are resolved on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities), ```
- The [GitLab Jira integration](../../user/project/integrations/jira.md) requires
GitLab to connect to Jira. Any TLS issues that arise from a private certificate
authority or self-signed certificate are resolved
[on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities),
as GitLab is the TLS client. as GitLab is the TLS client.
- The Jira Development panel integration requires Jira to connect to GitLab, which - The Jira Development panel integration requires Jira to connect to GitLab, which
causes Jira to be the TLS client. If your GitLab server's certificate is not causes Jira to be the TLS client. If your GitLab server's certificate is not
issued by a public certificate authority, the Java Truststore on Jira's server issued by a public certificate authority, the Java Truststore on Jira's server
needs to have the appropriate certificate added to it (such as your organization's must have the appropriate certificate (such as your organization's
root certificate). root certificate) added to it .
Refer to Atlassian's documentation and Atlassian Support for assistance setting up Jira correctly: Refer to Atlassian's documentation and Atlassian Support for assistance setting up Jira correctly:
- [Adding a certificate to the trust store](https://confluence.atlassian.com/kb/how-to-import-a-public-ssl-certificate-into-a-jvm-867025849.html). - [Add a certificate](https://confluence.atlassian.com/kb/how-to-import-a-public-ssl-certificate-into-a-jvm-867025849.html)
- Simplest approach is to use [`keytool`](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html). to the trust store.
- The simplest approach is [`keytool`](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html).
- Add additional roots to Java's default Truststore (`cacerts`) to allow Jira to - Add additional roots to Java's default Truststore (`cacerts`) to allow Jira to
also trust public certificate authorities. also trust public certificate authorities.
- If the integration stops working after upgrading Jira's Java runtime, this - If the integration stops working after upgrading Jira's Java runtime, the
might be because the `cacerts` Truststore got replaced. `cacerts` Truststore may have been replaced during the upgrade.
- [Troubleshooting connectivity up to and including TLS handshaking](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-error-779355358.html), - Troubleshooting connectivity [up to and including TLS handshaking](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-error-779355358.html),
using the a java class called `SSLPoke`. using the a java class called `SSLPoke`.
- Download the class from Atlassian's knowledge base to a directory on Jira's server, such as `/tmp`.
- Download the class from Atlassian's knowledge base to Jira's server, for example to `/tmp`.
- Use the same Java runtime as Jira. - Use the same Java runtime as Jira.
- Pass all networking-related parameters that Jira is called with, such as proxy - Pass all networking-related parameters that Jira is called with, such as proxy
settings or an alternative root Truststore (`-Djavax.net.ssl.trustStore`): settings or an alternative root Truststore (`-Djavax.net.ssl.trustStore`):
@ -154,38 +161,42 @@ The requested scope is invalid, unknown, or malformed.
Potential resolutions: Potential resolutions:
- Verify the URL shown in the browser after being redirected from Jira in step 5 of [Jira DVCS Connector Setup](#jira-dvcs-connector-setup) includes `scope=api` in the query string. 1. Verify that the URL shown in the browser after being redirected from Jira in the
- If `scope=api` is missing from the URL, return to [GitLab account configuration](#gitlab-account-configuration-for-dvcs) and ensure the application you created in step 1 has the `api` box checked under scopes. [Jira DVCS connector setup](#configure-jira-for-dvcs) includes `scope=api` in
the query string.
1. If `scope=api` is missing from the URL, edit the
[GitLab account configuration](#configure-a-gitlab-application-for-dvcs). Review
the **Scopes** field and ensure the `api` check box is selected.
### Jira error adding account and no repositories listed ### Jira error adding account and no repositories listed
```plaintext After you complete the **Add New Account** form in Jira and authorize access, you might
Error! encounter these issues:
Failed adding the account: [Error retrieving list of repositories]
```
This error message is generated in Jira after completing the **Add New Account** - An `Error! Failed adding the account: [Error retrieving list of repositories]` error.
form in Jira and authorizing access. Attempting to click **Try Again** returns - An `Account is already integrated with JIRA` error when you click **Try Again**.
`Account is already integrated with JIRA.` The account is set up in the DVCS - An account is visible in the DVCS accounts view, but no repositories are listed.
accounts view, but no repositories are listed.
Potential resolutions: To resolve this issue:
- If you're using GitLab versions 11.10-12.7, upgrade to GitLab 12.8.10 or later - If you're using GitLab Free or GitLab Starter, be sure you're using
to resolve an identified [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012). GitLab 13.4 or later.
- If you're using GitLab Free, be sure you're using GitLab 13.4 or later. - *If you're using GitLab versions 11.10-12.7,* upgrade to GitLab 12.8.10 or later
to resolve [an identified issue](https://gitlab.com/gitlab-org/gitlab/-/issues/37012).
[Contact GitLab Support](https://about.gitlab.com/support/) if none of these reasons apply. [Contact GitLab Support](https://about.gitlab.com/support/) if none of these reasons apply.
### Fixing synchronization issues ### Fix synchronization issues
If Jira displays incorrect information (such as deleted branches), you may need to If Jira displays incorrect information, such as deleted branches, you may need to
resynchronize the information. To do so: resynchronize the information. To do so:
1. In Jira, go to **Jira Administration > Applications > DVCS accounts**. 1. In Jira, go to **Jira Administration > Applications > DVCS accounts**.
1. At the account (group or subgroup) level, Jira displays an option to 1. At the account (group or subgroup) level, Jira displays an option to
**Refresh repositories** in the `...` (ellipsis) menu. **Refresh repositories** in the **{ellipsis_h}** (ellipsis) menu.
1. For each project, there's a sync button displayed next to the **last activity** date. 1. For each project, there's a sync button displayed next to the **last activity** date.
To perform a *soft resync*, click the button, or complete a *full sync* by shift clicking - To perform a *soft resync*, click the button.
the button. For more information, see - To complete a *full sync*, shift-click the button.
[Atlassian's documentation](https://support.atlassian.com/jira-cloud-administration/docs/synchronize-jira-cloud-to-bitbucket/).
For more information, read
[Atlassian's documentation](https://support.atlassian.com/jira-cloud-administration/docs/synchronize-jira-cloud-to-bitbucket/).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -19,8 +19,8 @@ After pushing your changes to a new branch, you can:
- [Discuss](../../../discussions/index.md) your implementation with your team - [Discuss](../../../discussions/index.md) your implementation with your team
- Preview changes submitted to a new branch with [Review Apps](../../../../ci/review_apps/index.md). - Preview changes submitted to a new branch with [Review Apps](../../../../ci/review_apps/index.md).
With [GitLab Starter](https://about.gitlab.com/pricing/), you can also request You can also request [approval](../../merge_requests/merge_request_approvals.md)
[approval](../../merge_requests/merge_request_approvals.md) from your managers. from your managers.
For more information on managing branches using the GitLab UI, see: For more information on managing branches using the GitLab UI, see:

View file

@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'date'
require 'pathname'
# https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page
namespace :gitlab do
namespace :docs do
desc 'GitLab | Docs | Create a doc redirect'
task :redirect, [:old_path, :new_path] do |_, args|
if args.old_path
old_path = args.old_path
else
puts '=> Enter the path of the OLD file:'
old_path = STDIN.gets.chomp
end
if args.new_path
new_path = args.new_path
else
puts '=> Enter the path of the NEW file:'
new_path = STDIN.gets.chomp
end
#
# If the new path is a relative URL, find the relative path between
# the old and new paths.
# The returned path is one level deeper, so remove the leading '../'.
#
unless new_path.start_with?('http')
old_pathname = Pathname.new(old_path)
new_pathname = Pathname.new(new_path)
relative_path = new_pathname.relative_path_from(old_pathname).to_s
(_, *last) = relative_path.split('/')
new_path = last.join('/')
end
#
# - If this is an external URL, move the date 1 year later.
# - If this is a relative URL, move the date 3 months later.
#
date = Time.now.utc.strftime('%Y-%m-%d')
date = new_path.start_with?('http') ? Date.parse(date) >> 12 : Date.parse(date) >> 3
puts "=> Creating new redirect from #{old_path} to #{new_path}"
File.open(old_path, 'w') do |post|
post.puts '---'
post.puts "redirect_to: '#{new_path}'"
post.puts '---'
post.puts
post.puts "This file was moved to [another location](#{new_path})."
post.puts
post.puts "<!-- This redirect file can be deleted after <#{date}>. -->"
post.puts "<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->"
end
end
end
end

View file

@ -1,4 +1,4 @@
import { GlButton } from '@gitlab/ui'; import { GlButton, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
@ -8,22 +8,29 @@ jest.mock('~/experimentation/experiment_tracking');
const displayText = 'Invite team members'; const displayText = 'Invite team members';
let wrapper; let wrapper;
let triggerProps;
let findButton;
const triggerComponent = {
button: GlButton,
anchor: GlLink,
};
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(InviteMembersTrigger, { wrapper = shallowMount(InviteMembersTrigger, {
propsData: { propsData: {
displayText, displayText,
...triggerProps,
...props, ...props,
}, },
}); });
}; };
describe('InviteMembersTrigger', () => { describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => {
const findButton = () => wrapper.findComponent(GlButton); triggerProps = { triggerElement };
findButton = () => wrapper.findComponent(triggerComponent[triggerElement]);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('displayText', () => { describe('displayText', () => {
@ -74,5 +81,19 @@ describe('InviteMembersTrigger', () => {
expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_'); expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_');
}); });
it('does not add tracking attributes', () => {
createComponent();
expect(findButton().attributes('data-track-event')).toBeUndefined();
expect(findButton().attributes('data-track-label')).toBeUndefined();
});
it('adds tracking attributes', () => {
createComponent({ label: '_label_', event: '_event_' });
expect(findButton().attributes('data-track-event')).toBe('_event_');
expect(findButton().attributes('data-track-label')).toBe('_label_');
});
}); });
}); });

View file

@ -7,6 +7,8 @@ import PackageSearch from '~/packages/list/components/package_search.vue';
import PackageListApp from '~/packages/list/components/packages_list_app.vue'; import PackageListApp from '~/packages/list/components/packages_list_app.vue';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import * as packageUtils from '~/packages_and_registries/shared/utils';
jest.mock('~/lib/utils/common_utils'); jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash'); jest.mock('~/flash');
@ -61,6 +63,7 @@ describe('packages_list_app', () => {
beforeEach(() => { beforeEach(() => {
createStore(); createStore();
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
}); });
afterEach(() => { afterEach(() => {
@ -72,25 +75,6 @@ describe('packages_list_app', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
});
it('call requestPackagesList on page:changed', () => { it('call requestPackagesList on page:changed', () => {
mountComponent(); mountComponent();
store.dispatch.mockClear(); store.dispatch.mockClear();
@ -108,10 +92,75 @@ describe('packages_list_app', () => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
}); });
it('does not call requestPackagesList two times on render', () => { it('does call requestPackagesList only one time on render', () => {
mountComponent(); mountComponent();
expect(store.dispatch).toHaveBeenCalledTimes(1); expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object));
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array));
expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList');
});
describe('url query string handling', () => {
const defaultQueryParamsMock = {
search: [1, 2],
type: 'npm',
sort: 'asc',
orderBy: 'created',
};
it('calls setSorting with the query string based sorting', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', {
orderBy: defaultQueryParamsMock.orderBy,
sort: defaultQueryParamsMock.sort,
});
});
it('calls setFilter with the query string based filters', () => {
jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [
{ type: 'type', value: { data: defaultQueryParamsMock.type } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } },
{ type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } },
]);
});
it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => {
jest
.spyOn(packageUtils, 'extractFilterAndSorting')
.mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } });
mountComponent();
expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' });
expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']);
});
});
describe('empty state', () => {
it('generate the correct empty list link', () => {
mountComponent();
const link = findListComponent().find(GlLink);
expect(link.attributes('href')).toBe(emptyListHelpUrl);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
mountComponent();
const heading = findEmptyState().find('h1');
expect(heading.text()).toBe('There are no packages yet');
});
}); });
describe('filter without results', () => { describe('filter without results', () => {

View file

@ -4,6 +4,7 @@ import component from '~/packages/list/components/package_search.vue';
import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue'; import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue';
import getTableHeaders from '~/packages/list/utils'; import getTableHeaders from '~/packages/list/utils';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
@ -12,7 +13,8 @@ describe('Package Search', () => {
let wrapper; let wrapper;
let store; let store;
const findRegistrySearch = () => wrapper.find(RegistrySearch); const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const createStore = (isGroupPage) => { const createStore = (isGroupPage) => {
const state = { const state = {
@ -37,6 +39,9 @@ describe('Package Search', () => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
localVue, localVue,
store, store,
stubs: {
UrlSync,
},
}); });
}; };
@ -104,4 +109,20 @@ describe('Package Search', () => {
expect(wrapper.emitted('update')).toEqual([[]]); expect(wrapper.emitted('update')).toEqual([[]]);
}); });
it('has a UrlSync component', () => {
mountComponent();
expect(findUrlSync().exists()).toBe(true);
});
it('on query:changed calls updateQuery from UrlSync', () => {
jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
mountComponent();
findRegistrySearch().vm.$emit('query:changed');
expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
});
}); });

View file

@ -1,7 +1,9 @@
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { import {
getQueryParams, getQueryParams,
keyValueToFilterToken, keyValueToFilterToken,
searchArrayToFilterTokens, searchArrayToFilterTokens,
extractFilterAndSorting,
} from '~/packages_and_registries/shared/utils'; } from '~/packages_and_registries/shared/utils';
describe('Packages And Registries shared utils', () => { describe('Packages And Registries shared utils', () => {
@ -27,9 +29,31 @@ describe('Packages And Registries shared utils', () => {
const search = ['one', 'two']; const search = ['one', 'two'];
expect(searchArrayToFilterTokens(search)).toStrictEqual([ expect(searchArrayToFilterTokens(search)).toStrictEqual([
{ type: 'filtered-search-term', value: { data: 'one' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } },
{ type: 'filtered-search-term', value: { data: 'two' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'two' } },
]); ]);
}); });
}); });
describe('extractFilterAndSorting', () => {
it.each`
search | type | sort | orderBy | result
${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }}
${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }}
`(
'returns sorting and filters objects in the correct form',
({ search, type, sort, orderBy, result }) => {
const queryObject = {
search,
type,
sort,
orderBy,
};
expect(extractFilterAndSorting(queryObject)).toStrictEqual(result);
},
);
});
}); });

View file

@ -1,5 +1,6 @@
import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
@ -61,7 +62,7 @@ describe('List Page', () => {
const waitForApolloRequestRender = async () => { const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
await waitForPromises(); await waitForPromises();
await wrapper.vm.$nextTick(); await nextTick();
}; };
const mountComponent = ({ const mountComponent = ({
@ -70,6 +71,7 @@ describe('List Page', () => {
detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
config = { isGroupPage: false }, config = { isGroupPage: false },
query = {},
} = {}) => { } = {}) => {
localVue.use(VueApollo); localVue.use(VueApollo);
@ -96,6 +98,7 @@ describe('List Page', () => {
$toast, $toast,
$route: { $route: {
name: 'foo', name: 'foo',
query,
}, },
...mocks, ...mocks,
}, },
@ -159,9 +162,11 @@ describe('List Page', () => {
}); });
describe('isLoading is true', () => { describe('isLoading is true', () => {
it('shows the skeleton loader', () => { it('shows the skeleton loader', async () => {
mountComponent(); mountComponent();
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
}); });
@ -177,9 +182,11 @@ describe('List Page', () => {
expect(findCliCommands().exists()).toBe(false); expect(findCliCommands().exists()).toBe(false);
}); });
it('title has the metadataLoading props set to true', () => { it('title has the metadataLoading props set to true', async () => {
mountComponent(); mountComponent();
await nextTick();
expect(findRegistryHeader().props('metadataLoading')).toBe(true); expect(findRegistryHeader().props('metadataLoading')).toBe(true);
}); });
}); });
@ -312,7 +319,7 @@ describe('List Page', () => {
await selectImageForDeletion(); await selectImageForDeletion();
findDeleteImage().vm.$emit('success'); findDeleteImage().vm.$emit('success');
await wrapper.vm.$nextTick(); await nextTick();
const alert = findDeleteAlert(); const alert = findDeleteAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
@ -328,7 +335,7 @@ describe('List Page', () => {
await selectImageForDeletion(); await selectImageForDeletion();
findDeleteImage().vm.$emit('error'); findDeleteImage().vm.$emit('error');
await wrapper.vm.$nextTick(); await nextTick();
const alert = findDeleteAlert(); const alert = findDeleteAlert();
expect(alert.exists()).toBe(true); expect(alert.exists()).toBe(true);
@ -349,7 +356,7 @@ describe('List Page', () => {
findRegistrySearch().vm.$emit('filter:submit'); findRegistrySearch().vm.$emit('filter:submit');
await wrapper.vm.$nextTick(); await nextTick();
}; };
it('has a search box element', async () => { it('has a search box element', async () => {
@ -374,7 +381,7 @@ describe('List Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
await wrapper.vm.$nextTick(); await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
}); });
@ -417,7 +424,7 @@ describe('List Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page'); findImageList().vm.$emit('prev-page');
await wrapper.vm.$nextTick(); await nextTick();
expect(resolver).toHaveBeenCalledWith( expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ before: pageInfo.startCursor }), expect.objectContaining({ before: pageInfo.startCursor }),
@ -437,7 +444,7 @@ describe('List Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findImageList().vm.$emit('next-page'); findImageList().vm.$emit('next-page');
await wrapper.vm.$nextTick(); await nextTick();
expect(resolver).toHaveBeenCalledWith( expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }), expect.objectContaining({ after: pageInfo.endCursor }),
@ -458,11 +465,10 @@ describe('List Page', () => {
expect(findDeleteModal().exists()).toBe(true); expect(findDeleteModal().exists()).toBe(true);
}); });
it('contains a description with the path of the item to delete', () => { it('contains a description with the path of the item to delete', async () => {
findImageList().vm.$emit('delete', { path: 'foo' }); findImageList().vm.$emit('delete', { path: 'foo' });
return wrapper.vm.$nextTick().then(() => { await nextTick();
expect(findDeleteModal().html()).toContain('foo'); expect(findDeleteModal().html()).toContain('foo');
});
}); });
}); });
@ -498,4 +504,60 @@ describe('List Page', () => {
testTrackingCall('confirm_delete'); testTrackingCall('confirm_delete');
}); });
}); });
describe('url query string handling', () => {
const defaultQueryParams = {
search: [1, 2],
sort: 'asc',
orderBy: 'CREATED',
};
const queryChangePayload = 'foo';
it('query:updated event pushes the new query to the router', async () => {
const push = jest.fn();
mountComponent({ mocks: { $router: { push } } });
await nextTick();
findRegistrySearch().vm.$emit('query:changed', queryChangePayload);
expect(push).toHaveBeenCalledWith({ query: queryChangePayload });
});
it('graphql API call has the variables set from the URL', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ query: defaultQueryParams, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
name: 1,
sort: 'CREATED_ASC',
}),
);
});
it.each`
sort | orderBy | search | payload
${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }}
${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }}
${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }}
${undefined} | ${undefined} | ${undefined} | ${{}}
${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }}
${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }}
${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }}
${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }}
`(
'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload',
async ({ sort, orderBy, search, payload }) => {
const resolver = jest.fn().mockResolvedValue({ sort, orderBy });
mountComponent({ query: { sort, orderBy, search }, resolver });
await nextTick();
expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload));
},
);
});
}); });

View file

@ -2,7 +2,7 @@
RSpec.shared_examples 'issuable invite members experiments' do RSpec.shared_examples 'issuable invite members experiments' do
context 'when a privileged user can invite' do context 'when a privileged user can invite' do
it 'shows a link for inviting members and follows through to the members page' do it 'shows a link for inviting members and launches invite modal' do
project.add_maintainer(user) project.add_maintainer(user)
visit issuable_path visit issuable_path
@ -11,14 +11,14 @@ RSpec.shared_examples 'issuable invite members experiments' do
wait_for_requests wait_for_requests
page.within '.dropdown-menu-user' do page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: project_project_members_path(project)) expect(page).to have_link('Invite Members')
expect(page).to have_selector('[data-track-event="click_invite_members"]') expect(page).to have_selector('[data-track-event="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]') expect(page).to have_selector('[data-track-label="edit_assignee"]')
end end
click_link 'Invite Members' click_link 'Invite Members'
expect(current_path).to eq project_project_members_path(project) expect(page).to have_content("You're inviting members to the")
end end
end end

0
vendor/gitignore/C++.gitignore vendored Executable file → Normal file
View file

0
vendor/gitignore/Java.gitignore vendored Executable file → Normal file
View file