summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2018-11-27 15:10:40 +0000
committerPhil Hughes <me@iamphill.com>2018-11-27 15:10:40 +0000
commit50e21a89a0009813b9f090288b22c64c5cefbd58 (patch)
tree7a142c12573f872f14ac366ec00082c650664592
parent15b4a8f93aaf313b8197ca381f529b00bd231a20 (diff)
downloadgitlab-ce-50e21a89a0009813b9f090288b22c64c5cefbd58.tar.gz
Suggests issues when typing title
This suggests possibly related issues when the user types a title. This uses GraphQL to allow the frontend to request the exact data that is requires. We also get free caching through the Vue Apollo plugin. With this we can include the ability to import .graphql files in JS and Vue files. Also we now have the Vue test utils library to make testing Vue components easier. Closes #22071
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/app.vue96
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue137
-rw-r--r--app/assets/javascripts/issuable_suggestions/index.js38
-rw-r--r--app/assets/javascripts/issuable_suggestions/queries/issues.graphql26
-rw-r--r--app/assets/javascripts/lib/graphql.js9
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js5
-rw-r--r--app/assets/stylesheets/pages/issuable.scss34
-rw-r--r--app/controllers/projects/issues_controller.rb7
-rw-r--r--app/graphql/resolvers/issues_resolver.rb25
-rw-r--r--app/graphql/types/issue_type.rb47
-rw-r--r--app/graphql/types/label_type.rb12
-rw-r--r--app/graphql/types/milestone_type.rb17
-rw-r--r--app/graphql/types/order.rb8
-rw-r--r--app/graphql/types/permission_types/issue.rb14
-rw-r--r--app/graphql/types/project_type.rb5
-rw-r--r--app/graphql/types/sort.rb10
-rw-r--r--app/graphql/types/user_type.rb14
-rw-r--r--app/policies/milestone_policy.rb5
-rw-r--r--app/presenters/issue_presenter.rb9
-rw-r--r--app/presenters/user_presenter.rb9
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--config/webpack.config.js12
-rw-r--r--lib/gitlab/graphql/loaders/batch_model_loader.rb29
-rw-r--r--locale/gitlab.pot15
-rw-r--r--package.json5
-rw-r--r--spec/features/issues_spec.rb12
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb40
-rw-r--r--spec/graphql/types/issue_type_spec.rb7
-rw-r--r--spec/graphql/types/permission_types/issue_spec.rb12
-rw-r--r--spec/graphql/types/project_type_spec.rb4
-rw-r--r--spec/javascripts/issuable_suggestions/components/app_spec.js96
-rw-r--r--spec/javascripts/issuable_suggestions/components/item_spec.js139
-rw-r--r--spec/javascripts/issuable_suggestions/mock_data.js26
-rw-r--r--spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb28
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb59
-rw-r--r--yarn.lock173
36 files changed, 1185 insertions, 1 deletions
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue
new file mode 100644
index 00000000000..eea0701312b
--- /dev/null
+++ b/app/assets/javascripts/issuable_suggestions/components/app.vue
@@ -0,0 +1,96 @@
+<script>
+import _ from 'underscore';
+import { GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import Suggestion from './item.vue';
+import query from '../queries/issues.graphql';
+
+export default {
+ components: {
+ Suggestion,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ search: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ issues: {
+ query,
+ debounce: 250,
+ skip() {
+ return this.isSearchEmpty;
+ },
+ update: data => data.project.issues.edges.map(({ node }) => node),
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ search: this.search,
+ };
+ },
+ },
+ },
+ data() {
+ return {
+ issues: [],
+ loading: 0,
+ };
+ },
+ computed: {
+ isSearchEmpty() {
+ return _.isEmpty(this.search);
+ },
+ showSuggestions() {
+ return !this.isSearchEmpty && this.issues.length && !this.loading;
+ },
+ },
+ watch: {
+ search() {
+ if (this.isSearchEmpty) {
+ this.issues = [];
+ }
+ },
+ },
+ helpText: __(
+ 'These existing issues have a similar title. It might be better to comment there instead of creating another similar issue.',
+ ),
+};
+</script>
+
+<template>
+ <div v-show="showSuggestions" class="form-group row issuable-suggestions">
+ <div v-once class="col-form-label col-sm-2 pt-0">
+ {{ __('Similar issues') }}
+ <icon
+ v-gl-tooltip.bottom
+ :title="$options.helpText"
+ :aria-label="$options.helpText"
+ name="question-o"
+ class="text-secondary suggestion-help-hover"
+ />
+ </div>
+ <div class="col-sm-10">
+ <ul class="list-unstyled m-0">
+ <li
+ v-for="(suggestion, index) in issues"
+ :key="suggestion.id"
+ :class="{
+ 'append-bottom-default': index !== issues.length - 1,
+ }"
+ >
+ <suggestion :suggestion="suggestion" />
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
new file mode 100644
index 00000000000..9a16b486bf5
--- /dev/null
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -0,0 +1,137 @@
+<script>
+import _ from 'underscore';
+import { GlLink, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeago from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ Icon,
+ UserAvatarImage,
+ TimeagoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeago],
+ props: {
+ suggestion: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isOpen() {
+ return this.suggestion.state === 'opened';
+ },
+ isClosed() {
+ return this.suggestion.state === 'closed';
+ },
+ counts() {
+ return [
+ {
+ id: _.uniqueId(),
+ icon: 'thumb-up',
+ tooltipTitle: __('Upvotes'),
+ count: this.suggestion.upvotes,
+ },
+ {
+ id: _.uniqueId(),
+ icon: 'comment',
+ tooltipTitle: __('Comments'),
+ count: this.suggestion.userNotesCount,
+ },
+ ].filter(({ count }) => count);
+ },
+ stateIcon() {
+ return this.isClosed ? 'issue-close' : 'issue-open-m';
+ },
+ stateTitle() {
+ return this.isClosed ? __('Closed') : __('Opened');
+ },
+ closedOrCreatedDate() {
+ return this.suggestion.closedAt || this.suggestion.createdAt;
+ },
+ hasUpdated() {
+ return this.suggestion.updatedAt !== this.suggestion.createdAt;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="suggestion-item">
+ <div class="d-flex align-items-center">
+ <icon
+ v-if="suggestion.confidential"
+ v-gl-tooltip.bottom
+ :title="__('Confidential')"
+ name="eye-slash"
+ class="suggestion-help-hover mr-1 suggestion-confidential"
+ />
+ <gl-link :href="suggestion.webUrl" target="_blank" class="suggestion bold str-truncated-100">
+ {{ suggestion.title }}
+ </gl-link>
+ </div>
+ <div class="text-secondary suggestion-footer">
+ <icon
+ ref="state"
+ :name="stateIcon"
+ :class="{
+ 'suggestion-state-open': isOpen,
+ 'suggestion-state-closed': isClosed,
+ }"
+ class="suggestion-help-hover"
+ />
+ <gl-tooltip :target="() => $refs.state" placement="bottom">
+ <span class="d-block">
+ <span class="bold"> {{ stateTitle }} </span> {{ timeFormated(closedOrCreatedDate) }}
+ </span>
+ <span class="text-tertiary">{{ tooltipTitle(closedOrCreatedDate) }}</span>
+ </gl-tooltip>
+ #{{ suggestion.iid }} &bull;
+ <timeago-tooltip
+ :time="suggestion.createdAt"
+ tooltip-placement="bottom"
+ class="suggestion-help-hover"
+ />
+ by
+ <gl-link :href="suggestion.author.webUrl">
+ <user-avatar-image
+ :img-src="suggestion.author.avatarUrl"
+ :size="16"
+ css-classes="mr-0 float-none"
+ tooltip-placement="bottom"
+ class="d-inline-block"
+ >
+ <span class="bold d-block">{{ __('Author') }}</span> {{ suggestion.author.name }}
+ <span class="text-tertiary">@{{ suggestion.author.username }}</span>
+ </user-avatar-image>
+ </gl-link>
+ <template v-if="hasUpdated">
+ &bull; {{ __('updated') }}
+ <timeago-tooltip
+ :time="suggestion.updatedAt"
+ tooltip-placement="bottom"
+ class="suggestion-help-hover"
+ />
+ </template>
+ <span class="suggestion-counts">
+ <span
+ v-for="{ count, icon, tooltipTitle, id } in counts"
+ :key="id"
+ v-gl-tooltip.bottom
+ :title="tooltipTitle"
+ class="suggestion-help-hover prepend-left-8 text-tertiary"
+ >
+ <icon :name="icon" /> {{ count }}
+ </span>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js
new file mode 100644
index 00000000000..2c80cf1797a
--- /dev/null
+++ b/app/assets/javascripts/issuable_suggestions/index.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import defaultClient from '~/lib/graphql';
+import App from './components/app.vue';
+
+Vue.use(VueApollo);
+
+export default function() {
+ const el = document.getElementById('js-suggestions');
+ const issueTitle = document.getElementById('issue_title');
+ const { projectPath } = el.dataset;
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ data() {
+ return {
+ search: issueTitle.value,
+ };
+ },
+ mounted() {
+ issueTitle.addEventListener('input', () => {
+ this.search = issueTitle.value;
+ });
+ },
+ render(h) {
+ return h(App, {
+ props: {
+ projectPath,
+ search: this.search,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/issuable_suggestions/queries/issues.graphql b/app/assets/javascripts/issuable_suggestions/queries/issues.graphql
new file mode 100644
index 00000000000..2384b381344
--- /dev/null
+++ b/app/assets/javascripts/issuable_suggestions/queries/issues.graphql
@@ -0,0 +1,26 @@
+query issueSuggestion($fullPath: ID!, $search: String) {
+ project(fullPath: $fullPath) {
+ issues(search: $search, sort: updated_desc, first: 5) {
+ edges {
+ node {
+ iid
+ title
+ confidential
+ userNotesCount
+ upvotes
+ webUrl
+ state
+ closedAt
+ createdAt
+ updatedAt
+ author {
+ name
+ username
+ avatarUrl
+ webUrl
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
new file mode 100644
index 00000000000..20a0f142d9e
--- /dev/null
+++ b/app/assets/javascripts/lib/graphql.js
@@ -0,0 +1,9 @@
+import ApolloClient from 'apollo-boost';
+import csrf from '~/lib/utils/csrf';
+
+export default new ApolloClient({
+ uri: `${gon.relative_url_root}/api/graphql`,
+ headers: {
+ [csrf.headerKey]: csrf.token,
+ },
+});
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 197bfa8a394..02a56685a35 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -7,6 +7,7 @@ import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
+import initSuggestions from '~/issuable_suggestions';
export default () => {
new ShortcutsNavigation();
@@ -15,4 +16,8 @@ export default () => {
new LabelsSelect();
new MilestoneSelect();
new IssuableTemplateSelectors();
+
+ if (gon.features.issueSuggestions && gon.features.graphql) {
+ initSuggestions();
+ }
};
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 78c5ae9ae63..5b5f486ea63 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -938,3 +938,37 @@
}
}
}
+
+.issuable-suggestions svg {
+ vertical-align: sub;
+}
+
+.suggestion-item a {
+ color: initial;
+}
+
+.suggestion-confidential {
+ color: $orange-600;
+}
+
+.suggestion-state-open {
+ color: $green-500;
+}
+
+.suggestion-state-closed {
+ color: $blue-500;
+}
+
+.suggestion-help-hover {
+ cursor: help;
+}
+
+.suggestion-footer {
+ font-size: 12px;
+ line-height: 15px;
+
+ .avatar {
+ margin-top: -3px;
+ border: 0;
+ }
+}
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 308f666394c..d6d7110355b 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -38,6 +38,8 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
+ before_action :set_suggested_issues_feature_flags, only: [:new]
+
respond_to :html
def index
@@ -263,4 +265,9 @@ class Projects::IssuesController < Projects::ApplicationController
# 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422')
end
+
+ def set_suggested_issues_feature_flags
+ push_frontend_feature_flag(:graphql)
+ push_frontend_feature_flag(:issue_suggestions)
+ end
end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
new file mode 100644
index 00000000000..4ab3c13787a
--- /dev/null
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class IssuesResolver < BaseResolver
+ extend ActiveSupport::Concern
+
+ argument :search, GraphQL::STRING_TYPE,
+ required: false
+ argument :sort, Types::Sort,
+ required: false,
+ default_value: 'created_desc'
+
+ type Types::IssueType, null: true
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ # Will need to be be made group & namespace aware with
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/54520
+ args[:project_id] = project.id
+
+ IssuesFinder.new(context[:current_user], args).execute
+ end
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
new file mode 100644
index 00000000000..a8f2f7914a8
--- /dev/null
+++ b/app/graphql/types/issue_type.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Types
+ class IssueType < BaseObject
+ expose_permissions Types::PermissionTypes::Issue
+
+ graphql_name 'Issue'
+
+ present_using IssuePresenter
+
+ field :iid, GraphQL::ID_TYPE, null: false
+ field :title, GraphQL::STRING_TYPE, null: false
+ field :description, GraphQL::STRING_TYPE, null: true
+ field :state, GraphQL::STRING_TYPE, null: false
+
+ field :author, Types::UserType,
+ null: false,
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do
+ authorize :read_user
+ end
+
+ field :assignees, Types::UserType.connection_type, null: true
+
+ field :labels, Types::LabelType.connection_type, null: true
+ field :milestone, Types::MilestoneType,
+ null: true,
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do
+ authorize :read_milestone
+ end
+
+ field :due_date, Types::TimeType, null: true
+ field :confidential, GraphQL::BOOLEAN_TYPE, null: false
+ field :discussion_locked, GraphQL::BOOLEAN_TYPE,
+ null: false,
+ resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked }
+
+ field :upvotes, GraphQL::INT_TYPE, null: false
+ field :downvotes, GraphQL::INT_TYPE, null: false
+ field :user_notes_count, GraphQL::INT_TYPE, null: false
+ field :web_url, GraphQL::STRING_TYPE, null: false
+
+ field :closed_at, Types::TimeType, null: true
+
+ field :created_at, Types::TimeType, null: false
+ field :updated_at, Types::TimeType, null: false
+ end
+end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
new file mode 100644
index 00000000000..ccd466edc1a
--- /dev/null
+++ b/app/graphql/types/label_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class LabelType < BaseObject
+ graphql_name 'Label'
+
+ field :description, GraphQL::STRING_TYPE, null: true
+ field :title, GraphQL::STRING_TYPE, null: false
+ field :color, GraphQL::STRING_TYPE, null: false
+ field :text_color, GraphQL::STRING_TYPE, null: false
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
new file mode 100644
index 00000000000..af31b572c9a
--- /dev/null
+++ b/app/graphql/types/milestone_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class MilestoneType < BaseObject
+ graphql_name 'Milestone'
+
+ field :description, GraphQL::STRING_TYPE, null: true
+ field :title, GraphQL::STRING_TYPE, null: false
+ field :state, GraphQL::STRING_TYPE, null: false
+
+ field :due_date, Types::TimeType, null: true
+ field :start_date, Types::TimeType, null: true
+
+ field :created_at, Types::TimeType, null: false
+ field :updated_at, Types::TimeType, null: false
+ end
+end
diff --git a/app/graphql/types/order.rb b/app/graphql/types/order.rb
new file mode 100644
index 00000000000..c5e1cc406b4
--- /dev/null
+++ b/app/graphql/types/order.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class Types::Order < Types::BaseEnum
+ value "id", "Created at date"
+ value "updated_at", "Updated at date"
+ end
+end
diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb
new file mode 100644
index 00000000000..199540c7d6d
--- /dev/null
+++ b/app/graphql/types/permission_types/issue.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class Issue < BasePermissionType
+ description 'Check permissions for the current user on a issue'
+ graphql_name 'IssuePermissions'
+
+ abilities :read_issue, :admin_issue,
+ :update_issue, :create_note,
+ :reopen_issue
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 7b879608b34..050706f97be 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -73,6 +73,11 @@ module Types
authorize :read_merge_request
end
+ field :issues,
+ Types::IssueType.connection_type,
+ null: true,
+ resolver: Resolvers::IssuesResolver
+
field :pipelines,
Types::Ci::PipelineType.connection_type,
null: false,
diff --git a/app/graphql/types/sort.rb b/app/graphql/types/sort.rb
new file mode 100644
index 00000000000..1f756fdab69
--- /dev/null
+++ b/app/graphql/types/sort.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ class Types::Sort < Types::BaseEnum
+ value "updated_desc", "Updated at descending order"
+ value "updated_asc", "Updated at ascending order"
+ value "created_desc", "Created at descending order"
+ value "created_asc", "Created at ascending order"
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
new file mode 100644
index 00000000000..a13e65207df
--- /dev/null
+++ b/app/graphql/types/user_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class UserType < BaseObject
+ graphql_name 'User'
+
+ present_using UserPresenter
+
+ field :name, GraphQL::STRING_TYPE, null: false
+ field :username, GraphQL::STRING_TYPE, null: false
+ field :avatar_url, GraphQL::STRING_TYPE, null: false
+ field :web_url, GraphQL::STRING_TYPE, null: false
+ end
+end
diff --git a/app/policies/milestone_policy.rb b/app/policies/milestone_policy.rb
new file mode 100644
index 00000000000..ac4f5b08504
--- /dev/null
+++ b/app/policies/milestone_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class MilestonePolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
new file mode 100644
index 00000000000..c12a202efbc
--- /dev/null
+++ b/app/presenters/issue_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class IssuePresenter < Gitlab::View::Presenter::Delegated
+ presents :issue
+
+ def web_url
+ Gitlab::UrlBuilder.build(issue)
+ end
+end
diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb
new file mode 100644
index 00000000000..14ef53e9ec8
--- /dev/null
+++ b/app/presenters/user_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class UserPresenter < Gitlab::View::Presenter::Delegated
+ presents :user
+
+ def web_url
+ Gitlab::Routing.url_helpers.user_url(user)
+ end
+end
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index b33c758b464..1618655182c 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -17,6 +17,8 @@
= render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
+- if Feature.enabled?(:issue_suggestions) && Feature.enabled?(:graphql)
+ #js-suggestions{ data: { project_path: @project.full_path } }
= render 'shared/form_elements/description', model: issuable, form: form, project: project
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 9ecae9790fd..b9044e13f50 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -84,7 +84,7 @@ module.exports = {
},
resolve: {
- extensions: ['.js'],
+ extensions: ['.js', '.gql', '.graphql'],
alias: {
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
emojis: path.join(ROOT_PATH, 'fixtures/emojis'),
@@ -101,6 +101,11 @@ module.exports = {
strictExportPresence: true,
rules: [
{
+ type: 'javascript/auto',
+ test: /\.mjs$/,
+ use: [],
+ },
+ {
test: /\.js$/,
exclude: path => /node_modules|vendor[\\/]assets/.test(path) && !/\.vue\.js/.test(path),
loader: 'babel-loader',
@@ -122,6 +127,11 @@ module.exports = {
},
},
{
+ test: /\.(graphql|gql)$/,
+ exclude: /node_modules/,
+ loader: 'graphql-tag/loader',
+ },
+ {
test: /\.svg$/,
loader: 'raw-loader',
},
diff --git a/lib/gitlab/graphql/loaders/batch_model_loader.rb b/lib/gitlab/graphql/loaders/batch_model_loader.rb
new file mode 100644
index 00000000000..5a0099dc6b1
--- /dev/null
+++ b/lib/gitlab/graphql/loaders/batch_model_loader.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Graphql
+ module Loaders
+ class BatchModelLoader
+ attr_reader :model_class, :model_id
+
+ def initialize(model_class, model_id)
+ @model_class, @model_id = model_class, model_id
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find
+ BatchLoader.for({ model: model_class, id: model_id }).batch do |loader_info, loader|
+ per_model = loader_info.group_by { |info| info[:model] }
+ per_model.each do |model, info|
+ ids = info.map { |i| i[:id] }
+ results = model.where(id: ids)
+
+ results.each { |record| loader.call({ model: model, id: record.id }, record) }
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 71374388a7d..805a2f2a327 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1379,6 +1379,9 @@ msgstr ""
msgid "Close"
msgstr ""
+msgid "Closed"
+msgstr ""
+
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
@@ -4467,6 +4470,9 @@ msgstr ""
msgid "Open source software to collaborate on code"
msgstr ""
+msgid "Opened"
+msgstr ""
+
msgid "OpenedNDaysAgo|Opened"
msgstr ""
@@ -5856,6 +5862,9 @@ msgstr ""
msgid "Sign-up restrictions"
msgstr ""
+msgid "Similar issues"
+msgstr ""
+
msgid "Size and domain settings for static websites"
msgstr ""
@@ -6392,6 +6401,9 @@ msgstr ""
msgid "There was an error when unsubscribing from this label."
msgstr ""
+msgid "These existing issues have a similar title. It might be better to comment there instead of creating another similar issue."
+msgstr ""
+
msgid "They can be managed using the %{link}."
msgstr ""
@@ -7840,6 +7852,9 @@ msgstr ""
msgid "this document"
msgstr ""
+msgid "updated"
+msgstr ""
+
msgid "username"
msgstr ""
diff --git a/package.json b/package.json
index 64df2532977..62e48625c05 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,8 @@
"@babel/preset-env": "^7.1.0",
"@gitlab/svgs": "^1.38.0",
"@gitlab/ui": "^1.11.0",
+ "apollo-boost": "^0.1.20",
+ "apollo-client": "^2.4.5",
"autosize": "^4.0.0",
"axios": "^0.17.1",
"babel-loader": "^8.0.4",
@@ -60,6 +62,7 @@
"formdata-polyfill": "^3.0.11",
"fuzzaldrin-plus": "^0.5.0",
"glob": "^7.1.2",
+ "graphql": "^14.0.2",
"imports-loader": "^0.8.0",
"jed": "^1.1.1",
"jquery": "^3.2.1",
@@ -97,6 +100,7 @@
"url-loader": "^1.1.1",
"visibilityjs": "^1.2.4",
"vue": "^2.5.17",
+ "vue-apollo": "^3.0.0-beta.25",
"vue-loader": "^15.4.2",
"vue-resource": "^1.5.0",
"vue-router": "^3.0.1",
@@ -127,6 +131,7 @@
"eslint-plugin-jasmine": "^2.10.1",
"gettext-extractor": "^3.3.2",
"gettext-extractor-vue": "^4.0.1",
+ "graphql-tag": "^2.10.0",
"istanbul": "^0.4.5",
"jasmine-core": "^2.9.0",
"jasmine-diff": "^0.1.3",
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index 5c1ffb76351..406e80e91aa 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -682,6 +682,18 @@ describe 'Issues' do
expect(find('.js-issuable-selector .dropdown-toggle-text')).to have_content('bug')
end
end
+
+ context 'suggestions', :js do
+ it 'displays list of related issues' do
+ create(:issue, project: project, title: 'test issue')
+
+ visit new_project_issue_path(project)
+
+ fill_in 'issue_title', with: issue.title
+
+ expect(page).to have_selector('.suggestion-item', count: 1)
+ end
+ end
end
describe 'new issue by email' do
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
new file mode 100644
index 00000000000..ca90673521c
--- /dev/null
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+
+describe Resolvers::IssuesResolver do
+ include GraphqlHelpers
+
+ let(:current_user) { create(:user) }
+ set(:project) { create(:project) }
+ set(:issue) { create(:issue, project: project) }
+ set(:issue2) { create(:issue, project: project, title: 'foo') }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ describe '#resolve' do
+ it 'finds all issues' do
+ expect(resolve_issues).to contain_exactly(issue, issue2)
+ end
+
+ it 'searches issues' do
+ expect(resolve_issues(search: 'foo')).to contain_exactly(issue2)
+ end
+
+ it 'sort issues' do
+ expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue]
+ end
+
+ it 'returns issues user can see' do
+ project.add_guest(current_user)
+
+ create(:issue, confidential: true)
+
+ expect(resolve_issues).to contain_exactly(issue, issue2)
+ end
+ end
+
+ def resolve_issues(args = {}, context = { current_user: current_user })
+ resolve(described_class, obj: project, args: args, ctx: context)
+ end
+end
diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb
new file mode 100644
index 00000000000..63a07647a60
--- /dev/null
+++ b/spec/graphql/types/issue_type_spec.rb
@@ -0,0 +1,7 @@
+require 'spec_helper'
+
+describe GitlabSchema.types['Issue'] do
+ it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Issue) }
+
+ it { expect(described_class.graphql_name).to eq('Issue') }
+end
diff --git a/spec/graphql/types/permission_types/issue_spec.rb b/spec/graphql/types/permission_types/issue_spec.rb
new file mode 100644
index 00000000000..c3f84629aa2
--- /dev/null
+++ b/spec/graphql/types/permission_types/issue_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+
+describe Types::PermissionTypes::Issue do
+ it do
+ expected_permissions = [
+ :read_issue, :admin_issue, :update_issue,
+ :create_note, :reopen_issue
+ ]
+
+ expect(described_class).to have_graphql_fields(expected_permissions)
+ end
+end
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 49606c397b9..61d4c42665a 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -14,5 +14,9 @@ describe GitlabSchema.types['Project'] do
end
end
+ describe 'nested issues' do
+ it { expect(described_class).to have_graphql_field(:issues) }
+ end
+
it { is_expected.to have_graphql_field(:pipelines) }
end
diff --git a/spec/javascripts/issuable_suggestions/components/app_spec.js b/spec/javascripts/issuable_suggestions/components/app_spec.js
new file mode 100644
index 00000000000..7bb8e26b81a
--- /dev/null
+++ b/spec/javascripts/issuable_suggestions/components/app_spec.js
@@ -0,0 +1,96 @@
+import { shallowMount } from '@vue/test-utils';
+import App from '~/issuable_suggestions/components/app.vue';
+import Suggestion from '~/issuable_suggestions/components/item.vue';
+
+describe('Issuable suggestions app component', () => {
+ let vm;
+
+ function createComponent(search = 'search') {
+ vm = shallowMount(App, {
+ propsData: {
+ search,
+ projectPath: 'project',
+ },
+ });
+ }
+
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it('does not render with empty search', () => {
+ createComponent('');
+
+ expect(vm.isVisible()).toBe(false);
+ });
+
+ describe('with data', () => {
+ let data;
+
+ beforeEach(() => {
+ data = { issues: [{ id: 1 }, { id: 2 }] };
+ });
+
+ it('renders component', () => {
+ createComponent();
+ vm.setData(data);
+
+ expect(vm.isEmpty()).toBe(false);
+ });
+
+ it('does not render with empty search', () => {
+ createComponent('');
+ vm.setData(data);
+
+ expect(vm.isVisible()).toBe(false);
+ });
+
+ it('does not render when loading', () => {
+ createComponent();
+ vm.setData({
+ ...data,
+ loading: 1,
+ });
+
+ expect(vm.isVisible()).toBe(false);
+ });
+
+ it('does not render with empty issues data', () => {
+ createComponent();
+ vm.setData({ issues: [] });
+
+ expect(vm.isVisible()).toBe(false);
+ });
+
+ it('renders list of issues', () => {
+ createComponent();
+ vm.setData(data);
+
+ expect(vm.findAll(Suggestion).length).toBe(2);
+ });
+
+ it('adds margin class to first item', () => {
+ createComponent();
+ vm.setData(data);
+
+ expect(
+ vm
+ .findAll('li')
+ .at(0)
+ .is('.append-bottom-default'),
+ ).toBe(true);
+ });
+
+ it('does not add margin class to last item', () => {
+ createComponent();
+ vm.setData(data);
+
+ expect(
+ vm
+ .findAll('li')
+ .at(1)
+ .is('.append-bottom-default'),
+ ).toBe(false);
+ });
+ });
+});
diff --git a/spec/javascripts/issuable_suggestions/components/item_spec.js b/spec/javascripts/issuable_suggestions/components/item_spec.js
new file mode 100644
index 00000000000..7bd1fe678f4
--- /dev/null
+++ b/spec/javascripts/issuable_suggestions/components/item_spec.js
@@ -0,0 +1,139 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlTooltip, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import Suggestion from '~/issuable_suggestions/components/item.vue';
+import mockData from '../mock_data';
+
+describe('Issuable suggestions suggestion component', () => {
+ let vm;
+
+ function createComponent(suggestion = {}) {
+ vm = shallowMount(Suggestion, {
+ propsData: {
+ suggestion: {
+ ...mockData(),
+ ...suggestion,
+ },
+ },
+ });
+ }
+
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it('renders title', () => {
+ createComponent();
+
+ expect(vm.text()).toContain('Test issue');
+ });
+
+ it('renders issue link', () => {
+ createComponent();
+
+ const link = vm.find(GlLink);
+
+ expect(link.attributes('href')).toBe(`${gl.TEST_HOST}/test/issue/1`);
+ });
+
+ it('renders IID', () => {
+ createComponent();
+
+ expect(vm.text()).toContain('#1');
+ });
+
+ describe('opened state', () => {
+ it('renders icon', () => {
+ createComponent();
+
+ const icon = vm.find(Icon);
+
+ expect(icon.props('name')).toBe('issue-open-m');
+ });
+
+ it('renders created timeago', () => {
+ createComponent({
+ closedAt: '',
+ });
+
+ const tooltip = vm.find(GlTooltip);
+
+ expect(tooltip.find('.d-block').text()).toContain('Opened');
+ expect(tooltip.text()).toContain('3 days ago');
+ });
+ });
+
+ describe('closed state', () => {
+ it('renders icon', () => {
+ createComponent({
+ state: 'closed',
+ });
+
+ const icon = vm.find(Icon);
+
+ expect(icon.props('name')).toBe('issue-close');
+ });
+
+ it('renders closed timeago', () => {
+ createComponent();
+
+ const tooltip = vm.find(GlTooltip);
+
+ expect(tooltip.find('.d-block').text()).toContain('Opened');
+ expect(tooltip.text()).toContain('1 day ago');
+ });
+ });
+
+ describe('author', () => {
+ it('renders author info', () => {
+ createComponent();
+
+ const link = vm.findAll(GlLink).at(1);
+
+ expect(link.text()).toContain('Author Name');
+ expect(link.text()).toContain('@author.username');
+ });
+
+ it('renders author image', () => {
+ createComponent();
+
+ const image = vm.find(UserAvatarImage);
+
+ expect(image.props('imgSrc')).toBe(`${gl.TEST_HOST}/avatar`);
+ });
+ });
+
+ describe('counts', () => {
+ it('renders upvotes count', () => {
+ createComponent();
+
+ const count = vm.findAll('.suggestion-counts span').at(0);
+
+ expect(count.text()).toContain('1');
+ expect(count.find(Icon).props('name')).toBe('thumb-up');
+ });
+
+ it('renders notes count', () => {
+ createComponent();
+
+ const count = vm.findAll('.suggestion-counts span').at(1);
+
+ expect(count.text()).toContain('2');
+ expect(count.find(Icon).props('name')).toBe('comment');
+ });
+ });
+
+ describe('confidential', () => {
+ it('renders confidential icon', () => {
+ createComponent({
+ confidential: true,
+ });
+
+ const icon = vm.find(Icon);
+
+ expect(icon.props('name')).toBe('eye-slash');
+ expect(icon.attributes('data-original-title')).toBe('Confidential');
+ });
+ });
+});
diff --git a/spec/javascripts/issuable_suggestions/mock_data.js b/spec/javascripts/issuable_suggestions/mock_data.js
new file mode 100644
index 00000000000..4f0f9ef8d62
--- /dev/null
+++ b/spec/javascripts/issuable_suggestions/mock_data.js
@@ -0,0 +1,26 @@
+function getDate(daysMinus) {
+ const today = new Date();
+ today.setDate(today.getDate() - daysMinus);
+
+ return today.toISOString();
+}
+
+export default () => ({
+ id: 1,
+ iid: 1,
+ state: 'opened',
+ upvotes: 1,
+ userNotesCount: 2,
+ closedAt: getDate(1),
+ createdAt: getDate(3),
+ updatedAt: getDate(2),
+ confidential: false,
+ webUrl: `${gl.TEST_HOST}/test/issue/1`,
+ title: 'Test issue',
+ author: {
+ avatarUrl: `${gl.TEST_HOST}/avatar`,
+ name: 'Author Name',
+ username: 'author.username',
+ webUrl: `${gl.TEST_HOST}/author`,
+ },
+});
diff --git a/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb
new file mode 100644
index 00000000000..4609593ef6a
--- /dev/null
+++ b/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe Gitlab::Graphql::Loaders::BatchModelLoader do
+ describe '#find' do
+ let(:issue) { create(:issue) }
+ let(:user) { create(:user) }
+
+ it 'finds a model by id' do
+ issue_result = described_class.new(Issue, issue.id).find
+ user_result = described_class.new(User, user.id).find
+
+ expect(issue_result.__sync).to eq(issue)
+ expect(user_result.__sync).to eq(user)
+ end
+
+ it 'only queries once per model' do
+ other_user = create(:user)
+ user
+ issue
+
+ expect do
+ [described_class.new(User, other_user.id).find,
+ described_class.new(User, user.id).find,
+ described_class.new(Issue, issue.id).find].map(&:__sync)
+ end.not_to exceed_query_limit(2)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
new file mode 100644
index 00000000000..355336ad7e2
--- /dev/null
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -0,0 +1,59 @@
+require 'spec_helper'
+
+describe 'getting an issue list for a project' do
+ include GraphqlHelpers
+
+ let(:project) { create(:project, :repository, :public) }
+ let(:current_user) { create(:user) }
+ let(:issues_data) { graphql_data['project']['issues']['edges'] }
+ let!(:issues) do
+ create(:issue, project: project, discussion_locked: true)
+ create(:issue, project: project)
+ end
+ let(:fields) do
+ <<~QUERY
+ edges {
+ node {
+ #{all_graphql_fields_for('issues'.classify)}
+ }
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('issues', {}, fields)
+ )
+ end
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+ end
+
+ it 'includes a web_url' do
+ post_graphql(query, current_user: current_user)
+
+ expect(issues_data[0]['node']['webUrl']).to be_present
+ end
+
+ it 'includes discussion locked' do
+ post_graphql(query, current_user: current_user)
+
+ expect(issues_data[0]['node']['discussionLocked']).to eq false
+ expect(issues_data[1]['node']['discussionLocked']).to eq true
+ end
+
+ context 'when the user does not have access to the issue' do
+ it 'returns nil' do
+ project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
+
+ post_graphql(query)
+
+ expect(issues_data).to eq []
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index 63a8913fa3d..9872c85c13d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -654,6 +654,11 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==
+"@types/async@2.0.50":
+ version "2.0.50"
+ resolved "https://registry.yarnpkg.com/@types/async/-/async-2.0.50.tgz#117540e026d64e1846093abbd5adc7e27fda7bcb"
+ integrity sha512-VMhZMMQgV1zsR+lX/0IBfAk+8Eb7dPVMWiQGFAt3qjo5x7Ml6b77jUo0e1C3ToD+XRDXqtrfw+6AB0uUsPEr3Q==
+
"@types/events@*":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86"
@@ -698,6 +703,11 @@
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
+"@types/zen-observable@^0.8.0":
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d"
+ integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==
+
"@vue/component-compiler-utils@^2.0.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-2.2.0.tgz#bbbb7ed38a9a8a7c93abe7ef2e54a90a04b631b4"
@@ -996,6 +1006,103 @@ anymatch@^2.0.0:
micromatch "^3.1.4"
normalize-path "^2.1.1"
+apollo-boost@^0.1.20:
+ version "0.1.20"
+ resolved "https://registry.yarnpkg.com/apollo-boost/-/apollo-boost-0.1.20.tgz#cc3e418ebd2bea857656685d32a7a20443493363"
+ integrity sha512-n2MiEY5IGpD/cy0RH+pM9vbmobM/JZ5qz38XQAUA41FxxMPlLFQxf0IUMm0tijLOJvJJBub3pDt+Of4TVPBCqA==
+ dependencies:
+ apollo-cache "^1.1.20"
+ apollo-cache-inmemory "^1.3.9"
+ apollo-client "^2.4.5"
+ apollo-link "^1.0.6"
+ apollo-link-error "^1.0.3"
+ apollo-link-http "^1.3.1"
+ apollo-link-state "^0.4.0"
+ graphql-tag "^2.4.2"
+
+apollo-cache-inmemory@^1.3.9:
+ version "1.3.9"
+ resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.3.9.tgz#10738ba6a04faaeeb0da21bbcc1f7c0b5902910c"
+ integrity sha512-Q2k84p/OqIuMUyeWGc6XbVXXZu0erYOO+wTx9p+CnQUspnNvf7zmvFNgFnmudXzfuG1m1CSzePk6fC/M1ehOqQ==
+ dependencies:
+ apollo-cache "^1.1.20"
+ apollo-utilities "^1.0.25"
+ optimism "^0.6.6"
+
+apollo-cache@1.1.20, apollo-cache@^1.1.20:
+ version "1.1.20"
+ resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.1.20.tgz#6152cc4baf6a63e376efee79f75de4f5c84bf90e"
+ integrity sha512-+Du0/4kUSuf5PjPx0+pvgMGV12ezbHA8/hubYuqRQoy/4AWb4faa61CgJNI6cKz2mhDd9m94VTNKTX11NntwkQ==
+ dependencies:
+ apollo-utilities "^1.0.25"
+
+apollo-client@^2.4.5:
+ version "2.4.5"
+ resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.4.5.tgz#545beda1ef60814943b5622f0feabc9f29ee9822"
+ integrity sha512-nUm06EGa4TP/IY68OzmC3lTD32TqkjLOQdb69uYo+lHl8NnwebtrAw3qFtsQtTEz6ueBp/Z/HasNZng4jwafVQ==
+ dependencies:
+ "@types/zen-observable" "^0.8.0"
+ apollo-cache "1.1.20"
+ apollo-link "^1.0.0"
+ apollo-link-dedup "^1.0.0"
+ apollo-utilities "1.0.25"
+ symbol-observable "^1.0.2"
+ zen-observable "^0.8.0"
+ optionalDependencies:
+ "@types/async" "2.0.50"
+
+apollo-link-dedup@^1.0.0:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/apollo-link-dedup/-/apollo-link-dedup-1.0.10.tgz#7b94589fe7f969777efd18a129043c78430800ae"
+ integrity sha512-tpUI9lMZsidxdNygSY1FxflXEkUZnvKRkMUsXXuQUNoSLeNtEvUX7QtKRAl4k9ubLl8JKKc9X3L3onAFeGTK8w==
+ dependencies:
+ apollo-link "^1.2.3"
+
+apollo-link-error@^1.0.3:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.1.1.tgz#69d7124d4dc11ce60f505c940f05d4f1aa0945fb"
+ integrity sha512-/yPcaQWcBdB94vpJ4FsiCJt1dAGGRm+6Tsj3wKwP+72taBH+UsGRQQZk7U/1cpZwl1yqhHZn+ZNhVOebpPcIlA==
+ dependencies:
+ apollo-link "^1.2.3"
+
+apollo-link-http-common@^0.2.5:
+ version "0.2.5"
+ resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.5.tgz#d094beb7971523203359bf830bfbfa7b4e7c30ed"
+ integrity sha512-6FV1wr5AqAyJ64Em1dq5hhGgiyxZE383VJQmhIoDVc3MyNcFL92TkhxREOs4rnH2a9X2iJMko7nodHSGLC6d8w==
+ dependencies:
+ apollo-link "^1.2.3"
+
+apollo-link-http@^1.3.1:
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.5.tgz#7dbe851821771ad67fa29e3900c57f38cbd80da8"
+ integrity sha512-C5N6N/mRwmepvtzO27dgMEU3MMtRKSqcljBkYNZmWwH11BxkUQ5imBLPM3V4QJXNE7NFuAQAB5PeUd4ligivTQ==
+ dependencies:
+ apollo-link "^1.2.3"
+ apollo-link-http-common "^0.2.5"
+
+apollo-link-state@^0.4.0:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/apollo-link-state/-/apollo-link-state-0.4.2.tgz#ac00e9be9b0ca89eae0be6ba31fe904b80bbe2e8"
+ integrity sha512-xMPcAfuiPVYXaLwC6oJFIZrKgV3GmdO31Ag2eufRoXpvT0AfJZjdaPB4450Nu9TslHRePN9A3quxNueILlQxlw==
+ dependencies:
+ apollo-utilities "^1.0.8"
+ graphql-anywhere "^4.1.0-alpha.0"
+
+apollo-link@^1.0.0, apollo-link@^1.0.6, apollo-link@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.3.tgz#9bd8d5fe1d88d31dc91dae9ecc22474d451fb70d"
+ integrity sha512-iL9yS2OfxYhigme5bpTbmRyC+Htt6tyo2fRMHT3K1XRL/C5IQDDz37OjpPy4ndx7WInSvfSZaaOTKFja9VWqSw==
+ dependencies:
+ apollo-utilities "^1.0.0"
+ zen-observable-ts "^0.8.10"
+
+apollo-utilities@1.0.25, apollo-utilities@^1.0.0, apollo-utilities@^1.0.25, apollo-utilities@^1.0.8:
+ version "1.0.25"
+ resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.25.tgz#899b00f5f990fb451675adf84cb3de82eb6372ea"
+ integrity sha512-AXvqkhni3Ir1ffm4SA1QzXn8k8I5BBl4PVKEyak734i4jFdp+xgfUyi2VCqF64TJlFTA/B73TRDUvO2D+tKtZg==
+ dependencies:
+ fast-json-stable-stringify "^2.0.0"
+
append-transform@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab"
@@ -3992,6 +4099,25 @@ graphlibrary@^2.2.0:
dependencies:
lodash "^4.17.5"
+graphql-anywhere@^4.1.0-alpha.0:
+ version "4.1.22"
+ resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.22.tgz#1c831ba3c9e5664a0dd24d10d23a9e9512d92056"
+ integrity sha512-qm2/1cKM8nfotxDhm4J0r1znVlK0Yge/yEKt26EVVBgpIhvxjXYFALCGbr7cvfDlvzal1iSPpaYa+8YTtjsxQA==
+ dependencies:
+ apollo-utilities "^1.0.25"
+
+graphql-tag@^2.10.0, graphql-tag@^2.4.2:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.0.tgz#87da024be863e357551b2b8700e496ee2d4353ae"
+ integrity sha512-9FD6cw976TLLf9WYIUPCaaTpniawIjHWZSwIRZSjrfufJamcXbVVYfN2TWvJYbw0Xf2JjYbl1/f2+wDnBVw3/w==
+
+graphql@^14.0.2:
+ version "14.0.2"
+ resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650"
+ integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw==
+ dependencies:
+ iterall "^1.2.2"
+
gzip-size@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.0.0.tgz#a55ecd99222f4c48fd8c01c625ce3b349d0a0e80"
@@ -4288,6 +4414,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
+immutable-tuple@^0.4.9:
+ version "0.4.9"
+ resolved "https://registry.yarnpkg.com/immutable-tuple/-/immutable-tuple-0.4.9.tgz#473ebdd6c169c461913a454bf87ef8f601a20ff0"
+ integrity sha512-LWbJPZnidF8eczu7XmcnLBsumuyRBkpwIRPCZxlojouhBo5jEBO4toj6n7hMy6IxHU/c+MqDSWkvaTpPlMQcyA==
+
import-lazy@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
@@ -4835,6 +4966,11 @@ isurl@^1.0.0-alpha5:
has-to-string-tag-x "^1.2.0"
is-object "^1.0.1"
+iterall@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
+ integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==
+
jasmine-core@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.9.0.tgz#bfbb56defcd30789adec5a3fbba8504233289c72"
@@ -5982,6 +6118,13 @@ opn@^5.1.0:
dependencies:
is-wsl "^1.1.0"
+optimism@^0.6.6:
+ version "0.6.8"
+ resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.6.8.tgz#0780b546da8cd0a72e5207e0c3706c990c8673a6"
+ integrity sha512-bN5n1KCxSqwBDnmgDnzMtQTHdL+uea2HYFx1smvtE+w2AMl0Uy31g0aXnP/Nt85OINnMJPRpJyfRQLTCqn5Weg==
+ dependencies:
+ immutable-tuple "^0.4.9"
+
optimist@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
@@ -7638,6 +7781,11 @@ svg4everybody@2.1.9:
resolved "https://registry.yarnpkg.com/svg4everybody/-/svg4everybody-2.1.9.tgz#5bd9f6defc133859a044646d4743fabc28db7e2d"
integrity sha1-W9n23vwTOFmgRGRtR0P6vCjbfi0=
+symbol-observable@^1.0.2:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+ integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
+
table@^4.0.3:
version "4.0.3"
resolved "http://registry.npmjs.org/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc"
@@ -7715,6 +7863,11 @@ three@^0.84.0:
resolved "https://registry.yarnpkg.com/three/-/three-0.84.0.tgz#95be85a55a0fa002aa625ed559130957dcffd918"
integrity sha1-lb6FpVoPoAKqYl7VWRMJV9z/2Rg=
+throttle-debounce@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.0.1.tgz#7307ddd6cd9acadb349132fbf6c18d78c88a5e62"
+ integrity sha512-Sr6jZBlWShsAaSXKyNXyNicOrJW/KtkDqIEwHt4wYwWA2wa/q67Luhqoujg48V8hTk60wB56tYrJJn6jc2R7VA==
+
through2@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be"
@@ -8166,6 +8319,14 @@ void-elements@^2.0.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
+vue-apollo@^3.0.0-beta.25:
+ version "3.0.0-beta.25"
+ resolved "https://registry.yarnpkg.com/vue-apollo/-/vue-apollo-3.0.0-beta.25.tgz#05a9a699b2ba6103639e9bd6c3bb88ca04c4b637"
+ integrity sha512-M7/l3h0NlFvaZ/s/wrtRiOt3xXMbaNNuteGaCY+U5D0ABrQqvCgy5mayIZHurQxbloluNkbCt18wRKAgJTAuKA==
+ dependencies:
+ chalk "^2.4.1"
+ throttle-debounce "^2.0.0"
+
vue-eslint-parser@^3.2.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-3.2.2.tgz#47c971ee4c39b0ee7d7f5e154cb621beb22f7a34"
@@ -8584,3 +8745,15 @@ yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
+
+zen-observable-ts@^0.8.10:
+ version "0.8.10"
+ resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.10.tgz#18e2ce1c89fe026e9621fd83cc05168228fce829"
+ integrity sha512-5vqMtRggU/2GhePC9OU4sYEWOdvmayp2k3gjPf4F0mXwB3CSbbNznfDUvDJx9O2ZTa1EIXdJhPchQveFKwNXPQ==
+ dependencies:
+ zen-observable "^0.8.0"
+
+zen-observable@^0.8.0:
+ version "0.8.11"
+ resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.11.tgz#d3415885eeeb42ee5abb9821c95bb518fcd6d199"
+ integrity sha512-N3xXQVr4L61rZvGMpWe8XoCGX8vhU35dPyQ4fm5CY/KDlG0F75un14hjbckPXTDuKUY6V0dqR2giT6xN8Y4GEQ==