Commit 960be622 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 61e68a1e 04a53f93
......@@ -336,6 +336,15 @@ Graphql/AuthorizeTypes:
- 'spec/**/*.rb'
- 'ee/spec/**/*.rb'
Graphql/JSONType:
Enabled: true
Include:
- 'app/graphql/types/**/*'
- 'ee/app/graphql/types/**/*'
Exclude:
- 'spec/**/*.rb'
- 'ee/spec/**/*.rb'
RSpec/EnvAssignment:
Enable: true
Include:
......
export const ANY_AUTHOR = 'Any';
export const NO_LABEL = 'No label';
export const DEBOUNCE_DELAY = 200;
export const SortDirection = {
......
......@@ -184,6 +184,21 @@ export default {
this.recentSearches = resultantSearches;
});
},
/**
* When user hits Enter/Return key while typing tokens, we emit `onFilter`
* event immediately so at that time, we don't want to keep tokens dropdown
* visible on UI so this is essentially a hack which allows us to do that
* until `GlFilteredSearch` natively supports this.
* See this discussion https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421#note_385729546
*/
blurSearchInput() {
const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector(
'.gl-filtered-search-token-segment-input',
);
if (searchInputEl) {
searchInputEl.blur();
}
},
handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
......@@ -217,6 +232,7 @@ export default {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
}
this.blurSearchInput();
this.$emit('onFilter', filters);
},
},
......@@ -226,6 +242,7 @@ export default {
<template>
<div class="vue-filtered-search-bar-container d-md-flex">
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
......
<script>
import {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { NO_LABEL, DEBOUNCE_DELAY } from '../constants';
export default {
noLabel: NO_LABEL,
components: {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
labels: this.config.initialLabels || [],
loading: true,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeLabel() {
// Strip double quotes
const strippedCurrentValue = this.currentValue.includes(' ')
? this.currentValue.substring(1, this.currentValue.length - 1)
: this.currentValue;
return this.labels.find(label => label.title.toLowerCase() === strippedCurrentValue);
},
containerStyle() {
if (this.activeLabel) {
const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel);
return { backgroundColor: color, color: textColor };
}
return {};
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.labels.length) {
this.fetchLabelBySearchTerm(this.value.data);
}
},
},
},
methods: {
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
.fetchLabels(searchTerm)
.then(res => {
// We'd want to avoid doing this check but
// labels.json and /groups/:id/labels & /projects/:id/labels
// return response differently.
this.labels = Array.isArray(res) ? res : res.data;
})
.catch(() => createFlash(__('There was a problem fetching labels.')))
.finally(() => {
this.loading = false;
});
},
searchLabels: debounce(function debouncedSearch({ data }) {
this.fetchLabelBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchLabels"
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners">
~{{ activeLabel ? activeLabel.title : inputValue }}
</gl-token>
</template>
<template #suggestions>
<gl-filtered-search-suggestion :value="$options.noLabel">
{{ __('No label') }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title">
<div class="gl-display-flex">
<span
:style="{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
></span>
<div>{{ label.title }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -10,6 +10,7 @@
# action_id: integer
# author_id: integer
# project_id; integer
# target_id; integer
# state: 'pending' (default) or 'done'
# type: 'Issue' or 'MergeRequest' or ['Issue', 'MergeRequest']
#
......@@ -23,7 +24,7 @@ class TodosFinder
NONE = '0'
TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design)).freeze
TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design AlertManagement::Alert)).freeze
attr_accessor :current_user, :params
......@@ -47,6 +48,7 @@ class TodosFinder
items = by_action(items)
items = by_author(items)
items = by_state(items)
items = by_target_id(items)
items = by_types(items)
items = by_group(items)
# Filtering by project HAS TO be the last because we use
......@@ -198,6 +200,12 @@ class TodosFinder
items.with_states(params[:state])
end
def by_target_id(items)
return items if params[:target_id].blank?
items.for_target(params[:target_id])
end
def by_types(items)
if types.any?
items.for_type(types)
......
......@@ -4,7 +4,7 @@ module Resolvers
class TodoResolver < BaseResolver
type Types::TodoType, null: true
alias_method :user, :object
alias_method :target, :object
argument :action, [Types::TodoActionEnum],
required: false,
......@@ -31,9 +31,10 @@ module Resolvers
description: 'The type of the todo'
def resolve(**args)
return Todo.none if user != context[:current_user]
return Todo.none unless current_user.present? && target.present?
return Todo.none if target.is_a?(User) && target != current_user
TodosFinder.new(user, todo_finder_params(args)).execute
TodosFinder.new(current_user, todo_finder_params(args)).execute
end
private
......@@ -46,6 +47,15 @@ module Resolvers
author_id: args[:author_id],
action_id: args[:action],
project_id: args[:project_id]
}.merge(target_params)
end
def target_params
return {} unless TodosFinder::TODO_TYPES.include?(target.class.name)
{
type: target.class.name,
target_id: target.id
}
end
end
......
......@@ -71,7 +71,7 @@ module Types
description: 'Number of events of this alert',
method: :events
field :details,
field :details, # rubocop:disable Graphql/JSONType
GraphQL::Types::JSON,
null: true,
description: 'Alert details'
......@@ -97,6 +97,12 @@ module Types
description: 'URL for metrics embed for the alert',
resolve: -> (alert, _args, _context) { alert.present.metrics_dashboard_url }
field :todos,
Types::TodoType.connection_type,
null: true,
description: 'Todos of the current user for the alert',
resolver: Resolvers::TodoResolver
def notes
object.ordered_notes
end
......
......@@ -163,7 +163,8 @@ module TodosHelper
{ id: '', text: 'Any Type' },
{ id: 'Issue', text: 'Issue' },
{ id: 'MergeRequest', text: 'Merge Request' },
{ id: 'DesignManagement::Design', text: 'Design' }
{ id: 'DesignManagement::Design', text: 'Design' },
{ id: 'AlertManagement::Alert', text: 'Alert' }
]
end
......
---
title: Support getting a todo for an alert in GraphQL API
merge_request: 34789
author:
type: added
......@@ -4,6 +4,7 @@ stage: Enablement
group: Distribution
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Reference architectures
You can set up GitLab on a single server or scale it up to serve many users.
......@@ -20,10 +21,8 @@ you scale GitLab accordingly.
Testing on these reference architectures were performed with
[GitLab's Performance Tool](https://gitlab.com/gitlab-org/quality/performance)
at specific coded workloads, and the throughputs used for testing were
calculated based on sample customer data. After selecting the reference
architecture that matches your scale, refer to
[Configure GitLab to Scale](#configure-gitlab-to-scale) to see the components
involved, and how to configure them.
calculated based on sample customer data. Select the
[reference architecture](#available-reference-architectures) that matches your scale.
Each endpoint type is tested with the following number of requests per second (RPS)
per 1,000 users:
......@@ -152,42 +151,7 @@ is recommended.
instance to other geographical locations as a read-only fully operational instance
that can also be promoted in case of disaster.
## Configure GitLab to scale
NOTE: **Note:**
From GitLab 13.0, using NFS for Git repositories is deprecated. In GitLab 14.0,
support for NFS for Git repositories is scheduled to be removed. Upgrade to
[Gitaly Cluster](../gitaly/praefect.md) as soon as possible.
The following components are the ones you need to configure in order to scale
GitLab. They are listed in the order you'll typically configure them if they are
required by your [reference architecture](#reference-architectures) of choice.
Most of them are bundled in the GitLab deb/rpm package (called Omnibus GitLab),
but depending on your system architecture, you may require some components which are
not included in it. If required, those should be configured before
setting up components provided by GitLab. Advice on how to select the right
solution for your organization is provided in the configuration instructions
column.
| Component | Description | Configuration instructions | Bundled with Omnibus GitLab |
|-----------|-------------|----------------------------|
| Load balancer(s) ([6](#footnotes)) | Handles load balancing, typically when you have multiple GitLab application services nodes | [Load balancer configuration](../high_availability/load_balancer.md) ([6](#footnotes)) | No |
| Object storage service ([4](#footnotes)) | Recommended store for shared data objects | [Object Storage configuration](../object_storage.md) | No |
| NFS ([5](#footnotes)) ([7](#footnotes)) | Shared disk storage service. Can be used as an alternative Object Storage. Required for GitLab Pages | [NFS configuration](../high_availability/nfs.md) | No |
| [Consul](../../development/architecture.md#consul) ([3](#footnotes)) | Service discovery and health checks/failover | [Consul configuration](../high_availability/consul.md) **(PREMIUM ONLY)** | Yes |
| [PostgreSQL](../../development/architecture.md#postgresql) | Database | [PostgreSQL configuration](https://docs.gitlab.com/omnibus/settings/database.html) | Yes |
| [PgBouncer](../../development/architecture.md#pgbouncer) | Database connection pooler | [PgBouncer configuration](../postgresql/pgbouncer.md) **(PREMIUM ONLY)** | Yes |
| Repmgr | PostgreSQL cluster management and failover | [PostgreSQL and Repmgr configuration](../postgresql/replication_and_failover.md) | Yes |
| Patroni | An alternative PostgreSQL cluster management and failover | [PostgreSQL and Patroni configuration](../postgresql/replication_and_failover.md#patroni) | Yes |
| [Redis](../../development/architecture.md#redis) ([3](#footnotes)) | Key/value store for fast data lookup and caching | [Redis configuration](../high_availability/redis.md) | Yes |
| Redis Sentinel | Redis | [Redis Sentinel configuration](../high_availability/redis.md) | Yes |
| [Gitaly](../../development/architecture.md#gitaly) ([2](#footnotes)) ([7](#footnotes)) | Provides access to Git repositories | [Gitaly configuration](../gitaly/index.md#run-gitaly-on-its-own-server) | Yes |
| [Sidekiq](../../development/architecture.md#sidekiq) | Asynchronous/background jobs | [Sidekiq configuration](../high_availability/sidekiq.md) | Yes |
| [GitLab application services](../../development/architecture.md#unicorn)([1](#footnotes)) | Puma/Unicorn, Workhorse, GitLab Shell - serves front-end requests (UI, API, Git over HTTP/SSH) | [GitLab app scaling configuration](../high_availability/gitlab.md) | Yes |
| [Prometheus](../../development/architecture.md#prometheus) and [Grafana](../../development/architecture.md#grafana) | GitLab environment monitoring | [Monitoring node for scaling](../high_availability/monitoring_node.md) | Yes |
### Configuring select components with Cloud Native Helm
## Configuring select components with Cloud Native Helm
We also provide [Helm charts](https://docs.gitlab.com/charts/) as a Cloud Native installation
method for GitLab. For the reference architectures, select components can be set up in this
......@@ -205,44 +169,3 @@ specs, only translated into Kubernetes resources.
For example, if you were to set up a 50k installation with the Rails nodes being run in Helm,
then the same amount of resources as given for Omnibus should be given to the Kubernetes
cluster with the Rails nodes broken down into a number of smaller Pods across that cluster.
## Footnotes
1. In our architectures we run each GitLab Rails node using the Puma webserver
and have its number of workers set to 90% of available CPUs along with four threads. For
nodes that are running Rails with other components the worker value should be reduced
accordingly where we've found 50% achieves a good balance but this is dependent
on workload.
1. Gitaly node requirements are dependent on customer data, specifically the number of
projects and their sizes. We recommend that each Gitaly node should store no more than 5TB of data
and have the number of [`gitaly-ruby` workers](../gitaly/index.md#gitaly-ruby)
set to 20% of available CPUs. Additional nodes should be considered in conjunction
with a review of expected data size and spread based on the recommendations above.
1. Recommended Redis setup differs depending on the size of the architecture.
For smaller architectures (less than 3,000 users) a single instance should suffice.
For medium sized installs (3,000 - 5,000) we suggest one Redis cluster for all
classes and that Redis Sentinel is hosted alongside Consul.
For larger architectures (10,000 users or more) we suggest running a separate
[Redis Cluster](../redis/replication_and_failover.md#running-multiple-redis-clusters) for the Cache class
and another for the Queues and Shared State classes respectively. We also recommend
that you run the Redis Sentinel clusters separately for each Redis Cluster.
1. For data objects such as LFS, Uploads, Artifacts, etc. We recommend an [Object Storage service](../object_storage.md)
over NFS where possible, due to better performance.
1. NFS can be used as an alternative for object storage but this isn't typically
recommended for performance reasons. Note however it is required for [GitLab
Pages](https://gitlab.com/gitlab-org/gitlab-pages/-/issues/196).
1. Our architectures have been tested and validated with [HAProxy](https://www.haproxy.org/)
as the load balancer. Although other load balancers with similar feature sets
could also be used, those load balancers have not been validated.
1. We strongly recommend that any Gitaly or NFS nodes be set up with SSD disks over
HDD with a throughput of at least 8,000 IOPS for read operations and 2,000 IOPS for write
as these components have heavy I/O. These IOPS values are recommended only as a starter
as with time they may be adjusted higher or lower depending on the scale of your
environment's workload. If you're running the environment on a Cloud provider
you may need to refer to their documentation on how configure IOPS correctly.
......@@ -325,9 +325,11 @@ Example response:
"design_repositories_failed_count": nil,
"design_repositories_synced_in_percentage": "0.00%",
"projects_count": 41,
"repositories_count": 41,
"repositories_failed_count": nil,
"repositories_synced_count": nil,
"repositories_synced_in_percentage": "0.00%",
"wikis_count": 41,
"wikis_failed_count": nil,
"wikis_synced_count": nil,
"wikis_synced_in_percentage": "0.00%",
......@@ -402,9 +404,11 @@ Example response:
"design_repositories_failed_count": nil,
"design_repositories_synced_in_percentage": "0.00%",
"projects_count": 41,
"repositories_count": 41,
"repositories_failed_count": 1,
"repositories_synced_count": 40,
"repositories_synced_in_percentage": "97.56%",
"wikis_count": 41,
"wikis_failed_count": 0,
"wikis_synced_count": 41,
"wikis_synced_in_percentage": "100.00%",
......@@ -448,9 +452,6 @@ Example response:
]
```
NOTE: **Note:**
In GitLab 12.0, deprecated fields `wikis_count` and `repositories_count` were removed. Use `projects_count` instead.
## Retrieve status about a specific Geo node
```plaintext
......@@ -495,9 +496,11 @@ Example response:
"design_repositories_failed_count": nil,
"design_repositories_synced_in_percentage": "0.00%",
"projects_count": 41,
"repositories_count": 41,
"repositories_failed_count": 1,
"repositories_synced_count": 40,
"repositories_synced_in_percentage": "97.56%",
"wikis_count": 41,
"wikis_failed_count": 0,
"wikis_synced_count": 41,
"wikis_synced_in_percentage": "100.00%",
......@@ -517,9 +520,6 @@ Example response:
Note: The `health_status` parameter can only be in an "Healthy" or "Unhealthy" state, while the `health` parameter can be empty, "Healthy", or contain the actual error message.
NOTE: **Note:**
In GitLab 12.0, deprecated fields `wikis_count` and `repositories_count` were removed. Use `projects_count` instead.
## Retrieve project sync or verification failures that occurred on the current node
This only works on a secondary node.
......
......@@ -319,6 +319,61 @@ type AlertManagementAlert implements Noteable {
"""
title: String
"""
Todos of the current user for the alert
"""
todos(
"""
The action to be filtered
"""
action: [TodoActionEnum!]
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
The ID of an author
"""
authorId: [ID!]
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
The ID of a group
"""
groupId: [ID!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
The ID of a project
"""
projectId: [ID!]
"""
The state of the todo
"""
state: [TodoStateEnum!]
"""
The type of the todo
"""
type: [TodoTargetEnum!]
): TodoConnection
"""
Timestamp the alert was last updated
"""
......
......@@ -871,6 +871,167 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "todos",
"description": "Todos of the current user for the alert",
"args": [
{
"name": "action",
"description": "The action to be filtered",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "TodoActionEnum",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "authorId",
"description": "The ID of an author",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "projectId",
"description": "The ID of a project",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "groupId",
"description": "The ID of a group",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "state",
"description": "The state of the todo",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "TodoStateEnum",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "type",
"description": "The type of the todo",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "TodoTargetEnum",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "TodoConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp the alert was last updated",
---
title: Geo API - Expose repositories_count and wikis_count
merge_request: 38361
author:
type: changed
......@@ -19,6 +19,10 @@ module EE
end
end
# Aliased from :project_count
expose :repositories_count
expose :wikis_count
expose :replication_slots_count
expose :replication_slots_used_count
......
......@@ -27,8 +27,10 @@ FactoryBot.define do
design_repositories_failed_count { 3 }
design_repositories_synced_count { 200 }
projects_count { 10 }
repositories_count { 10 }
repositories_synced_count { 5 }
repositories_failed_count { 0 }
wikis_count { 10 }
wikis_synced_count { 4 }
wikis_failed_count { 1 }
repositories_checksummed_count { 600 }
......
......@@ -33,8 +33,10 @@
"design_repositories_synced_count",
"repositories_replication_enabled",
"projects_count",
"repositories_count",
"repositories_failed_count",
"repositories_synced_count",
"wikis_count",
"wikis_failed_count",
"wikis_synced_count",
"repository_verification_enabled",
......@@ -113,10 +115,12 @@
"design_repositories_synced_in_percentage": { "type": "string" },
"repositories_replication_enabled": { "type": ["boolean", "null"] },
"projects_count": { "type": "integer" },
"repositories_count": { "type": "integer" },
"repositories_failed_count": { "type": ["integer", "null"] },
"repository_verification_enabled": { "type": ["boolean", "null"] },
"repositories_synced_count": { "type": ["integer", "null"] },
"repositories_synced_in_percentage": { "type": "string" },
"wikis_count": { "type": "integer" },
"wikis_failed_count": { "type": ["integer", "null"] },
"wikis_synced_count": { "type": ["integer", "null"] },
"wikis_synced_in_percentage": { "type": "string" },
......
......@@ -24164,6 +24164,9 @@ msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
msgid "There was a problem fetching labels."
msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
......
# frozen_string_literal: true
# This cop checks for use of GraphQL::Types::JSON types in GraphQL fields
# and arguments.
#
# @example
#
# # bad
# class AwfulClass
# field :some_field, GraphQL::Types::JSON
# end
#
# # good
# class GreatClass
# field :some_field, GraphQL::STRING_TYPE
# end
module RuboCop
module Cop
module Graphql
class JSONType < RuboCop::Cop::Cop
MSG = 'Avoid using GraphQL::Types::JSON. See: ' \
'https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#json'.freeze
def_node_matcher :has_json_type?, <<~PATTERN
(send nil? {:field :argument}
(sym _)
(const
(const
(const nil? :GraphQL) :Types) :JSON)
(...)?)
PATTERN
def on_send(node)
add_offense(node, location: :expression) if has_json_type?(node)
end
end
end
end
end
......@@ -232,6 +232,26 @@ RSpec.describe TodosFinder do
expect(todos).to match_array([todo2, todo1])
end
end
context 'when filtering by target id' do
it 'returns the expected todos for the target' do
todos = finder.new(user, { target_id: issue.id }).execute
expect(todos).to match_array([todo1])
end
it 'returns the expected todos for multiple target ids' do
todos = finder.new(user, { target_id: [issue.id, merge_request.id] }).execute
expect(todos).to match_array([todo1, todo2])
end
it 'returns the expected todos for empty target id collection' do
todos = finder.new(user, { target_id: [] }).execute
expect(todos).to match_array([todo1, todo2])
end
end
end
context 'external authorization' do
......@@ -307,9 +327,9 @@ RSpec.describe TodosFinder do
it 'returns the expected types' do
expected_result =
if Gitlab.ee?
%w[Epic Issue MergeRequest DesignManagement::Design]
%w[Epic Issue MergeRequest DesignManagement::Design AlertManagement::Alert]
else
%w[Issue MergeRequest DesignManagement::Design]
%w[Issue MergeRequest DesignManagement::Design AlertManagement::Alert]
end
expect(described_class.todo_types).to contain_exactly(*expected_result)
......
import { shallowMount } from '@vue/test-utils';
import { shallowMount, mount } from '@vue/test-utils';
import {
GlFilteredSearch,
GlButtonGroup,
......@@ -16,13 +16,16 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se
import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data';
const createComponent = ({
shallow = true,
namespace = 'gitlab-org/gitlab-test',
recentSearchesStorageKey = 'requirements',
tokens = mockAvailableTokens,
sortOptions = mockSortOptions,
searchInputPlaceholder = 'Filter requirements',
} = {}) =>
shallowMount(FilteredSearchBarRoot, {
} = {}) => {
const mountMethod = shallow ? shallowMount : mount;
return mountMethod(FilteredSearchBarRoot, {
propsData: {
namespace,
recentSearchesStorageKey,
......@@ -31,6 +34,7 @@ const createComponent = ({
searchInputPlaceholder,
},
});
};
describe('FilteredSearchBarRoot', () => {
let wrapper;
......@@ -54,13 +58,13 @@ describe('FilteredSearchBarRoot', () => {
describe('computed', () => {
describe('tokenSymbols', () => {
it('returns a map containing type and symbols from `tokens` prop', () => {
expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' });
expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' });
});
});
describe('tokenTitles', () => {
it('returns a map containing type and title from `tokens` prop', () => {
expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' });
expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' });
});
});
......@@ -233,6 +237,14 @@ describe('FilteredSearchBarRoot', () => {
});
});
it('calls `blurSearchInput` method to remove focus from filter input field', () => {
jest.spyOn(wrapper.vm, 'blurSearchInput');
wrapper.find(GlFilteredSearch).vm.$emit('submit', mockFilters);
expect(wrapper.vm.blurSearchInput).toHaveBeenCalled();
});
it('emits component event `onFilter` with provided filters param', () => {
wrapper.vm.handleFilterSubmit(mockFilters);
......@@ -260,13 +272,28 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
});
it('renders search history items dropdown with formatting done using token symbols', async () => {
const wrapperFullMount = createComponent({ shallow: false });
wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]);
await wrapperFullMount.vm.$nextTick();
const searchHistoryItemsEl = wrapperFullMount.findAll(
'.gl-search-box-by-click-menu .gl-search-box-by-click-history-item',
);
expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"');
wrapperFullMount.destroy();
});
it('renders sort dropdown component', () => {
expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
expect(wrapper.find(GlDropdown).exists()).toBe(true);
expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title);
});
it('renders dropdown items', () => {
it('renders sort dropdown items', () => {
const dropdownItemsEl = wrapper.findAll(GlDropdownItem);
expect(dropdownItemsEl).toHaveLength(mockSortOptions.length);
......
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
export const mockAuthor1 = {
id: 1,
......@@ -42,7 +45,18 @@ export const mockAuthorToken = {
fetchAuthors: Api.projectUsers.bind(Api),
};
export const mockAvailableTokens = [mockAuthorToken];
export const mockLabelToken = {
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: () => Promise.resolve(mockLabels),
};
export const mockAvailableTokens = [mockAuthorToken, mockLabelToken];
export const mockHistoryItems = [
[
......@@ -53,6 +67,13 @@ export const mockHistoryItems = [
operator: '=',
},
},
{
type: 'label_name',
value: {
data: 'Bug',
operator: '=',
},
},
'duo',
],
[
......
import { mount } from '@vue/test-utils';
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import {
mockRegularLabel,
mockLabels,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import { mockLabelToken } from '../mock_data';
jest.mock('~/flash');
const createComponent = ({ config = mockLabelToken, value = { data: '' }, active = false } = {}) =>
mount(LabelToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
stubs: {
Portal: {
template: '<div><slot></slot></div>',
},
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
},
});
describe('LabelToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
// Label title with spaces is always enclosed in quotations by component.
wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
wrapper.setData({
labels: mockLabels,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe('"foo label"');
});
});
describe('activeLabel', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeLabel).toEqual(mockRegularLabel);
});
});
describe('containerStyle', () => {
it('returns object containing `backgroundColor` and `color` properties based on `activeLabel` value', () => {
expect(wrapper.vm.containerStyle).toEqual({
backgroundColor: mockRegularLabel.color,
color: mockRegularLabel.textColor,
});
});
it('returns empty object when `activeLabel` is not set', async () => {
wrapper.setData({
labels: [],
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.containerStyle).toEqual({});
});
});
});
describe('methods', () => {
describe('fetchLabelBySearchTerm', () => {
it('calls `config.fetchLabels` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels');
wrapper.vm.fetchLabelBySearchTerm('foo');
expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo');
});
it('sets response to `labels` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels);
wrapper.vm.fetchLabelBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.labels).toEqual(mockLabels);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
wrapper.vm.fetchLabelBySearchTerm('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was a problem fetching labels.');
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({});
wrapper.vm.fetchLabelBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
});
describe('template', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
wrapper.setData({
labels: mockLabels,
});
await wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
});
it('renders token item when value is selected', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // Label, =, "Foo Label"
expect(tokenSegments.at(2).text()).toBe(`~${mockRegularLabel.title}`); // "Foo Label"
expect(
tokenSegments
.at(2)
.find('.gl-token')
.attributes('style'),
).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);');
});
});
});
......@@ -99,7 +99,7 @@ RSpec.describe Resolvers::TodoResolver do
end
end
context 'when no user is provided' do
context 'when no target is provided' do
it 'returns no todos' do
todos = resolve(described_class, obj: nil, args: {}, ctx: { current_user: current_user })
......@@ -107,7 +107,7 @@ RSpec.describe Resolvers::TodoResolver do
end
end
context 'when provided user is not current user' do
context 'when target user is not the current user' do
it 'returns no todos' do
other_user = create(:user)
......@@ -116,6 +116,16 @@ RSpec.describe Resolvers::TodoResolver do
expect(todos).to be_empty
end
end
context 'when request is for a todo target' do
it 'returns only the todos for the target' do
target = issue_todo_pending.target
todos = resolve(described_class, obj: target, args: {}, ctx: { current_user: current_user })
expect(todos).to contain_exactly(issue_todo_pending)
end
end
end
def resolve_todos(args = {}, context = { current_user: current_user })
......
......@@ -28,6 +28,7 @@ RSpec.describe GitlabSchema.types['AlertManagementAlert'] do
notes
discussions
metrics_dashboard_url
todos
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting Alert Management Alert Assignees' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:alert) { create(:alert_management_alert, project: project) }
let_it_be(:other_alert) { create(:alert_management_alert, project: project) }
let_it_be(:todo) { create(:todo, :pending, target: alert, user: current_user, project: project) }
let_it_be(:other_todo) { create(:todo, :pending, target: other_alert, user: current_user, project: project) }
let(:fields) do
<<~QUERY
nodes {
iid
todos {
nodes {
id
}
}
}
QUERY
end
let(:graphql_query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('alertManagementAlerts', {}, fields)
)
end
let(:gql_alerts) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') }
let(:gql_todos) { gql_alerts.map { |gql_alert| [gql_alert['iid'], gql_alert['todos']['nodes']] }.to_h }
let(:gql_alert_todo) { gql_todos[alert.iid.to_s].first }
let(:gql_other_alert_todo) { gql_todos[other_alert.iid.to_s].first }
before do
project.add_developer(current_user)
end
it 'includes the correct metrics dashboard url' do
post_graphql(graphql_query, current_user: current_user)
expect(gql_alert_todo['id']).to eq(todo.to_global_id.to_s)
expect(gql_other_alert_todo['id']).to eq(other_todo.to_global_id.to_s)
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/json_type'
RSpec.describe RuboCop::Cop::Graphql::JSONType, type: :rubocop do
include CopHelper
subject(:cop) { described_class.new }
context 'fields' do
it 'adds an offense when GraphQL::Types::JSON is used' do
inspect_source(<<~RUBY.strip)
class MyType
field :some_field, GraphQL::Types::JSON
end
RUBY
expect(cop.offenses.size).to eq(1)
end
it 'adds an offense when GraphQL::Types::JSON is used with other keywords' do
inspect_source(<<~RUBY.strip)
class MyType
field :some_field, GraphQL::Types::JSON, null: true, description: 'My description'
end
RUBY
expect(cop.offenses.size).to eq(1)
end
it 'does not add an offense for other types' do
expect_no_offenses(<<~RUBY.strip)
class MyType
field :some_field, GraphQL::STRING_TYPE
end
RUBY
end
end
context 'arguments' do
it 'adds an offense when GraphQL::Types::JSON is used' do
inspect_source(<<~RUBY.strip)
class MyType
argument :some_arg, GraphQL::Types::JSON
end
RUBY
expect(cop.offenses.size).to eq(1)
end
it 'adds an offense when GraphQL::Types::JSON is used with other keywords' do
inspect_source(<<~RUBY.strip)
class MyType
argument :some_arg, GraphQL::Types::JSON, null: true, description: 'My description'
end
RUBY
expect(cop.offenses.size).to eq(1)
end
it 'does not add an offense for other types' do
expect_no_offenses(<<~RUBY.strip)
class MyType
argument :some_arg, GraphQL::STRING_TYPE
end
RUBY
end
end
it 'does not add an offense for uses outside of field or argument' do
expect_no_offenses(<<~RUBY.strip)
class MyType
foo :some_field, GraphQL::Types::JSON
end
RUBY
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment