Commit 6c73ac27 authored by Denys Mishunov's avatar Denys Mishunov Committed by Phil Hughes

Add blob resolver to snippet GraphQL type

In order to retrieve single or multiple blobs
from snippets and avoid retrieving all at the
same time, we add a resolver to the blobs field in the
snippet type.

We can now pass a string or an array of files to retrieve.
parent 581e0829
...@@ -151,7 +151,7 @@ export default { ...@@ -151,7 +151,7 @@ export default {
this.newSnippet = false; this.newSnippet = false;
}, },
onSnippetFetch(snippetRes) { onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) { if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched(); this.onNewSnippetFetched();
} else { } else {
this.onExistingSnippetFetched(); this.onExistingSnippetFetched();
......
...@@ -23,6 +23,7 @@ export default { ...@@ -23,6 +23,7 @@ export default {
return { return {
ids: this.snippet.id, ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER, rich: this.activeViewerType === RICH_BLOB_VIEWER,
paths: [this.blob.path],
}; };
}, },
update(data) { update(data) {
...@@ -79,8 +80,10 @@ export default { ...@@ -79,8 +80,10 @@ export default {
}, },
onContentUpdate(data) { onContentUpdate(data) {
const { path: blobPath } = this.blob; const { path: blobPath } = this.blob;
const { blobs } = data.snippets.edges[0].node; const {
const updatedBlobData = blobs.find(blob => blob.path === blobPath); blobs: { nodes: dataBlobs },
} = data.snippets.nodes[0];
const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData; return updatedBlobData.richData || updatedBlobData.plainData;
}, },
}, },
......
...@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; ...@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
export default { export default {
components: { components: {
...@@ -37,6 +38,7 @@ export default { ...@@ -37,6 +38,7 @@ export default {
}, },
apollo: { apollo: {
canCreateSnippet: { canCreateSnippet: {
fetchPolicy: fetchPolicies.NO_CACHE,
query() { query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet; return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
}, },
......
...@@ -12,18 +12,20 @@ fragment SnippetBase on Snippet { ...@@ -12,18 +12,20 @@ fragment SnippetBase on Snippet {
httpUrlToRepo httpUrlToRepo
sshUrlToRepo sshUrlToRepo
blobs { blobs {
binary nodes {
name binary
path name
rawPath path
size rawPath
externalStorage size
renderedAsText externalStorage
simpleViewer { renderedAsText
...BlobViewer simpleViewer {
} ...BlobViewer
richViewer { }
...BlobViewer richViewer {
...BlobViewer
}
} }
} }
userPermissions { userPermissions {
......
...@@ -16,7 +16,7 @@ function appFactory(el, Component) { ...@@ -16,7 +16,7 @@ function appFactory(el, Component) {
} }
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient({}, { batchMax: 1 }),
}); });
const { const {
......
...@@ -11,9 +11,16 @@ export const getSnippetMixin = { ...@@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid, ids: this.snippetGid,
}; };
}, },
update: data => data.snippets.edges[0]?.node, update: data => {
const res = data.snippets.nodes[0];
if (res) {
res.blobs = res.blobs.nodes;
}
return res;
},
result(res) { result(res) {
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) { if (this.onSnippetFetch) {
this.onSnippetFetch(res); this.onSnippetFetch(res);
} }
......
query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) { snippets(ids: $ids) {
edges { nodes {
node { id
id blobs(paths: $paths) {
blobs { nodes {
path path
richData @include(if: $rich) richData @include(if: $rich)
plainData @skip(if: $rich) plainData @skip(if: $rich)
......
...@@ -4,13 +4,11 @@ ...@@ -4,13 +4,11 @@
query GetSnippetQuery($ids: [ID!]) { query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) { snippets(ids: $ids) {
edges { nodes {
node { ...SnippetBase
...SnippetBase ...SnippetProject
...SnippetProject author {
author { ...Author
...Author
}
} }
} }
} }
......
# frozen_string_literal: true
module Resolvers
module Snippets
class BlobsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :snippet, :object
argument :paths, [GraphQL::STRING_TYPE],
required: false,
description: 'Paths of the blobs'
def resolve(**args)
authorize!(snippet)
return [snippet.blob] if snippet.empty_repo?
paths = Array(args.fetch(:paths, []))
if paths.empty?
snippet.blobs
else
snippet.repository.blobs_at(transformed_blob_paths(paths))
end
end
def authorized_resource?(snippet)
Ability.allowed?(context[:current_user], :read_snippet, snippet)
end
private
def transformed_blob_paths(paths)
ref = snippet.default_branch
paths.map { |path| [ref, path] }
end
end
end
end
...@@ -69,10 +69,11 @@ module Types ...@@ -69,10 +69,11 @@ module Types
null: false, null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' } deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
field :blobs, type: [Types::Snippets::BlobType], field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs', description: 'Snippet blobs',
calls_gitaly: true, calls_gitaly: true,
null: false null: true,
resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE, field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository', description: 'SSH URL to the snippet repository',
......
...@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated ...@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end end
def blob def blob
blobs.first return snippet.blob if snippet.empty_repo?
end
def blobs blobs.first
if snippet.empty_repo?
[snippet.blob]
else
snippet.blobs
end
end end
private private
......
...@@ -16250,7 +16250,32 @@ type Snippet implements Noteable { ...@@ -16250,7 +16250,32 @@ type Snippet implements Noteable {
""" """
Snippet blobs Snippet blobs
""" """
blobs: [SnippetBlob!]! blobs(
"""
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
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Paths of the blobs
"""
paths: [String!]
): SnippetBlobConnection
""" """
Timestamp this snippet was created Timestamp this snippet was created
...@@ -16473,6 +16498,41 @@ input SnippetBlobActionInputType { ...@@ -16473,6 +16498,41 @@ input SnippetBlobActionInputType {
previousPath: String previousPath: String
} }
"""
The connection type for SnippetBlob.
"""
type SnippetBlobConnection {
"""
A list of edges.
"""
edges: [SnippetBlobEdge]
"""
A list of nodes.
"""
nodes: [SnippetBlob]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type SnippetBlobEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: SnippetBlob
}
""" """
Represents how the blob content should be displayed Represents how the blob content should be displayed
""" """
......
...@@ -47549,24 +47549,69 @@ ...@@ -47549,24 +47549,69 @@
"name": "blobs", "name": "blobs",
"description": "Snippet blobs", "description": "Snippet blobs",
"args": [ "args": [
{
], "name": "paths",
"type": { "description": "Paths of the blobs",
"kind": "NON_NULL", "type": {
"name": null, "kind": "LIST",
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null, "name": null,
"ofType": { "ofType": {
"kind": "OBJECT", "kind": "NON_NULL",
"name": "SnippetBlob", "name": null,
"ofType": 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": "OBJECT",
"name": "SnippetBlobConnection",
"ofType": null
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
...@@ -48220,6 +48265,118 @@ ...@@ -48220,6 +48265,118 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "SnippetBlobConnection",
"description": "The connection type for SnippetBlob.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlobEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"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": "SnippetBlobEdge",
"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": "SnippetBlob",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "SnippetBlobViewer", "name": "SnippetBlobViewer",
...@@ -2303,7 +2303,6 @@ Represents a snippet entry. ...@@ -2303,7 +2303,6 @@ Represents a snippet entry.
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `author` | User | The owner of the snippet | | `author` | User | The owner of the snippet |
| `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 | | `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 |
| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created | | `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet | | `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
......
...@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => { ...@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => {
// Ideally we wouldn't call this method directly, but we don't have a way to trigger // Ideally we wouldn't call this method directly, but we don't have a way to trigger
// apollo responses yet. // apollo responses yet.
const loadSnippet = (...edges) => { const loadSnippet = (...nodes) => {
if (edges.length) { if (nodes.length) {
wrapper.setData({ wrapper.setData({
snippet: edges[0], snippet: nodes[0],
}); });
} }
wrapper.vm.onSnippetFetch({ wrapper.vm.onSnippetFetch({
data: { data: {
snippets: { snippets: {
edges, nodes,
}, },
}, },
}); });
......
...@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => { ...@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => {
async ({ snippetBlobs, currentBlob, expectedContent }) => { async ({ snippetBlobs, currentBlob, expectedContent }) => {
const apolloData = { const apolloData = {
snippets: { snippets: {
edges: [ nodes: [
{ {
node: { blobs: {
blobs: snippetBlobs, nodes: snippetBlobs,
}, },
}, },
], ],
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Snippets::BlobsResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
context 'when user is not authorized' do
let(:other_user) { create(:user) }
it 'raises an error' do
expect do
resolve_blobs(snippet, user: other_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when using no filter' do
it 'returns all snippet blobs' do
expect(resolve_blobs(snippet).map(&:path)).to contain_exactly(*snippet.list_files)
end
end
context 'when using filters' do
context 'when paths is a single string' do
it 'returns an array of files' do
path = 'CHANGELOG'
expect(resolve_blobs(snippet, args: { paths: path }).first.path).to eq(path)
end
end
context 'when paths is an array of string' do
it 'returns an array of files' do
paths = ['CHANGELOG', 'README.md']
expect(resolve_blobs(snippet, args: { paths: paths }).map(&:path)).to contain_exactly(*paths)
end
end
end
end
def resolve_blobs(snippet, user: current_user, args: {})
resolve(described_class, args: args, ctx: { current_user: user }, obj: snippet)
end
end
...@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
end end
describe 'blobs field' do
subject { described_class.fields['blobs'] }
it 'returns blobs' do
is_expected.to have_graphql_type(Types::Snippets::BlobType.connection_type)
is_expected.to have_graphql_resolver(Resolvers::Snippets::BlobsResolver)
end
end
context 'when restricted visibility level is set to public' do context 'when restricted visibility level is set to public' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) } let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
...@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do
describe '#blobs' do describe '#blobs' do
let_it_be(:snippet) { create(:personal_snippet, :public, author: user) } let_it_be(:snippet) { create(:personal_snippet, :public, author: user) }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] } let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blobs', 'edges') }
let(:paths) { [] }
let(:query) do
%(
{
snippets {
edges {
node {
blobs(paths: #{paths}) {
edges {
node {
name
path
}
}
}
}
}
}
}
)
end
subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json } subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
shared_examples 'an array' do shared_examples 'an array' do
it 'returns an array of snippet blobs' do it 'returns an array of snippet blobs' do
...@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array' it_behaves_like 'an array'
it 'contains the first blob from the snippet' do it 'contains the first blob from the snippet' do
expect(query_blobs.first['name']).to eq blob.name expect(query_blobs.first['node']['name']).to eq blob.name
expect(query_blobs.first['path']).to eq blob.path expect(query_blobs.first['node']['path']).to eq blob.path
end end
end end
...@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do ...@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array' it_behaves_like 'an array'
it 'contains all the blobs from the repository' do it 'contains all the blobs from the repository' do
resulting_blobs_names = query_blobs.map { |b| b['name'] } resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match_array(blobs.map(&:name)) expect(resulting_blobs_names).to match_array(blobs.map(&:name))
end end
context 'when specific path is set' do
let(:paths) { ['CHANGELOG'] }
it_behaves_like 'an array'
it 'returns specific files' do
resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match(paths)
end
end
end end
end end
......
...@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do ...@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do
end end
end end
end end
describe '#blobs' do
let(:snippet) { personal_snippet }
subject { presenter.blobs }
context 'when snippet does not have a repository' do
it 'returns an array with one SnippetBlob' do
expect(subject.size).to eq(1)
expect(subject.first).to eq(snippet.blob)
end
end
context 'when snippet has a repository' do
let(:snippet) { create(:snippet, :repository, author: user) }
it 'returns an array with all repository blobs' do
expect(subject).to match_array(snippet.blobs)
end
end
end
end end
...@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do ...@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do
end end
shared_examples 'creates snippet' do shared_examples 'creates snippet' do
it 'returns the created Snippet' do it 'returns the created Snippet', :aggregate_failures do
expect do expect do
subject subject
end.to change { Snippet.count }.by(1) end.to change { Snippet.count }.by(1)
snippet = Snippet.last
created_file_1 = snippet.repository.blob_at('HEAD', file_1[:filePath])
created_file_2 = snippet.repository.blob_at('HEAD', file_2[:filePath])
expect(created_file_1.data).to match(file_1[:content])
expect(created_file_2.data).to match(file_2[:content])
expect(mutation_response['snippet']['title']).to eq(title) expect(mutation_response['snippet']['title']).to eq(title)
expect(mutation_response['snippet']['description']).to eq(description) expect(mutation_response['snippet']['description']).to eq(description)
expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level) expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
end end
context 'when action is invalid' do context 'when action is invalid' do
......
...@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do ...@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do aggregate_failures do
expect(blob_to_update.data).to eq updated_content expect(blob_to_update.data).to eq updated_content
expect(blob_to_delete).to be_nil expect(blob_to_delete).to be_nil
expect(blob_in_mutation_response(updated_file)['plainData']).to match(updated_content)
expect(mutation_response['snippet']['title']).to eq(updated_title) expect(mutation_response['snippet']['title']).to eq(updated_title)
expect(mutation_response['snippet']['description']).to eq(updated_description) expect(mutation_response['snippet']['description']).to eq(updated_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('public') expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
...@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do ...@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do aggregate_failures do
expect(blob_at(updated_file).data).to eq blob_to_update.data expect(blob_at(updated_file).data).to eq blob_to_update.data
expect(blob_at(deleted_file).data).to eq blob_to_delete.data expect(blob_at(deleted_file).data).to eq blob_to_delete.data
expect(blob_in_mutation_response(deleted_file)['plainData']).not_to be_nil
expect(mutation_response['snippet']['title']).to eq(original_title) expect(mutation_response['snippet']['title']).to eq(original_title)
expect(mutation_response['snippet']['description']).to eq(original_description) expect(mutation_response['snippet']['description']).to eq(original_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('private') expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
...@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do ...@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do
end end
end end
def blob_in_mutation_response(filename)
mutation_response['snippet']['blobs'].select { |blob| blob['name'] == filename }[0]
end
def blob_at(filename) def blob_at(filename)
snippet.repository.blob_at('HEAD', filename) snippet.repository.blob_at('HEAD', filename)
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment