Commit 306bbfb4 authored by Robert Speicher's avatar Robert Speicher

Merge branch...

Merge branch '13495-design-versions-should-not-have-duplicate-ids-and-have-version-included-in-id' into 'master'

13495-e: Create DesignAtVersion

Closes #13495

See merge request gitlab-org/gitlab!15260
parents 1aa19e0d ce3795fd
......@@ -58,5 +58,9 @@ module Resolvers
def single?
false
end
def current_user
context[:current_user]
end
end
end
......@@ -40,3 +40,5 @@ module Types
resolver: Resolvers::EchoResolver
end
end
Types::QueryType.prepend_if_ee('EE::Types::QueryType')
......@@ -654,6 +654,16 @@ type Design implements DesignFields & Noteable {
"""
before: String
"""
The Global ID of the most recent acceptable version
"""
earlierOrEqualToId: ID
"""
The SHA256 of the most recent acceptable version
"""
earlierOrEqualToSha: String
"""
Returns the first _n_ elements from the list.
"""
......@@ -666,10 +676,130 @@ type Design implements DesignFields & Noteable {
): DesignVersionConnection!
}
"""
A design pinned to a specific version. The image field reflects the design as of the associated version.
"""
type DesignAtVersion implements DesignFields {
"""
The underlying design.
"""
design: Design!
"""
The diff refs for this design
"""
diffRefs: DiffRefs!
"""
How this design was changed in the current version
"""
event: DesignVersionEvent!
"""
The filename of the design
"""
filename: String!
"""
The full path to the design file
"""
fullPath: String!
"""
The ID of this design
"""
id: ID!
"""
The URL of the image
"""
image: String!
"""
The issue the design belongs to
"""
issue: Issue!
"""
The total count of user-created notes for this design
"""
notesCount: Int!
"""
The project the design belongs to
"""
project: Project!
"""
The version this design-at-versions is pinned to
"""
version: DesignVersion!
}
"""
The connection type for DesignAtVersion.
"""
type DesignAtVersionConnection {
"""
A list of edges.
"""
edges: [DesignAtVersionEdge]
"""
A list of nodes.
"""
nodes: [DesignAtVersion]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type DesignAtVersionEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: DesignAtVersion
}
"""
A collection of designs.
"""
type DesignCollection {
"""
Find a specific design
"""
design(
"""
Find a design by its filename
"""
filename: String
"""
Find a design by its ID
"""
id: ID
): Design
"""
Find a design as of a version
"""
designAtVersion(
"""
The Global ID of the design at this version
"""
id: ID!
): DesignAtVersion
"""
All designs for the design collection
"""
......@@ -721,6 +851,21 @@ type DesignCollection {
"""
project: Project!
"""
A specific version
"""
version(
"""
The Global ID of the version
"""
id: ID
"""
The SHA256 of a specific version
"""
sha: String
): DesignVersion
"""
All versions related to all designs, ordered newest first
"""
......@@ -735,6 +880,16 @@ type DesignCollection {
"""
before: String
"""
The Global ID of the most recent acceptable version
"""
earlierOrEqualToId: ID
"""
The SHA256 of the most recent acceptable version
"""
earlierOrEqualToSha: String
"""
Returns the first _n_ elements from the list.
"""
......@@ -829,6 +984,28 @@ interface DesignFields {
project: Project!
}
type DesignManagement {
"""
Find a design as of a version
"""
designAtVersion(
"""
The Global ID of the design at this version
"""
id: ID!
): DesignAtVersion
"""
Find a version
"""
version(
"""
The Global ID of the version
"""
id: ID!
): DesignVersion
}
"""
Autogenerated input type of DesignManagementDelete
"""
......@@ -924,7 +1101,30 @@ type DesignManagementUploadPayload {
skippedDesigns: [Design!]!
}
"""
A specific version in which designs were added, modified or deleted
"""
type DesignVersion {
"""
A particular design as of this version, provided it is visible at this version
"""
designAtVersion(
"""
The ID of a specific design
"""
designId: ID
"""
The filename of a specific design
"""
filename: String
"""
The ID of the DesignAtVersion
"""
id: ID
): DesignAtVersion!
"""
All designs that were changed in the version
"""
......@@ -950,6 +1150,41 @@ type DesignVersion {
last: Int
): DesignConnection!
"""
All designs that are visible at this version, as of this version
"""
designsAtVersion(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Filters designs by their filename
"""
filenames: [String!]
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Filters designs by their ID
"""
ids: [ID!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
): DesignAtVersionConnection!
"""
ID of the design version
"""
......@@ -5604,6 +5839,11 @@ type Query {
"""
currentUser: User
"""
Fields related to design management
"""
designManagement: DesignManagement!
"""
Text to echo back
"""
......
......@@ -48,6 +48,24 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "designManagement",
"description": "Fields related to design management",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DesignManagement",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "echo",
"description": "Text to echo back",
......@@ -9736,6 +9754,66 @@
"name": "DesignCollection",
"description": "A collection of designs.",
"fields": [
{
"name": "design",
"description": "Find a specific design",
"args": [
{
"name": "id",
"description": "Find a design by its ID",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "filename",
"description": "Find a design by its filename",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Design",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "designAtVersion",
"description": "Find a design as of a version",
"args": [
{
"name": "id",
"description": "The Global ID of the design at this version",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DesignAtVersion",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "designs",
"description": "All designs for the design collection",
......@@ -9875,10 +9953,63 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "version",
"description": "A specific version",
"args": [
{
"name": "sha",
"description": "The SHA256 of a specific version",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "id",
"description": "The Global ID of the version",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DesignVersion",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "versions",
"description": "All versions related to all designs, ordered newest first",
"args": [
{
"name": "earlierOrEqualToSha",
"description": "The SHA256 of the most recent acceptable version",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "earlierOrEqualToId",
"description": "The Global ID of the most recent acceptable version",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -11132,6 +11263,26 @@
"name": "versions",
"description": "All versions related to this design ordered newest first",
"args": [
{
"name": "earlierOrEqualToSha",
"description": "The SHA256 of the most recent acceptable version",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "earlierOrEqualToId",
"description": "The Global ID of the most recent acceptable version",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -11378,6 +11529,11 @@
"kind": "OBJECT",
"name": "Design",
"ofType": null
},
{
"kind": "OBJECT",
"name": "DesignAtVersion",
"ofType": null
}
]
},
......@@ -11531,8 +11687,55 @@
{
"kind": "OBJECT",
"name": "DesignVersion",
"description": null,
"description": "A specific version in which designs were added, modified or deleted",
"fields": [
{
"name": "designAtVersion",
"description": "A particular design as of this version, provided it is visible at this version",
"args": [
{
"name": "id",
"description": "The ID of the DesignAtVersion",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "designId",
"description": "The ID of a specific design",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "filename",
"description": "The filename of a specific design",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DesignAtVersion",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "designs",
"description": "All designs that were changed in the version",
......@@ -11591,17 +11794,92 @@
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the design version",
"name": "designsAtVersion",
"description": "All designs that are visible at this version, as of this version",
"args": [
{
"name": "ids",
"description": "Filters designs by their ID",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "filenames",
"description": "Filters designs by their filename",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"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": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"kind": "OBJECT",
"name": "DesignAtVersionConnection",
"ofType": null
}
},
......@@ -11609,8 +11887,8 @@
"deprecationReason": null
},
{
"name": "sha",
"description": "SHA of the design version",
"name": "id",
"description": "ID of the design version",
"args": [
],
......@@ -11625,6 +11903,136 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sha",
"description": "SHA of the design version",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DesignAtVersionConnection",
"description": "The connection type for DesignAtVersion.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DesignAtVersionEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DesignAtVersion",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DesignAtVersionEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "DesignAtVersion",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
......@@ -11634,6 +12042,221 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DesignAtVersion",
"description": "A design pinned to a specific version. The image field reflects the design as of the associated version.",
"fields": [
{
"name": "design",
"description": "The underlying design.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Design",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "diffRefs",
"description": "The diff refs for this design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DiffRefs",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "event",
"description": "How this design was changed in the current version",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DesignVersionEvent",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filename",
"description": "The filename of the design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "fullPath",
"description": "The full path to the design file",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "The ID of this design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "image",
"description": "The URL of the image",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue the design belongs to",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "notesCount",
"description": "The total count of user-created notes for this design",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "project",
"description": "The project the design belongs to",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Project",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "version",
"description": "The version this design-at-versions is pinned to",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DesignVersion",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
{
"kind": "INTERFACE",
"name": "DesignFields",
"ofType": null
}
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicDescendantCount",
......@@ -16743,6 +17366,73 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DesignManagement",
"description": null,
"fields": [
{
"name": "designAtVersion",
"description": "Find a design as of a version",
"args": [
{
"name": "id",
"description": "The Global ID of the design at this version",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DesignAtVersion",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "version",
"description": "Find a version",
"args": [
{
"name": "id",
"description": "The Global ID of the version",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DesignVersion",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Mutation",
......
......@@ -130,6 +130,24 @@ A single design
| `event` | DesignVersionEvent! | How this design was changed in the current version |
| `notesCount` | Int! | The total count of user-created notes for this design |
## DesignAtVersion
A design pinned to a specific version. The image field reflects the design as of the associated version.
| Name | Type | Description |
| --- | ---- | ---------- |
| `id` | ID! | The ID of this design |
| `project` | Project! | The project the design belongs to |
| `issue` | Issue! | The issue the design belongs to |
| `filename` | String! | The filename of the design |
| `fullPath` | String! | The full path to the design file |
| `image` | String! | The URL of the image |
| `diffRefs` | DiffRefs! | The diff refs for this design |
| `event` | DesignVersionEvent! | How this design was changed in the current version |
| `notesCount` | Int! | The total count of user-created notes for this design |
| `version` | DesignVersion! | The version this design-at-versions is pinned to |
| `design` | Design! | The underlying design. |
## DesignCollection
A collection of designs.
......@@ -138,6 +156,16 @@ A collection of designs.
| --- | ---- | ---------- |
| `project` | Project! | Project associated with the design collection |
| `issue` | Issue! | Issue associated with the design collection |
| `version` | DesignVersion | A specific version |
| `designAtVersion` | DesignAtVersion | Find a design as of a version |
| `design` | Design | Find a specific design |
## DesignManagement
| Name | Type | Description |
| --- | ---- | ---------- |
| `version` | DesignVersion | Find a version |
| `designAtVersion` | DesignAtVersion | Find a design as of a version |
## DesignManagementDeletePayload
......@@ -162,10 +190,13 @@ Autogenerated return type of DesignManagementUpload
## DesignVersion
A specific version in which designs were added, modified or deleted
| Name | Type | Description |
| --- | ---- | ---------- |
| `id` | ID! | ID of the design version |
| `sha` | ID! | SHA of the design version |
| `designAtVersion` | DesignAtVersion! | A particular design as of this version, provided it is visible at this version |
## DestroyNotePayload
......
......@@ -6,7 +6,9 @@ module DesignManagement
# Params:
# ids: integer[]
# filenames: string[]
# visible_at_version: ?version
# filenames: String[]
def initialize(issue, current_user, params = {})
@issue = issue
@current_user = current_user
......@@ -39,13 +41,15 @@ module DesignManagement
end
def by_filename(items)
return items unless params[:filenames].present?
return items if params[:filenames].nil?
return ::DesignManagement::Design.none if params[:filenames].empty?
items.with_filename(params[:filenames])
end
def by_id(items)
return items unless params[:ids].present?
return items if params[:ids].nil?
return ::DesignManagement::Design.none if params[:ids].empty?
items.id_in(params[:ids])
end
......
# frozen_string_literal: true
module EE
module Types
module QueryType
extend ActiveSupport::Concern
# The design management context object needs to implement #issue
DesignManagementObject = Struct.new(:issue)
prepended do
field :design_management, ::Types::DesignManagementType,
null: false,
description: 'Fields related to design management'
def design_management
DesignManagementObject.new(nil)
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
class DesignAtVersionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: false
authorize :read_design
argument :id, GraphQL::ID_TYPE,
required: true,
description: 'The Global ID of the design at this version'
def resolve(id:)
authorized_find!(id: id)
end
def find_object(id:)
dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
return unless consistent?(dav)
dav
end
def self.single
self
end
private
# If this resolver is mounted on something that has an issue
# (such as design collection for instance), then we should check
# that the DesignAtVersion as found by its ID does in fact belong
# to this issue.
def consistent?(dav)
issue.nil? || (dav&.design&.issue_id == issue.id)
end
def issue
object&.issue
end
end
end
end
......@@ -3,40 +3,54 @@
module Resolvers
module DesignManagement
class DesignResolver < BaseResolver
argument :ids,
[GraphQL::ID_TYPE],
argument :id, GraphQL::ID_TYPE,
required: false,
description: 'Filters designs by their ID'
argument :filenames,
[GraphQL::STRING_TYPE],
required: false,
description: 'Filters designs by their filename'
argument :at_version,
GraphQL::ID_TYPE,
description: 'Find a design by its ID'
argument :filename, GraphQL::STRING_TYPE,
required: false,
description: 'Filters designs to only those that existed at the version. ' \
'If argument is omitted or nil then all designs will reflect the latest version'
description: 'Find a design by its filename'
def resolve(filename: nil, id: nil)
params = parse_args(filename, id)
def resolve(**args)
find_designs(args)
build_finder(params).execute.first
end
def version(args)
args[:at_version] ? GitlabSchema.object_from_id(args[:at_version])&.sync : nil
def self.single
self
end
private
def issue
object.issue
end
def design_ids(args)
args[:ids] ? args[:ids].map { |id| GlobalID.parse(id).model_id } : nil
def build_finder(params)
::DesignManagement::DesignsFinder.new(issue, current_user, params)
end
def error(msg)
raise ::Gitlab::Graphql::Errors::ArgumentError, msg
end
def parse_args(filename, id)
provided = [filename, id].map(&:present?)
if provided.none?
error('one of id or filename must be passed')
elsif provided.all?
error('only one of id or filename may be passed')
elsif filename.present?
{ filenames: [filename] }
else
{ ids: [parse_gid(id)] }
end
end
def find_designs(args)
::DesignManagement::DesignsFinder.new(
object.issue,
context[:current_user],
ids: design_ids(args),
filenames: args[:filenames],
visible_at_version: version(args)
).execute
def parse_gid(gid)
GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id
end
end
end
......
# frozen_string_literal: true
module Resolvers
module DesignManagement
class DesignsResolver < BaseResolver
argument :ids,
[GraphQL::ID_TYPE],
required: false,
description: 'Filters designs by their ID'
argument :filenames,
[GraphQL::STRING_TYPE],
required: false,
description: 'Filters designs by their filename'
argument :at_version,
GraphQL::ID_TYPE,
required: false,
description: 'Filters designs to only those that existed at the version. ' \
'If argument is omitted or nil then all designs will reflect the latest version'
def self.single
::Resolvers::DesignManagement::DesignResolver
end
def resolve(ids: nil, filenames: nil, at_version: nil)
::DesignManagement::DesignsFinder.new(
issue,
current_user,
ids: design_ids(ids),
filenames: filenames,
visible_at_version: version(at_version),
order: :id
).execute
end
private
def version(at_version)
GitlabSchema.object_from_id(at_version)&.sync if at_version
end
def design_ids(ids)
ids&.map { |id| GlobalID.parse(id).model_id }
end
def issue
object.issue
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
module Version
# Resolver for a DesignAtVersion object given an implicit version context
class DesignAtVersionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: true
authorize :read_design
argument :id, GraphQL::ID_TYPE,
required: false,
as: :design_at_version_id,
description: 'The ID of the DesignAtVersion'
argument :design_id, GraphQL::ID_TYPE,
required: false,
description: 'The ID of a specific design'
argument :filename, GraphQL::STRING_TYPE,
required: false,
description: 'The filename of a specific design'
def self.single
self
end
def resolve(design_id: nil, filename: nil, design_at_version_id: nil)
validate_arguments(design_id, filename, design_at_version_id)
return unless Ability.allowed?(current_user, :read_design, issue)
return specific_design_at_version(design_at_version_id) if design_at_version_id
find(design_id, filename).map { |d| make(d) }.first
end
private
def validate_arguments(design_id, filename, design_at_version_id)
args = { filename: filename, id: design_at_version_id, design_id: design_id }
passed = args.compact.keys
return if passed.size == 1
msg = "Exactly one of #{args.keys.join(', ')} expected, got #{passed}"
raise Gitlab::Graphql::Errors::ArgumentError, msg
end
def specific_design_at_version(id)
dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
return unless consistent?(dav)
dav
end
# Test that the DAV found by ID actually belongs on this version, and
# that it is visible at this version.
def consistent?(dav)
return false unless dav.present?
dav.design.issue_id == issue.id &&
dav.version.id == version.id &&
dav.design.visible_in?(version)
end
def find(id, filename)
ids = [parse_design_id(id).model_id] if id
filenames = [filename] if filename
::DesignManagement::DesignsFinder
.new(issue, current_user, ids: ids, filenames: filenames, visible_at_version: version)
.execute
end
def parse_design_id(id)
GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
end
def issue
version.issue
end
def version
object
end
def make(design)
::DesignManagement::DesignAtVersion.new(design: design, version: version)
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
module Version
# Resolver for DesignAtVersion objects given an implicit version context
class DesignsAtVersionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: true
authorize :read_design
argument :ids,
[GraphQL::ID_TYPE],
required: false,
description: 'Filters designs by their ID'
argument :filenames,
[GraphQL::STRING_TYPE],
required: false,
description: 'Filters designs by their filename'
def self.single
::Resolvers::DesignManagement::Version::DesignAtVersionResolver
end
def resolve(ids: nil, filenames: nil)
find(ids, filenames).execute.map { |d| make(d) }
end
private
def find(ids, filenames)
ids = ids&.map { |id| parse_design_id(id).model_id }
::DesignManagement::DesignsFinder.new(issue, current_user,
ids: ids,
filenames: filenames,
visible_at_version: version)
end
def parse_design_id(id)
GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
end
def issue
version.issue
end
def version
object
end
def make(design)
::DesignManagement::DesignAtVersion.new(design: design, version: version)
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
class VersionInCollectionResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::VersionType, null: true
authorize :read_design
alias_method :collection, :object
argument :sha, GraphQL::STRING_TYPE,
required: false,
description: "The SHA256 of a specific version"
argument :id, GraphQL::ID_TYPE,
required: false,
description: 'The Global ID of the version'
def resolve(id: nil, sha: nil)
check_args(id, sha)
gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
::DesignManagement::VersionsFinder
.new(collection, current_user, sha: sha, version_id: gid&.model_id)
.execute
.first
end
def self.single
self
end
private
def check_args(id, sha)
return if id.present? || sha.present?
raise ::Gitlab::Graphql::Errors::ArgumentError, 'one of id or sha is required'
end
end
end
end
......@@ -3,25 +3,22 @@
module Resolvers
module DesignManagement
class VersionResolver < BaseResolver
type Types::DesignManagement::VersionType.connection_type, null: false
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :design_or_collection, :object
type Types::DesignManagement::VersionType, null: true
def resolve(parent: nil)
# Find an `at_version` argument passed to a parent node.
#
# If one is found, then a design collection further up the AST
# has been filtered to reflect designs at that version, and so
# for consistency we should only present versions up to the given
# version here.
at_version = Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
version = at_version ? GitlabSchema.object_from_id(at_version).sync : nil
authorize :read_design
::DesignManagement::VersionsFinder.new(
design_or_collection,
context[:current_user],
earlier_or_equal_to: version
).execute
argument :id, GraphQL::ID_TYPE,
required: true,
description: 'The Global ID of the version'
def resolve(id:)
authorized_find!(id: id)
end
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version)
end
end
end
......
# frozen_string_literal: true
module Resolvers
module DesignManagement
class VersionsResolver < BaseResolver
type Types::DesignManagement::VersionType.connection_type, null: false
alias_method :design_or_collection, :object
argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE,
as: :sha,
required: false,
description: 'The SHA256 of the most recent acceptable version'
argument :earlier_or_equal_to_id, GraphQL::ID_TYPE,
as: :id,
required: false,
description: 'The Global ID of the most recent acceptable version'
# This resolver has a custom singular resolver
def self.single
::Resolvers::DesignManagement::VersionInCollectionResolver
end
def resolve(parent: nil, id: nil, sha: nil)
version = cutoff(parent, id, sha)
raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present?
if version == :unconstrained
find
else
find(earlier_or_equal_to: version)
end
end
private
# Find the most recent version that the client will accept
def cutoff(parent, id, sha)
if sha.present? || id.present?
specific_version(id, sha)
elsif at_version = at_version_arg(parent)
by_id(at_version)
else
:unconstrained
end
end
def specific_version(id, sha)
gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
find(sha: sha, version_id: gid&.model_id).first
end
def find(**params)
::DesignManagement::VersionsFinder
.new(design_or_collection, current_user, params)
.execute
end
def by_id(id)
GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync
end
# Find an `at_version` argument passed to a parent node.
#
# If one is found, then a design collection further up the AST
# has been filtered to reflect designs at that version, and so
# for consistency we should only present versions up to the given
# version here.
def at_version_arg(parent)
::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
end
end
end
end
......@@ -5,7 +5,8 @@ module Types
class DesignAtVersionType < BaseObject
graphql_name 'DesignAtVersion'
description 'A design pinned to a specific version'
description 'A design pinned to a specific version. ' \
'The image field reflects the design as of the associated version.'
authorize :read_design
......
......@@ -12,14 +12,32 @@ module Types
description: 'Project associated with the design collection'
field :issue, Types::IssueType, null: false,
description: 'Issue associated with the design collection'
field :designs, Types::DesignManagement::DesignType.connection_type, null: false,
resolver: Resolvers::DesignManagement::DesignResolver,
field :designs,
Types::DesignManagement::DesignType.connection_type,
null: false,
resolver: Resolvers::DesignManagement::DesignsResolver,
description: 'All designs for the design collection'
# TODO: allow getting a single design by filename
# exposing all designs
field :versions, Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver,
field :versions,
Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionsResolver,
description: 'All versions related to all designs, ordered newest first'
field :version,
Types::DesignManagement::VersionType,
resolver: Resolvers::DesignManagement::VersionsResolver.single,
description: 'A specific version'
field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
null: true,
resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
description: 'Find a design as of a version'
field :design, ::Types::DesignManagement::DesignType,
null: true,
resolver: ::Resolvers::DesignManagement::DesignResolver,
description: 'Find a specific design'
end
end
end
......@@ -15,7 +15,7 @@ module Types
field :versions,
Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver,
resolver: Resolvers::DesignManagement::VersionsResolver,
description: "All versions related to this design ordered newest first",
extras: [:parent]
......
......@@ -2,19 +2,36 @@
module Types
module DesignManagement
class VersionType < BaseObject
class VersionType < ::Types::BaseObject
# Just `Version` might be a bit to general to expose globally so adding
# a `Design` prefix to specify the class exposed in GraphQL
graphql_name 'DesignVersion'
description 'A specific version in which designs were added, modified or deleted'
authorize :read_design
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the design version'
field :sha, GraphQL::ID_TYPE, null: false,
description: 'SHA of the design version'
field :designs, Types::DesignManagement::DesignType.connection_type, null: false,
field :designs,
::Types::DesignManagement::DesignType.connection_type,
null: false,
description: 'All designs that were changed in the version'
field :designs_at_version,
::Types::DesignManagement::DesignAtVersionType.connection_type,
null: false,
description: 'All designs that are visible at this version, as of this version',
resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver
field :design_at_version,
::Types::DesignManagement::DesignAtVersionType,
null: false,
description: 'A particular design as of this version, provided it is visible at this version',
resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single
end
end
end
# frozen_string_literal: true
# rubocop: disable Graphql/AuthorizeTypes
module Types
class DesignManagementType < BaseObject
graphql_name 'DesignManagement'
field :version, ::Types::DesignManagement::VersionType,
null: true,
resolver: ::Resolvers::DesignManagement::VersionResolver,
description: 'Find a version'
field :design_at_version, ::Types::DesignManagement::DesignAtVersionType,
null: true,
resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver,
description: 'Find a design as of a version'
end
end
......@@ -78,6 +78,19 @@ module DesignManagement
most_recent_action&.deletion?
end
# A design is visible_in? a version if:
# * it was created before that version
# * the most recent action before the version was not a deletion
def visible_in?(version)
map = strong_memoize(:visible_in) do
Hash.new do |h, k|
h[k] = self.class.visible_at_version(k).where(id: id).exists?
end
end
map[version]
end
def most_recent_action
strong_memoize(:most_recent_action) { actions.ordered.last }
end
......@@ -181,7 +194,7 @@ module DesignManagement
def clear_version_cache
[versions, actions].each(&:reset)
[:new_design, :diff_refs, :head_sha, :most_recent_action].each do |key|
%i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
clear_memoization(key)
end
end
......
---
title: 'Create DesignAtVersion model, exposing it with GraphQL'
merge_request: 15260
author:
type: added
......@@ -54,6 +54,20 @@ describe DesignManagement::DesignsFinder do
it { is_expected.to eq([design2]) }
end
context 'when passed empty array' do
context 'for filenames' do
let(:params) { { filenames: [] } }
it { is_expected.to be_empty }
end
context "for ids" do
let(:params) { { ids: [] } }
it { is_expected.to be_empty }
end
end
describe 'returning designs that existed at a particular given version' do
let(:all_versions) { issue.design_collection.versions.ordered }
let(:first_version) { all_versions.last }
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::DesignAtVersionResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:user) { create(:user) }
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) }
let(:current_user) { user }
let(:object) { issue.design_collection }
let(:global_id) { GitlabSchema.id_from_object(design_at_version).to_s }
let(:design_at_version) { ::DesignManagement::DesignAtVersion.new(design: design_a, version: version_a) }
let(:resource_not_available) { ::Gitlab::Graphql::Errors::ResourceNotAvailable }
before do
enable_design_management
project.add_developer(user)
end
describe '#resolve' do
context 'when the user cannot see designs' do
let(:current_user) { create(:user) }
it 'raises ResourceNotAvailable' do
expect { resolve_design }.to raise_error(resource_not_available)
end
end
it 'returns the specified design' do
expect(resolve_design).to eq(design_at_version)
end
context 'the ID belongs to a design on another issue' do
let(:other_dav) do
create(:design_at_version, issue: create(:issue, project: project))
end
let(:global_id) { global_id_of(other_dav) }
it 'raises ResourceNotAvailable' do
expect { resolve_design }.to raise_error(resource_not_available)
end
context 'the current object does not constrain the issue' do
let(:object) { nil }
it 'returns the object' do
expect(resolve_design).to eq(other_dav)
end
end
end
end
private
def resolve_design
args = { id: global_id }
ctx = { current_user: current_user }
eager_resolve(described_class, obj: object, args: args, ctx: ctx)
end
end
# frozen_string_literal: true
require "spec_helper"
require 'spec_helper'
describe Resolvers::DesignManagement::DesignResolver do
include GraphqlHelpers
......@@ -10,38 +10,79 @@ describe Resolvers::DesignManagement::DesignResolver do
enable_design_management
end
describe "#resolve" do
describe '#resolve' do
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:first_version) { create(:design_version) }
let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) }
let_it_be(:current_user) { create(:user) }
let_it_be(:design_on_other_issue) do
create(:design, issue: create(:issue, project: project), versions: [create(:design_version)])
end
let(:args) { { id: GitlabSchema.id_from_object(first_design).to_s } }
let(:gql_context) { { current_user: current_user } }
before do
project.add_developer(current_user)
end
context "when the user cannot see designs" do
it "returns nothing" do
expect(resolve_designs(issue.design_collection, {}, current_user: create(:user))).to be_empty
context 'when the user cannot see designs' do
let(:gql_context) { { current_user: create(:user) } }
it 'returns nothing' do
expect(resolve_design).to be_nil
end
end
context 'when no argument has been passed' do
let(:args) { {} }
it 'raises an error' do
expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /must/)
end
end
context 'when both arguments have been passed' do
let(:args) { { filename: first_design.filename, id: GitlabSchema.id_from_object(first_design).to_s } }
it 'raises an error' do
expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /may/)
end
end
context "for a design collection" do
it "returns designs" do
expect(resolve_designs(issue.design_collection, {}, current_user: current_user)).to contain_exactly(first_design)
context 'by ID' do
it 'returns the specified design' do
expect(resolve_design).to eq(first_design)
end
context 'the ID belongs to a design on another issue' do
let(:args) { { id: GitlabSchema.id_from_object(design_on_other_issue).to_s } }
it 'returns nothing' do
expect(resolve_design).to be_nil
end
end
end
context 'by filename' do
let(:args) { { filename: first_design.filename } }
it 'returns the specified design' do
expect(resolve_design).to eq(first_design)
end
it "returns all designs" do
second_version = create(:design_version)
second_design = create(:design, issue: issue, versions: [second_version])
context 'the filename belongs to a design on another issue' do
let(:args) { { filename: design_on_other_issue.filename } }
expect(resolve_designs(issue.design_collection, {}, current_user: current_user)).to contain_exactly(first_design, second_design)
it 'returns nothing' do
expect(resolve_design).to be_nil
end
end
end
end
def resolve_designs(obj, args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
def resolve_design
resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::DesignsResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
before do
enable_design_management
end
describe '#resolve' do
set(:issue) { create(:issue) }
set(:project) { issue.project }
set(:first_version) { create(:design_version) }
set(:first_design) { create(:design, issue: issue, versions: [first_version]) }
set(:current_user) { create(:user) }
let(:gql_context) { { current_user: current_user } }
let(:args) { {} }
before do
project.add_developer(current_user)
end
context 'when the user cannot see designs' do
let(:gql_context) { { current_user: create(:user) } }
it 'returns nothing' do
expect(resolve_designs).to be_empty
end
end
context 'for a design collection' do
context 'which contains just a single design' do
it 'returns just that design' do
expect(resolve_designs).to contain_exactly(first_design)
end
end
context 'which contains another design' do
it 'returns all designs' do
second_version = create(:design_version)
second_design = create(:design, issue: issue, versions: [second_version])
expect(resolve_designs).to contain_exactly(first_design, second_design)
end
end
end
describe 'filtering' do
describe 'by filename' do
let(:second_version) { create(:design_version) }
let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
let(:args) { { filenames: [second_design.filename] } }
it 'resolves to just the relevant design, ignoring designs with the same filename on different issues' do
create(:design, issue: create(:issue, project: project), filename: second_design.filename)
expect(resolve_designs).to contain_exactly(second_design)
end
end
describe 'by id' do
let(:second_version) { create(:design_version) }
let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
context 'the ID is on the current issue' do
let(:args) { { ids: [GitlabSchema.id_from_object(second_design).to_s] } }
it 'resolves to just the relevant design' do
expect(resolve_designs).to contain_exactly(second_design)
end
end
context 'the ID is on a different issue' do
let(:third_version) { create(:design_version) }
let(:third_design) { create(:design, issue: create(:issue, project: project), versions: [third_version]) }
let(:args) { { ids: [GitlabSchema.id_from_object(third_design).to_s] } }
it 'ignores it' do
expect(resolve_designs).to be_empty
end
end
end
end
end
def resolve_designs
resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::Version::DesignAtVersionResolver do
include GraphqlHelpers
include_context 'four designs in three versions'
let(:current_user) { authorized_user }
let(:gql_context) { { current_user: current_user } }
let(:version) { third_version }
let(:design) { design_a }
let(:all_singular_args) do
{
design_at_version_id: global_id_of(dav(design)),
design_id: global_id_of(design),
filename: design.filename
}
end
shared_examples 'a bad argument' do
let(:err_class) { ::Gitlab::Graphql::Errors::ArgumentError }
it 'raises an appropriate error' do
expect { resolve_objects }.to raise_error(err_class)
end
end
describe '#resolve' do
describe 'passing combinations of arguments' do
context 'passing no arguments' do
let(:args) { {} }
it_behaves_like 'a bad argument'
end
context 'passing all arguments' do
let(:args) { all_singular_args }
it_behaves_like 'a bad argument'
end
context 'passing any two arguments' do
let(:args) { all_singular_args.slice(*all_singular_args.keys.sample(2)) }
it_behaves_like 'a bad argument'
end
end
%i[design_at_version_id design_id filename].each do |arg|
describe "passing #{arg}" do
let(:args) { all_singular_args.slice(arg) }
it 'finds the design' do
expect(resolve_objects).to eq(dav(design))
end
context 'when the user cannot see designs' do
let(:current_user) { create(:user) }
it 'returns nothing' do
expect(resolve_objects).to be_nil
end
end
end
end
describe 'attempting to retrieve an object not visible at this version' do
let(:design) { design_d }
%i[design_at_version_id design_id filename].each do |arg|
describe "passing #{arg}" do
let(:args) { all_singular_args.slice(arg) }
it 'does not find the design' do
expect(resolve_objects).to be_nil
end
end
end
end
end
def resolve_objects
resolve(described_class, obj: version, args: args, ctx: gql_context)
end
def dav(design)
build(:design_at_version, design: design, version: version)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::Version::DesignsAtVersionResolver do
include GraphqlHelpers
include_context 'four designs in three versions'
set(:current_user) { authorized_user }
let(:gql_context) { { current_user: current_user } }
let(:version) { third_version }
describe '.single' do
let(:single) { ::Resolvers::DesignManagement::Version::DesignAtVersionResolver }
it 'returns the single context resolver' do
expect(described_class.single).to eq(single)
end
end
describe '#resolve' do
let(:args) { {} }
context 'when the user cannot see designs' do
let(:current_user) { create(:user) }
it 'returns nothing' do
expect(resolve_objects).to be_empty
end
end
context 'for the current version' do
it 'returns all designs visible at that version' do
expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c))
end
end
context 'for a previous version with more objects' do
let(:version) { second_version }
it 'returns objects that were later deleted' do
expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c), dav(design_d))
end
end
context 'for a previous version with fewer objects' do
let(:version) { first_version }
it 'does not return objects that were later created' do
expect(resolve_objects).to contain_exactly(dav(design_a))
end
end
describe 'filtering' do
describe 'by filename' do
let(:red_herring) { create(:design, issue: create(:issue, project: project)) }
let(:args) { { filenames: [design_b.filename, red_herring.filename] } }
it 'resolves to just the relevant design' do
create(:design, issue: create(:issue, project: project), filename: design_b.filename)
expect(resolve_objects).to contain_exactly(dav(design_b))
end
end
describe 'by id' do
let(:red_herring) { create(:design, issue: create(:issue, project: project)) }
let(:args) { { ids: [design_a, red_herring].map { |x| global_id_of(x) } } }
it 'resolves to just the relevant design, ignoring objects on other issues' do
expect(resolve_objects).to contain_exactly(dav(design_a))
end
end
end
end
def resolve_objects
resolve(described_class, obj: version, args: args, ctx: gql_context)
end
def dav(design)
build(:design_at_version, design: design, version: version)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::VersionInCollectionResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
let(:resolver) { described_class }
describe '#resolve' do
let_it_be(:issue) { create(:issue) }
let_it_be(:current_user) { create(:user) }
let_it_be(:first_version) { create(:design_version, issue: issue) }
let(:project) { issue.project }
let(:params) { {} }
before do
enable_design_management
project.add_developer(current_user)
end
let(:appropriate_error) { ::Gitlab::Graphql::Errors::ArgumentError }
subject(:result) { resolve_version(issue.design_collection) }
context 'Neither id nor sha is passed as parameters' do
it 'raises an appropriate error' do
expect { result }.to raise_error(appropriate_error)
end
end
context 'we pass an id' do
let(:params) { { id: global_id_of(first_version) } }
it { is_expected.to eq(first_version) }
end
context 'we pass a sha' do
let(:params) { { sha: first_version.sha } }
it { is_expected.to eq(first_version) }
end
context 'we pass an inconsistent mixture of sha and version id' do
let(:params) { { sha: first_version.sha, id: global_id_of(create(:design_version)) } }
it { is_expected.to be_nil }
end
context 'we pass the id of something that is not a design_version' do
let(:params) { { id: global_id_of(project) } }
it 'raises an appropriate error' do
expect { result }.to raise_error(appropriate_error)
end
end
end
def resolve_version(obj, context = { current_user: current_user })
resolve(resolver, obj: obj, args: params, ctx: context)
end
end
......@@ -6,54 +6,38 @@ describe Resolvers::DesignManagement::VersionResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:current_user) { create(:user) }
let_it_be(:version) { create(:design_version, issue: issue) }
let_it_be(:developer) { create(:user) }
let(:project) { issue.project }
let(:params) { { id: global_id_of(version) } }
before do
enable_design_management
project.add_developer(developer)
end
describe "#resolve" do
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:first_version) { create(:design_version) }
let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) }
context 'the current user is not authorized' do
let(:current_user) { create(:user) }
before do
project.add_developer(current_user)
end
context "for a design collection" do
let(:collection) { DesignManagement::DesignCollection.new(issue) }
it "returns the ordered versions" do
second_version = create(:design_version)
create(:design, issue: issue, versions: [second_version])
expect(resolve_versions(collection)).to eq([second_version, first_version])
end
end
context "for a design" do
it "returns the versions" do
expect(resolve_versions(first_design)).to eq([first_version])
end
it 'raises an error on resolution' do
expect { resolve_version }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context "when the user is anonymous" do
let(:current_user) { nil }
it "returns nothing" do
expect(resolve_versions(first_design)).to be_empty
end
end
context 'the current user is authorized' do
let(:current_user) { developer }
context "when the user cannot see designs" do
it "returns nothing" do
expect(resolve_versions(first_design, {}, current_user: create(:user))).to be_empty
context 'the id parameter is provided' do
it 'returns the specified version' do
expect(resolve_version).to eq(version)
end
end
end
def resolve_versions(obj, args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
def resolve_version
resolve(described_class, obj: nil, args: params, ctx: { current_user: current_user })
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::DesignManagement::VersionsResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
describe '#resolve' do
let(:resolver) { described_class }
let_it_be(:issue) { create(:issue) }
let_it_be(:authorized_user) { create(:user) }
let_it_be(:first_version) { create(:design_version, issue: issue) }
let_it_be(:other_version) { create(:design_version, issue: issue) }
let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version, other_version]) }
let_it_be(:other_design) { create(:design, :with_versions, issue: issue) }
let(:project) { issue.project }
let(:params) { {} }
let(:current_user) { authorized_user }
let(:parent_args) { { irrelevant: 1.2 } }
let(:parent) { double('Parent', parent: nil, irep_node: double(arguments: parent_args)) }
before do
enable_design_management
project.add_developer(authorized_user)
end
shared_examples 'a source of versions' do
subject(:result) { resolve_versions(object) }
let_it_be(:all_versions) { object.versions.ordered }
context 'when the user is not authorized' do
let(:current_user) { create(:user) }
it { is_expected.to be_empty }
end
context 'without constraints' do
it 'returns the ordered versions' do
expect(result).to eq(all_versions)
end
end
context 'when constrained' do
let_it_be(:matching) { all_versions.earlier_or_equal_to(first_version) }
shared_examples 'a query for all_versions up to the first_version' do
it { is_expected.to eq(matching) }
end
context 'by earlier_or_equal_to_id' do
let(:params) { { id: global_id_of(first_version) } }
it_behaves_like 'a query for all_versions up to the first_version'
end
context 'by earlier_or_equal_to_sha' do
let(:params) { { sha: first_version.sha } }
it_behaves_like 'a query for all_versions up to the first_version'
end
context 'by earlier_or_equal_to_sha AND earlier_or_equal_to_id' do
context 'and they match' do
# This usage is rather dumb, but so long as they match, this will
# return successfully
let(:params) do
{
sha: first_version.sha,
id: global_id_of(first_version)
}
end
it_behaves_like 'a query for all_versions up to the first_version'
end
context 'and they do not match' do
let(:params) do
{
sha: first_version.sha,
id: global_id_of(other_version)
}
end
it 'raises a suitable error' do
expect { result }.to raise_error(GraphQL::ExecutionError)
end
end
end
context 'by at_version in parent' do
let(:parent_args) { { atVersion: global_id_of(first_version) } }
it_behaves_like 'a query for all_versions up to the first_version'
end
end
end
describe 'a design collection' do
let_it_be(:object) { DesignManagement::DesignCollection.new(issue) }
it_behaves_like 'a source of versions'
end
describe 'a design' do
let_it_be(:object) { first_design }
it_behaves_like 'a source of versions'
end
def resolve_versions(obj, context = { current_user: current_user })
eager_resolve(resolver, obj: obj, args: params.merge(parent: parent), ctx: context)
end
end
end
......@@ -5,5 +5,9 @@ require 'spec_helper'
describe GitlabSchema.types['DesignCollection'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:project, :issue, :designs, :versions) }
it 'has the expected fields' do
expected_fields = %i[project issue designs versions version designAtVersion design]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
......@@ -5,5 +5,9 @@ require 'spec_helper'
describe GitlabSchema.types['DesignVersion'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:id, :sha, :designs) }
it 'has the expected fields' do
expected_fields = %i[id sha designs design_at_version designs_at_version]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['DesignManagement'] do
it { is_expected.to have_graphql_fields(:version, :design_at_version) }
end
......@@ -8,4 +8,6 @@ describe GitlabSchema.types['Issue'] do
it { expect(described_class).to have_graphql_field(:weight) }
it { expect(described_class).to have_graphql_field(:designs) }
it { expect(described_class).to have_graphql_field(:design_collection) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Query'] do
it do
is_expected.to have_graphql_fields(:design_management).at_least
end
end
......@@ -179,6 +179,57 @@ describe DesignManagement::Design do
end
end
describe '#visible_in?' do
set(:issue) { create(:issue) }
# It is expensive to re-create complex histories, so we do it once, and then
# assert that we can establish visibility at any given version.
it 'tells us when a design is visible' do
expected = []
first_design = create(:design, :with_versions, issue: issue, versions_count: 1)
prior_to_creation = first_design.versions.first
expected << [prior_to_creation, :not_created_yet, false]
v = modify_designs(first_design)
expected << [v, :not_created_yet, false]
design = create(:design, :with_versions, issue: issue, versions_count: 1)
created_in = design.versions.first
expected << [created_in, :created, true]
# The future state should not affect the result for any state, so we
# ensure that most states have a long future as well as a rich past
2.times do
v = modify_designs(first_design)
expected << [v, :unaffected_visible, true]
v = modify_designs(design)
expected << [v, :modified, true]
v = modify_designs(first_design)
expected << [v, :unaffected_visible, true]
v = delete_designs(design)
expected << [v, :deleted, false]
v = modify_designs(first_design)
expected << [v, :unaffected_nv, false]
v = restore_designs(design)
expected << [v, :restored, true]
end
delete_designs(design) # ensure visibility is not corelated with current state
got = expected.map do |(v, sym, _)|
[v, sym, design.visible_in?(v)]
end
expect(got).to eq(expected)
end
end
describe '#to_ability_name' do
it { expect(described_class.new.to_ability_name).to eq('design') }
end
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)' do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
let_it_be(:stranger) { create(:user) }
let_it_be(:old_version) do
create(:design_version, issue: issue,
created_designs: create_list(:design, 3, issue: issue))
end
let_it_be(:version) do
create(:design_version, issue: issue,
modified_designs: old_version.designs,
created_designs: create_list(:design, 2, issue: issue))
end
let(:current_user) { developer }
def query(vq = version_fields)
graphql_query_for(:project, { fullPath: project.full_path },
query_graphql_field(:issue, { iid: issue.iid.to_s },
query_graphql_field(:design_collection, nil,
query_graphql_field(:version, { sha: version.sha }, vq))))
end
let(:post_query) { post_graphql(query, current_user: current_user) }
let(:path_prefix) { %w[project issue designCollection version] }
let(:data) { graphql_data.dig(*path) }
before do
enable_design_management
project.add_developer(developer)
end
describe 'scalar fields' do
let(:path) { path_prefix }
let(:version_fields) { query_graphql_field(:sha) }
before do
post_query
end
{ id: ->(x) { x.to_global_id.to_s }, sha: ->(x) { x.sha } }.each do |field, value|
describe ".#{field}" do
let(:version_fields) { query_graphql_field(field) }
it "retrieves the #{field}" do
expect(data).to match(a_hash_including(field.to_s => value[version]))
end
end
end
end
describe 'design_at_version' do
let(:path) { path_prefix + %w[designAtVersion] }
let(:design) { issue.designs.visible_at_version(version).to_a.sample }
let(:design_at_version) { build(:design_at_version, design: design, version: version) }
let(:version_fields) do
query_graphql_field(:design_at_version, dav_params, 'id filename')
end
shared_examples :finds_dav do
it 'finds all the designs as of the given version' do
post_query
expect(data).to match(
a_hash_including(
'id' => global_id_of(design_at_version),
'filename' => design.filename
))
end
context 'when the current_user is not authorized' do
let(:current_user) { stranger }
it 'returns nil' do
post_query
expect(data).to be_nil
end
end
end
context 'by ID' do
let(:dav_params) { { id: global_id_of(design_at_version) } }
include_examples :finds_dav
end
context 'by filename' do
let(:dav_params) { { filename: design.filename } }
include_examples :finds_dav
end
context 'by design_id' do
let(:dav_params) { { design_id: global_id_of(design) } }
include_examples :finds_dav
end
end
describe 'designs_at_version' do
let(:path) { path_prefix + %w[designsAtVersion edges] }
let(:version_fields) do
query_graphql_field(:designs_at_version, dav_params, 'edges { node { id filename } }')
end
let(:dav_params) { nil }
let(:results) do
issue.designs.visible_at_version(version).map do |d|
dav = build(:design_at_version, design: d, version: version)
{ 'id' => global_id_of(dav), 'filename' => d.filename }
end
end
it 'finds all the designs as of the given version' do
post_query
expect(data.pluck('node')).to match_array(results)
end
describe 'filtering' do
let(:designs) { issue.designs.sample(3) }
let(:filenames) { designs.map(&:filename) }
let(:ids) do
designs.map { |d| global_id_of(build(:design_at_version, design: d, version: version)) }
end
before do
post_query
end
describe 'by filename' do
let(:dav_params) { { filenames: filenames } }
it 'finds the designs by filename' do
expect(data.map { |e| e.dig('node', 'id') }).to match_array(ids)
end
end
describe 'by design-id' do
let(:dav_params) { { ids: designs.map { |d| global_id_of(d) } } }
it 'finds the designs by id' do
expect(data.map { |e| e.dig('node', 'filename') }).to match_array(filenames)
end
end
end
describe 'pagination' do
let(:end_cursor) { graphql_data_at(*path_prefix, :designs_at_version, :page_info, :end_cursor) }
let(:ids) do
::DesignManagement::Design.visible_at_version(version).order(:id).map do |d|
global_id_of(build(:design_at_version, design: d, version: version))
end
end
let(:version_fields) do
query_graphql_field(:designs_at_version, { first: 2 }, fields)
end
let(:cursored_query) do
frag = query_graphql_field(:designs_at_version, { after: end_cursor }, fields)
query(frag)
end
let(:fields) { ['pageInfo { endCursor }', 'edges { node { id } }'] }
def response_values(data = graphql_data)
data.dig(*path).map { |e| e.dig('node', 'id') }
end
it 'sorts designs for reliable pagination' do
post_graphql(query, current_user: current_user)
expect(response_values).to match_array(ids.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = JSON.parse(response.body).fetch('data')
expect(response_values(new_data)).to match_array(ids.drop(2))
end
end
end
describe 'designs' do
let(:path) { path_prefix + %w[designs edges] }
let(:version_fields) do
query_graphql_field(:designs, nil, 'edges { node { id filename } }')
end
let(:results) do
version.designs.map do |design|
{ 'id' => global_id_of(design), 'filename' => design.filename }
end
end
it 'finds all the designs as of the given version' do
post_query
expect(data.pluck('node')).to match_array(results)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Getting versions related to an issue' do
include GraphqlHelpers
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:version_a) do
create(:design_version, issue: issue)
end
let_it_be(:version_b) do
create(:design_version, issue: issue)
end
let_it_be(:version_c) do
create(:design_version, issue: issue)
end
let_it_be(:version_d) do
create(:design_version, issue: issue)
end
let_it_be(:owner) { issue.project.owner }
def version_query(params = version_params)
query_graphql_field(:versions, params, version_query_fields)
end
let(:version_params) { nil }
let(:version_query_fields) { ['edges { node { sha } }'] }
let(:project) { issue.project }
let(:current_user) { owner }
let(:query) { make_query }
def make_query(vq = version_query)
graphql_query_for(:project, { fullPath: project.full_path },
query_graphql_field(:issue, { iid: issue.iid.to_s },
query_graphql_field(:design_collection, {}, vq)))
end
let(:design_collection) do
graphql_data_at(:project, :issue, :design_collection)
end
def response_values(data = graphql_data, key = 'sha')
path = %w[project issue designCollection versions edges]
data.dig(*path).map { |e| e.dig('node', key) }
end
before do
enable_design_management
end
it 'returns the design filename' do
post_graphql(query, current_user: current_user)
expect(response_values).to match_array([version_a, version_b, version_c, version_d].map(&:sha))
end
describe 'filter by sha' do
let(:sha) { version_b.sha }
let(:version_params) { { earlier_or_equal_to_sha: sha } }
it 'finds only those versions at or before the given cut-off' do
post_graphql(query, current_user: current_user)
expect(response_values).to contain_exactly(version_a.sha, version_b.sha)
end
end
describe 'filter by id' do
let(:id) { global_id_of(version_c) }
let(:version_params) { { earlier_or_equal_to_id: id } }
it 'finds only those versions at or before the given cut-off' do
post_graphql(query, current_user: current_user)
expect(response_values).to contain_exactly(version_a.sha, version_b.sha, version_c.sha)
end
end
describe 'pagination' do
let(:end_cursor) { design_collection.dig('versions', 'pageInfo', 'endCursor') }
let(:ids) { issue.design_collection.versions.ordered.map(&:sha) }
let(:query) { make_query(version_query(first: 2)) }
let(:cursored_query) do
make_query(version_query(after: end_cursor))
end
let(:version_query_fields) { ['pageInfo { endCursor }', 'edges { node { sha } }'] }
it 'sorts designs for reliable pagination' do
post_graphql(query, current_user: current_user)
expect(response_values).to match_array(ids.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = JSON.parse(response.body).fetch('data')
expect(response_values(new_data)).to match_array(ids.drop(2))
end
end
end
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe "Getting designs related to an issue" do
describe 'Getting designs related to an issue' do
include GraphqlHelpers
include DesignManagementTestHelpers
......@@ -19,57 +19,99 @@ describe "Getting designs related to an issue" do
}
NODE
end
let(:query) do
graphql_query_for(
"project",
{ "fullPath" => design.project.full_path },
query_graphql_field(
"issue",
{ iid: design.issue.iid },
query_graphql_field(
"designs", {}, design_query
)
)
)
let(:issue) { design.issue }
let(:project) { issue.project }
let(:query) { make_query }
def make_query(dq = design_query)
designs_field = query_graphql_field(:design_collection, {}, dq)
issue_field = query_graphql_field(:issue, { iid: issue.iid.to_s }, designs_field)
graphql_query_for(:project, { fullPath: project.full_path }, issue_field)
end
let(:design_collection) do
graphql_data["project"]["issue"]["designs"]
graphql_data_at(:project, :issue, :design_collection)
end
let(:design_response) do
design_collection["designs"]["edges"].first["node"]
design_collection.dig('designs', 'edges').first['node']
end
context "when the feature is not available" do
context 'when the feature is not available' do
before do
stub_licensed_features(design_management: false)
stub_feature_flags(design_managment: false)
end
it_behaves_like "a working graphql query" do
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it "returns no designs" do
it 'returns no designs' do
post_graphql(query, current_user: current_user)
expect(design_collection).to be_nil
end
end
context "when the feature is available" do
context 'when the feature is available' do
before do
enable_design_management
end
it "returns the design filename" do
it 'returns the design filename' do
post_graphql(query, current_user: current_user)
expect(design_response["filename"]).to eq(design.filename)
expect(design_response['filename']).to eq(design.filename)
end
describe 'pagination' do
before do
create_list(:design, 5, :with_file, issue: issue)
project.add_developer(current_user)
post_graphql(query, current_user: current_user)
end
let(:issue) { create(:issue) }
let(:end_cursor) { design_collection.dig('designs', 'pageInfo', 'endCursor') }
let(:ids) { issue.designs.order(:id).map { |d| global_id_of(d) } }
let(:query) { make_query(designs_fragment(first: 2)) }
let(:design_query_fields) { 'pageInfo { endCursor } edges { node { id } }' }
let(:cursored_query) do
make_query(designs_fragment(after: end_cursor))
end
def designs_fragment(params)
query_graphql_field(:designs, params, design_query_fields)
end
def response_ids(data = graphql_data)
path = %w[project issue designCollection designs edges]
data.dig(*path).map { |e| e.dig('node', 'id') }
end
it 'sorts designs for reliable pagination' do
expect(response_ids).to match_array(ids.take(2))
post_graphql(cursored_query, current_user: current_user)
new_data = JSON.parse(response.body).fetch('data')
expect(response_ids(new_data)).to match_array(ids.drop(2))
end
end
context "with versions" do
context 'with versions' do
set(:version) { design.versions.take }
let(:design_query) do
<<~NODE
......@@ -91,24 +133,24 @@ describe "Getting designs related to an issue" do
NODE
end
it "includes the version id" do
it 'includes the version id' do
post_graphql(query, current_user: current_user)
version_id = design_response["versions"]["edges"].first["node"]["id"]
version_id = design_response['versions']['edges'].first['node']['id']
expect(version_id).to eq(version.to_global_id.to_s)
end
it "includes the version sha" do
it 'includes the version sha' do
post_graphql(query, current_user: current_user)
version_sha = design_response["versions"]["edges"].first["node"]["sha"]
version_sha = design_response['versions']['edges'].first['node']['sha']
expect(version_sha).to eq(version.sha)
end
end
describe "viewing a design board at a particular version" do
describe 'viewing a design board at a particular version' do
set(:issue) { design.issue }
set(:second_design) { create(:design, :with_file, issue: issue, versions_count: 1) }
set(:deleted_design) { create(:design, :with_versions, issue: issue, deleted: true, versions_count: 1) }
......@@ -134,7 +176,7 @@ describe "Getting designs related to an issue" do
NODE
end
let(:design_response) do
design_collection["designs"]["edges"]
design_collection['designs']['edges']
end
def image_url(design, sha = nil)
......@@ -159,116 +201,116 @@ describe "Getting designs related to an issue" do
end
end
context "viewing the original version, when one design was created" do
context 'viewing the original version, when one design was created' do
let(:version) { all_versions.first }
before do
post_graphql(query, current_user: current_user)
end
it "only returns the first design" do
it 'only returns the first design' do
expect(design_nodes).to contain_exactly(
a_hash_including("id" => global_id(design))
a_hash_including('id' => global_id(design))
)
end
it "returns the correct version of the design image" do
it 'returns the correct version of the design image' do
expect(design_nodes).to contain_exactly(
a_hash_including("image" => image_url(design, version.sha))
a_hash_including('image' => image_url(design, version.sha))
)
end
it "returns the correct event for the design in this version" do
it 'returns the correct event for the design in this version' do
expect(design_nodes).to contain_exactly(
a_hash_including("event" => "CREATION")
a_hash_including('event' => 'CREATION')
)
end
it "only returns one version record for the design (the original version)" do
it 'only returns one version record for the design (the original version)' do
expect(version_nodes).to eq([
[{ "node" => { "id" => global_id(version) } }]
[{ 'node' => { 'id' => global_id(version) } }]
])
end
end
context "viewing the second version, when one design was created" do
context 'viewing the second version, when one design was created' do
let(:version) { all_versions.second }
before do
post_graphql(query, current_user: current_user)
end
it "only returns the first two designs" do
it 'only returns the first two designs' do
expect(design_nodes).to contain_exactly(
a_hash_including("id" => global_id(design)),
a_hash_including("id" => global_id(second_design))
a_hash_including('id' => global_id(design)),
a_hash_including('id' => global_id(second_design))
)
end
it "returns the correct versions of the design images" do
it 'returns the correct versions of the design images' do
expect(design_nodes).to contain_exactly(
a_hash_including("image" => image_url(design, version.sha)),
a_hash_including("image" => image_url(second_design, version.sha))
a_hash_including('image' => image_url(design, version.sha)),
a_hash_including('image' => image_url(second_design, version.sha))
)
end
it "returns the correct events for the designs in this version" do
it 'returns the correct events for the designs in this version' do
expect(design_nodes).to contain_exactly(
a_hash_including("event" => "NONE"),
a_hash_including("event" => "CREATION")
a_hash_including('event' => 'NONE'),
a_hash_including('event' => 'CREATION')
)
end
it "returns the correct versions records for both designs" do
it 'returns the correct versions records for both designs' do
expect(version_nodes).to eq([
[{ "node" => { "id" => global_id(design.versions.first) } }],
[{ "node" => { "id" => global_id(second_design.versions.first) } }]
[{ 'node' => { 'id' => global_id(design.versions.first) } }],
[{ 'node' => { 'id' => global_id(second_design.versions.first) } }]
])
end
end
context "viewing the last version, when one design was deleted and one was updated" do
context 'viewing the last version, when one design was deleted and one was updated' do
let(:version) { all_versions.last }
before do
second_design.actions.create!(version: version, event: "modification")
second_design.actions.create!(version: version, event: 'modification')
post_graphql(query, current_user: current_user)
end
it "does not include the deleted design" do
it 'does not include the deleted design' do
# The design does exist in the version
expect(version.designs).to include(deleted_design)
# But the GraphQL API does not include it in these results
expect(design_nodes).to contain_exactly(
a_hash_including("id" => global_id(design)),
a_hash_including("id" => global_id(second_design))
a_hash_including('id' => global_id(design)),
a_hash_including('id' => global_id(second_design))
)
end
it "returns the correct versions of the design images" do
it 'returns the correct versions of the design images' do
expect(design_nodes).to contain_exactly(
a_hash_including("image" => image_url(design, version.sha)),
a_hash_including("image" => image_url(second_design, version.sha))
a_hash_including('image' => image_url(design, version.sha)),
a_hash_including('image' => image_url(second_design, version.sha))
)
end
it "returns the correct events for the designs in this version" do
it 'returns the correct events for the designs in this version' do
expect(design_nodes).to contain_exactly(
a_hash_including("event" => "NONE"),
a_hash_including("event" => "MODIFICATION")
a_hash_including('event' => 'NONE'),
a_hash_including('event' => 'MODIFICATION')
)
end
it "returns all versions records for the designs" do
it 'returns all versions records for the designs' do
expect(version_nodes).to eq([
[
{ "node" => { "id" => global_id(design.versions.first) } }
{ 'node' => { 'id' => global_id(design.versions.first) } }
],
[
{ "node" => { "id" => global_id(second_design.versions.second) } },
{ "node" => { "id" => global_id(second_design.versions.first) } }
{ 'node' => { 'id' => global_id(second_design.versions.second) } },
{ 'node' => { 'id' => global_id(second_design.versions.first) } }
]
])
end
......@@ -298,7 +340,7 @@ describe "Getting designs related to an issue" do
end
let(:design_response) do
design_collection["designs"]["edges"].first["node"]
design_collection['designs']['edges'].first['node']
end
before do
......@@ -306,13 +348,13 @@ describe "Getting designs related to an issue" do
end
it 'returns the notes for the design' do
expect(design_response.dig("notes", "edges")).to eq(
["node" => { "id" => note.to_global_id.to_s }]
expect(design_response.dig('notes', 'edges')).to eq(
['node' => { 'id' => note.to_global_id.to_s }]
)
end
it 'returns a note_count for the design' do
expect(design_response["notesCount"]).to eq(1)
expect(design_response['notesCount']).to eq(1)
end
end
end
......
......@@ -60,7 +60,7 @@ describe 'Getting designs related to an issue' do
{ 'fullPath' => design.project.full_path },
query_graphql_field(
'issue',
{ iid: design.issue.iid },
{ iid: design.issue.iid.to_s },
query_graphql_field(
'designs', {}, design_node
)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Query.project(fullPath).issue(iid)' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:issue_b) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
let(:current_user) { developer }
let_it_be(:project_params) { { 'fullPath' => project.full_path } }
let_it_be(:issue_params) { { 'iid' => issue.iid.to_s } }
let_it_be(:issue_fields) { 'title' }
let(:query) do
graphql_query_for('project', project_params, project_fields)
end
let(:project_fields) do
query_graphql_field(:issue, issue_params, issue_fields)
end
shared_examples 'being able to fetch a design-like object by ID' do
let(:design) { design_a }
let(:path) { %w[project issue designCollection] + [GraphqlHelpers.fieldnamerize(object_field_name)] }
let(:design_fields) do
[
query_graphql_field(:filename),
query_graphql_field(:project, nil, query_graphql_field(:id))
]
end
let(:design_collection_fields) do
query_graphql_field(object_field_name, object_params, object_fields)
end
let(:object_fields) { design_fields }
context 'the ID is passed' do
let(:object_params) { { id: global_id_of(object) } }
let(:result_fields) { {} }
let(:expected_fields) do
result_fields.merge({ 'filename' => design.filename, 'project' => id_hash(project) })
end
it 'retrieves the object' do
post_query
data = graphql_data.dig(*path)
expect(data).to match(a_hash_including(expected_fields))
end
context 'the user is unauthorized' do
let(:current_user) { create(:user) }
it_behaves_like 'a failure to find anything'
end
end
context 'without parameters' do
let(:object_params) { nil }
it 'raises an error' do
post_query
expect(graphql_errors).to include(no_argument_error)
end
end
context 'attempting to retrieve an object from a different issue' do
let(:object_params) { { id: global_id_of(object_on_other_issue) } }
it_behaves_like 'a failure to find anything'
end
end
before do
project.add_developer(developer)
end
let(:post_query) { post_graphql(query, current_user: current_user) }
describe '.designCollection' do
include DesignManagementTestHelpers
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) }
let(:issue_fields) do
query_graphql_field(:design_collection, dc_params, design_collection_fields)
end
let(:dc_params) { nil }
let(:design_collection_fields) { nil }
before do
enable_design_management
end
describe '.design' do
let(:object) { design }
let(:object_field_name) { :design }
let(:no_argument_error) do
custom_graphql_error(path, a_string_matching(%r/id or filename/))
end
let_it_be(:object_on_other_issue) { create(:design, issue: issue_b) }
it_behaves_like 'being able to fetch a design-like object by ID'
it_behaves_like 'being able to fetch a design-like object by ID' do
let(:object_params) { { filename: design.filename } }
end
end
describe '.version' do
let(:version) { version_a }
let(:path) { %w[project issue designCollection version] }
let(:design_collection_fields) do
query_graphql_field(:version, version_params, 'id sha')
end
context 'no parameters' do
let(:version_params) { nil }
it 'raises an error' do
post_query
expect(graphql_errors).to include(custom_graphql_error(path, a_string_matching(%r/id or sha/)))
end
end
shared_examples 'a successful query for a version' do
it 'finds the version' do
post_query
data = graphql_data.dig(*path)
expect(data).to match(
a_hash_including('id' => global_id_of(version),
'sha' => version.sha)
)
end
end
context '(sha: STRING_TYPE)' do
let(:version_params) { { sha: version.sha } }
it_behaves_like 'a successful query for a version'
end
context '(id: ID_TYPE)' do
let(:version_params) { { id: global_id_of(version) } }
it_behaves_like 'a successful query for a version'
end
end
describe '.designAtVersion' do
it_behaves_like 'being able to fetch a design-like object by ID' do
let(:object) { build(:design_at_version, design: design, version: version) }
let(:object_field_name) { :design_at_version }
let(:version) { version_a }
let(:result_fields) { { 'version' => id_hash(version) } }
let(:object_fields) do
design_fields + [query_graphql_field(:version, nil, query_graphql_field(:id))]
end
let(:no_argument_error) { missing_required_argument(path, :id) }
let(:object_on_other_issue) { build(:design_at_version, issue: issue_b) }
end
end
end
def id_hash(object)
a_hash_including('id' => global_id_of(object))
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Query' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:developer) { create(:user) }
let(:current_user) { developer }
describe '.designManagement' do
include DesignManagementTestHelpers
let_it_be(:version) { create(:design_version, issue: issue) }
let_it_be(:design) { version.designs.first }
let(:query_result) { graphql_data.dig(*path) }
let(:query) { graphql_query_for(:design_management, nil, dm_fields) }
before do
enable_design_management
project.add_developer(developer)
post_graphql(query, current_user: current_user)
end
shared_examples 'a query that needs authorization' do
context 'the current user is not able to read designs' do
let(:current_user) { create(:user) }
it 'does not retrieve the record' do
expect(query_result).to be_nil
end
it 'raises an error' do
expect(graphql_errors).to include(
a_hash_including('message' => a_string_matching(%r{you don't have permission}))
)
end
end
end
describe '.version' do
let(:path) { %w[designManagement version] }
let(:dm_fields) do
query_graphql_field(:version, { 'id' => global_id_of(version) }, 'id sha')
end
it_behaves_like 'a working graphql query'
it_behaves_like 'a query that needs authorization'
context 'the current user is able to read designs' do
it 'fetches the expected data' do
expect(query_result).to eq('id' => global_id_of(version), 'sha' => version.sha)
end
end
end
describe '.designAtVersion' do
let_it_be(:design_at_version) do
::DesignManagement::DesignAtVersion.new(design: design, version: version)
end
let(:path) { %w[designManagement designAtVersion] }
let(:dm_fields) do
query_graphql_field(:design_at_version, { 'id' => global_id_of(design_at_version) }, <<~FIELDS)
id
filename
version { id sha }
design { id }
issue { title iid }
project { id fullPath }
FIELDS
end
it_behaves_like 'a working graphql query'
it_behaves_like 'a query that needs authorization'
context 'the current user is able to read designs' do
it 'fetches the expected data, including the correct associations' do
expect(query_result).to eq(
'id' => global_id_of(design_at_version),
'filename' => design_at_version.design.filename,
'version' => { 'id' => global_id_of(version), 'sha' => version.sha },
'design' => { 'id' => global_id_of(design) },
'issue' => { 'title' => issue.title, 'iid' => issue.iid.to_s },
'project' => { 'id' => global_id_of(project), 'fullPath' => project.full_path }
)
end
end
end
end
end
# frozen_string_literal: true
shared_context 'four designs in three versions' do
include DesignManagementTestHelpers
let_it_be(:issue) { create(:issue) }
let_it_be(:project) { issue.project }
let_it_be(:authorized_user) { create(:user) }
let_it_be(:design_a) { create(:design, issue: issue) }
let_it_be(:design_b) { create(:design, issue: issue) }
let_it_be(:design_c) { create(:design, issue: issue) }
let_it_be(:design_d) { create(:design, issue: issue) }
let_it_be(:first_version) do
create(:design_version, issue: issue,
created_designs: [design_a],
modified_designs: [],
deleted_designs: [])
end
let_it_be(:second_version) do
create(:design_version, issue: issue,
created_designs: [design_b, design_c, design_d],
modified_designs: [design_a],
deleted_designs: [])
end
let_it_be(:third_version) do
create(:design_version, issue: issue,
created_designs: [],
modified_designs: [design_a],
deleted_designs: [design_d])
end
before do
enable_design_management
project.add_developer(authorized_user)
end
end
......@@ -7,15 +7,10 @@ describe GitlabSchema.types['Query'] do
expect(described_class.graphql_name).to eq('Query')
end
it do
is_expected.to have_graphql_fields(:project,
:namespace,
:group,
:echo,
:metadata,
:current_user,
:snippets
).at_least
it 'has the expected fields' do
expected_fields = %i[project namespace group echo metadata current_user snippets]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
describe 'namespace field' do
......
......@@ -14,7 +14,7 @@ describe 'getting merge request information nested in a project' do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', iid: merge_request.iid)
query_graphql_field('mergeRequest', iid: merge_request.iid.to_s)
)
end
......
......@@ -25,7 +25,7 @@ describe 'getting task completion status information' do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field(type, { iid: iid }, fields)
query_graphql_field(type, { iid: iid.to_s }, fields)
)
end
......
......@@ -16,6 +16,20 @@ module GraphqlHelpers
resolver_class.new(object: obj, context: ctx).resolve(args)
end
# Eagerly run a loader's named resolver
# (syncs any lazy values returned by resolve)
def eager_resolve(resolver_class, **opts)
sync(resolve(resolver_class, **opts))
end
def sync(value)
if GitlabSchema.lazy?(value)
GitlabSchema.sync_lazy(value)
else
value
end
end
# Runs a block inside a BatchLoader::Executor wrapper
def batch(max_queries: nil, &blk)
wrapper = proc do
......@@ -39,7 +53,7 @@ module GraphqlHelpers
def batch_sync(max_queries: nil, &blk)
wrapper = proc do
lazy_vals = yield
lazy_vals.is_a?(Array) ? lazy_vals.map(&:sync) : lazy_vals&.sync
lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals)
end
batch(max_queries: max_queries, &wrapper)
......@@ -164,16 +178,26 @@ module GraphqlHelpers
def attributes_to_graphql(attributes)
attributes.map do |name, value|
value_str = if value.is_a?(Array)
'["' + value.join('","') + '"]'
else
"\"#{value}\""
end
value_str = as_graphql_literal(value)
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}"
end.join(", ")
end
# Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing.
# Missing support for Enums (feel free to add if you need it).
def as_graphql_literal(value)
case value
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
when Integer, Float then value.to_s
when String then "\"#{value.gsub(/"/, '\\"')}\""
when nil then 'null'
when true then 'true'
when false then 'false'
else raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
end
end
def post_multiplex(queries, current_user: nil, headers: {})
post api('/', current_user, version: 'graphql'), params: { _json: queries }, headers: headers
end
......@@ -216,6 +240,11 @@ module GraphqlHelpers
json_response['data'] || (raise NoData, graphql_errors)
end
def graphql_data_at(*path)
keys = path.map { |segment| GraphqlHelpers.fieldnamerize(segment) }
graphql_data.dig(*keys)
end
def graphql_errors
case json_response
when Hash # regular query
......
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