Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
43bf93be
Commit
43bf93be
authored
Oct 29, 2021
by
Paul Gascou-Vaillancourt
Committed by
Ezekiel Kigbo
Oct 29, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Implement the All on-demand DAST scans tab
parent
20934f17
Changes
13
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
478 additions
and
13 deletions
+478
-13
app/assets/javascripts/vue_shared/components/ci_badge_link.vue
...ssets/javascripts/vue_shared/components/ci_badge_link.vue
+1
-0
ee/app/assets/javascripts/on_demand_scans/components/tabs/all.vue
...ssets/javascripts/on_demand_scans/components/tabs/all.vue
+35
-2
ee/app/assets/javascripts/on_demand_scans/components/tabs/base_tab.vue
.../javascripts/on_demand_scans/components/tabs/base_tab.vue
+154
-2
ee/app/assets/javascripts/on_demand_scans/constants.js
ee/app/assets/javascripts/on_demand_scans/constants.js
+3
-0
ee/app/assets/javascripts/on_demand_scans/graphql/on_demand_scans.query.graphql
...pts/on_demand_scans/graphql/on_demand_scans.query.graphql
+34
-0
ee/app/assets/javascripts/on_demand_scans/graphql/provider.js
...pp/assets/javascripts/on_demand_scans/graphql/provider.js
+16
-0
ee/app/assets/javascripts/on_demand_scans/on_demand_scans_bundle.js
...ets/javascripts/on_demand_scans/on_demand_scans_bundle.js
+2
-0
ee/app/views/projects/on_demand_scans/index.html.haml
ee/app/views/projects/on_demand_scans/index.html.haml
+1
-0
ee/spec/frontend/fixtures/on_demand_dast_scans.rb
ee/spec/frontend/fixtures/on_demand_dast_scans.rb
+62
-0
ee/spec/frontend/on_demand_scans/components/tabs/__snapshots__/all_spec.js.snap
...mand_scans/components/tabs/__snapshots__/all_spec.js.snap
+30
-0
ee/spec/frontend/on_demand_scans/components/tabs/all_spec.js
ee/spec/frontend/on_demand_scans/components/tabs/all_spec.js
+1
-0
ee/spec/frontend/on_demand_scans/components/tabs/base_tab_spec.js
...frontend/on_demand_scans/components/tabs/base_tab_spec.js
+133
-9
locale/gitlab.pot
locale/gitlab.pot
+6
-0
No files found.
app/assets/javascripts/vue_shared/components/ci_badge_link.vue
View file @
43bf93be
...
...
@@ -21,6 +21,7 @@ import CiIcon from './ci_icon.vue';
* - Job show view - header
* - MR widget
* - Terraform table
* - On-demand scans list
*/
export
default
{
...
...
ee/app/assets/javascripts/on_demand_scans/components/tabs/all.vue
View file @
43bf93be
<
script
>
import
{
__
}
from
'
~/locale
'
;
import
{
__
,
s__
}
from
'
~/locale
'
;
import
onDemandScansQuery
from
'
../../graphql/on_demand_scans.query.graphql
'
;
import
BaseTab
from
'
./base_tab.vue
'
;
export
default
{
query
:
onDemandScansQuery
,
components
:
{
BaseTab
,
},
tableFields
:
[
{
label
:
__
(
'
Status
'
),
key
:
'
detailedStatus
'
,
},
{
label
:
__
(
'
Name
'
),
key
:
'
dastProfile.name
'
,
},
{
label
:
s__
(
'
OnDemandScans|Scan type
'
),
key
:
'
scanType
'
,
},
{
label
:
s__
(
'
OnDemandScans|Target
'
),
key
:
'
dastProfile.dastSiteProfile.targetUrl
'
,
},
{
label
:
__
(
'
Start date
'
),
key
:
'
createdAt
'
,
},
{
label
:
__
(
'
Pipeline
'
),
key
:
'
id
'
,
},
],
i18n
:
{
title
:
__
(
'
All
'
),
},
...
...
@@ -13,5 +41,10 @@ export default {
</
script
>
<
template
>
<base-tab
:title=
"$options.i18n.title"
v-bind=
"$attrs"
/>
<base-tab
v-bind=
"$attrs"
:query=
"$options.query"
:title=
"$options.i18n.title"
:fields=
"$options.tableFields"
/>
</
template
>
ee/app/assets/javascripts/on_demand_scans/components/tabs/base_tab.vue
View file @
43bf93be
<
script
>
import
{
GlTab
,
GlBadge
}
from
'
@gitlab/ui
'
;
import
{
GlTab
,
GlBadge
,
GlLink
,
GlTable
,
GlKeysetPagination
}
from
'
@gitlab/ui
'
;
import
CiBadgeLink
from
'
~/vue_shared/components/ci_badge_link.vue
'
;
import
TimeAgoTooltip
from
'
~/vue_shared/components/time_ago_tooltip.vue
'
;
import
{
DAST_SHORT_NAME
}
from
'
~/security_configuration/components/constants
'
;
import
{
__
}
from
'
~/locale
'
;
import
{
getIdFromGraphQLId
}
from
'
~/graphql_shared/utils
'
;
import
{
scrollToElement
}
from
'
~/lib/utils/common_utils
'
;
import
EmptyState
from
'
../empty_state.vue
'
;
import
{
PIPELINES_PER_PAGE
,
PIPELINES_POLL_INTERVAL
}
from
'
../../constants
'
;
const
defaultCursor
=
{
first
:
PIPELINES_PER_PAGE
,
last
:
null
,
after
:
null
,
before
:
null
,
};
export
default
{
PIPELINES_PER_PAGE
,
DAST_SHORT_NAME
,
getIdFromGraphQLId
,
components
:
{
GlTab
,
GlBadge
,
GlLink
,
GlTable
,
GlKeysetPagination
,
CiBadgeLink
,
TimeAgoTooltip
,
EmptyState
,
},
inject
:
[
'
projectPath
'
],
props
:
{
query
:
{
type
:
Object
,
required
:
true
,
},
title
:
{
type
:
String
,
required
:
true
,
...
...
@@ -27,6 +54,94 @@ export default {
required
:
false
,
default
:
undefined
,
},
fields
:
{
type
:
Array
,
required
:
true
,
},
},
apollo
:
{
pipelines
:
{
query
()
{
return
this
.
query
;
},
variables
()
{
return
{
fullPath
:
this
.
projectPath
,
...
this
.
cursor
,
};
},
update
(
data
)
{
const
pipelines
=
data
?.
project
?.
pipelines
;
if
(
!
pipelines
?.
nodes
?.
length
&&
(
this
.
cursor
.
after
||
this
.
cursor
.
before
))
{
this
.
resetCursor
();
this
.
updateRoute
();
}
return
pipelines
;
},
pollInterval
:
PIPELINES_POLL_INTERVAL
,
},
},
data
()
{
const
{
after
,
before
}
=
this
.
$route
.
query
;
const
cursor
=
{
...
defaultCursor
};
if
(
after
)
{
cursor
.
after
=
after
;
}
else
if
(
before
)
{
cursor
.
before
=
before
;
cursor
.
first
=
null
;
cursor
.
last
=
PIPELINES_PER_PAGE
;
}
return
{
cursor
,
hasError
:
false
,
};
},
computed
:
{
hasPipelines
()
{
return
Boolean
(
this
.
pipelines
?.
nodes
?.
length
);
},
tableFields
()
{
return
this
.
fields
.
map
(({
key
,
label
})
=>
({
key
,
label
,
class
:
[
'
gl-text-black-normal
'
],
thClass
:
[
'
gl-bg-transparent!
'
,
'
gl-white-space-nowrap
'
],
}));
},
},
methods
:
{
resetCursor
()
{
this
.
cursor
=
{
...
defaultCursor
};
},
nextPage
(
after
)
{
this
.
cursor
=
{
...
defaultCursor
,
after
,
};
this
.
updateRoute
({
after
});
},
prevPage
(
before
)
{
this
.
cursor
=
{
first
:
null
,
last
:
PIPELINES_PER_PAGE
,
after
:
null
,
before
,
};
this
.
updateRoute
({
before
});
},
updateRoute
(
query
=
{})
{
scrollToElement
(
this
.
$el
);
this
.
$router
.
push
({
path
:
this
.
$route
.
path
,
query
,
});
},
},
i18n
:
{
previousPage
:
__
(
'
Prev
'
),
nextPage
:
__
(
'
Next
'
),
},
};
</
script
>
...
...
@@ -37,6 +152,43 @@ export default {
{{
title
}}
<gl-badge
size=
"sm"
class=
"gl-tab-counter-badge"
>
{{
itemsCount
}}
</gl-badge>
</
template
>
<empty-state
:title=
"emptyStateTitle"
:text=
"emptyStateText"
no-primary-button
/>
<
template
v-if=
"hasPipelines"
>
<gl-table
thead-class=
"gl-border-b-solid gl-border-gray-100 gl-border-1"
:fields=
"tableFields"
:items=
"pipelines.nodes"
stacked=
"md"
>
<template
#cell(detailedStatus)=
"
{ item }">
<div
class=
"gl-my-3"
>
<ci-badge-link
:status=
"item.detailedStatus"
/>
</div>
</
template
>
<
template
#cell
(
scanType
)
>
{{
$options
.
DAST_SHORT_NAME
}}
</
template
>
<
template
#cell(createdAt)=
"{ item }"
>
<time-ago-tooltip
v-if=
"item.createdAt"
:time=
"item.createdAt"
tooltip-placement=
"left"
/>
</
template
>
<
template
#cell(id)=
"{ item }"
>
<gl-link
:href=
"item.path"
>
#
{{
$options
.
getIdFromGraphQLId
(
item
.
id
)
}}
</gl-link>
</
template
>
</gl-table>
<div
class=
"gl-display-flex gl-justify-content-center"
>
<gl-keyset-pagination
data-testid=
"pagination"
v-bind=
"pipelines.pageInfo"
:prev-text=
"$options.i18n.previousPage"
:next-text=
"$options.i18n.nextPage"
@
prev=
"prevPage"
@
next=
"nextPage"
/>
</div>
</template>
<empty-state
v-else
:title=
"emptyStateTitle"
:text=
"emptyStateText"
no-primary-button
/>
</gl-tab>
</template>
ee/app/assets/javascripts/on_demand_scans/constants.js
View file @
43bf93be
...
...
@@ -3,3 +3,6 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export
const
HELP_PAGE_PATH
=
helpPagePath
(
'
user/application_security/dast/index
'
,
{
anchor
:
'
on-demand-scans
'
,
});
export
const
PIPELINES_PER_PAGE
=
20
;
export
const
PIPELINES_POLL_INTERVAL
=
1000
;
ee/app/assets/javascripts/on_demand_scans/graphql/on_demand_scans.query.graphql
0 → 100644
View file @
43bf93be
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query
allPipelinesCount
(
$fullPath
:
ID
!,
$first
:
Int
,
$last
:
Int
,
$after
:
String
,
$before
:
String
)
{
project
(
fullPath
:
$fullPath
)
{
pipelines
(
source
:
"ondemand_dast_scan"
first
:
$first
last
:
$last
after
:
$after
before
:
$before
)
{
pageInfo
{
...
PageInfo
}
nodes
{
id
path
createdAt
detailedStatus
{
detailsPath
text
group
icon
}
dastProfile
{
name
dastSiteProfile
{
targetUrl
}
}
}
}
}
}
ee/app/assets/javascripts/on_demand_scans/graphql/provider.js
0 → 100644
View file @
43bf93be
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
createDefaultClient
from
'
~/lib/graphql
'
;
Vue
.
use
(
VueApollo
);
const
defaultClient
=
createDefaultClient
(
{},
{
assumeImmutableResults
:
true
,
},
);
export
default
new
VueApollo
({
defaultClient
,
});
ee/app/assets/javascripts/on_demand_scans/on_demand_scans_bundle.js
View file @
43bf93be
import
Vue
from
'
vue
'
;
import
{
createRouter
}
from
'
./router
'
;
import
OnDemandScans
from
'
./components/on_demand_scans.vue
'
;
import
apolloProvider
from
'
./graphql/provider
'
;
export
default
()
=>
{
const
el
=
document
.
querySelector
(
'
#js-on-demand-scans
'
);
...
...
@@ -13,6 +14,7 @@ export default () => {
return
new
Vue
({
el
,
router
:
createRouter
(),
apolloProvider
,
provide
:
{
projectPath
,
newDastScanPath
,
...
...
ee/app/views/projects/on_demand_scans/index.html.haml
View file @
43bf93be
-
breadcrumb_title
s_
(
'OnDemandScans|On-demand Scans'
)
-
page_title
s_
(
'OnDemandScans|On-demand Scans'
)
-
add_page_specific_style
'page_bundles/ci_status'
#js-on-demand-scans
{
data:
on_demand_scans_data
(
@project
)
}
ee/spec/frontend/fixtures/on_demand_dast_scans.rb
0 → 100644
View file @
43bf93be
# frozen_string_literal: true
require
'spec_helper'
RSpec
.
describe
'On-demand DAST scans (GraphQL fixtures)'
do
describe
GraphQL
::
Query
,
type: :request
do
include
ApiHelpers
include
GraphqlHelpers
include
JavaScriptFixturesHelpers
let_it_be
(
:current_user
)
{
create
(
:user
)
}
let_it_be
(
:project
)
{
create
(
:project
,
:repository
,
:public
)
}
let_it_be
(
:dast_profile
)
{
create
(
:dast_profile
,
project:
project
)
}
path
=
'on_demand_scans/graphql/on_demand_scans.query.graphql'
before
do
project
.
add_developer
(
current_user
)
end
context
'with pipelines'
do
let_it_be
(
:pipelines
)
do
create_list
(
:ci_pipeline
,
30
,
:success
,
source: :ondemand_dast_scan
,
sha:
project
.
commit
.
id
,
project:
project
,
user:
current_user
,
dast_profile:
dast_profile
)
end
it
"graphql/
#{
path
}
.with_pipelines.json"
do
query
=
get_graphql_query_as_string
(
path
,
ee:
true
)
post_graphql
(
query
,
current_user:
current_user
,
variables:
{
fullPath:
project
.
full_path
,
first:
20
})
expect_graphql_errors_to_be_empty
expect
(
graphql_data_at
(
:project
,
:pipelines
,
:nodes
)).
to
have_attributes
(
size:
20
)
end
end
context
'without pipelines'
do
it
"graphql/
#{
path
}
.without_pipelines.json"
do
query
=
get_graphql_query_as_string
(
path
,
ee:
true
)
post_graphql
(
query
,
current_user:
current_user
,
variables:
{
fullPath:
project
.
full_path
,
first:
20
})
expect_graphql_errors_to_be_empty
expect
(
graphql_data_at
(
:project
,
:pipelines
,
:nodes
)).
to
be_empty
end
end
end
end
ee/spec/frontend/on_demand_scans/components/tabs/__snapshots__/all_spec.js.snap
0 → 100644
View file @
43bf93be
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllTab renders the base tab with the correct props 1`] = `
Array [
Object {
"key": "detailedStatus",
"label": "Status",
},
Object {
"key": "dastProfile.name",
"label": "Name",
},
Object {
"key": "scanType",
"label": "Scan type",
},
Object {
"key": "dastProfile.dastSiteProfile.targetUrl",
"label": "Target",
},
Object {
"key": "createdAt",
"label": "Start date",
},
Object {
"key": "id",
"label": "Pipeline",
},
]
`;
ee/spec/frontend/on_demand_scans/components/tabs/all_spec.js
View file @
43bf93be
...
...
@@ -23,5 +23,6 @@ describe('AllTab', () => {
it
(
'
renders the base tab with the correct props
'
,
()
=>
{
expect
(
findBaseTab
().
props
(
'
title
'
)).
toBe
(
'
All
'
);
expect
(
findBaseTab
().
props
(
'
itemsCount
'
)).
toBe
(
12
);
expect
(
findBaseTab
().
props
(
'
fields
'
)).
toMatchSnapshot
();
});
});
ee/spec/frontend/on_demand_scans/components/tabs/base_tab_spec.js
View file @
43bf93be
import
{
GlTab
}
from
'
@gitlab/ui
'
;
import
{
GlTab
,
GlTable
}
from
'
@gitlab/ui
'
;
import
{
createLocalVue
}
from
'
@vue/test-utils
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
allPipelinesWithPipelinesMock
from
'
test_fixtures/graphql/on_demand_scans/graphql/on_demand_scans.query.graphql.with_pipelines.json
'
;
import
allPipelinesWithoutPipelinesMock
from
'
test_fixtures/graphql/on_demand_scans/graphql/on_demand_scans.query.graphql.without_pipelines.json
'
;
import
{
stubComponent
}
from
'
helpers/stub_component
'
;
import
{
shallowMountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
BaseTab
from
'
ee/on_demand_scans/components/tabs/base_tab.vue
'
;
import
EmptyState
from
'
ee/on_demand_scans/components/empty_state.vue
'
;
import
createMockApollo
from
'
helpers/mock_apollo_helper
'
;
import
onDemandScansQuery
from
'
ee/on_demand_scans/graphql/on_demand_scans.query.graphql
'
;
import
{
createRouter
}
from
'
ee/on_demand_scans/router
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
{
scrollToElement
}
from
'
~/lib/utils/common_utils
'
;
import
setWindowLocation
from
'
helpers/set_window_location_helper
'
;
jest
.
mock
(
'
~/lib/utils/common_utils
'
);
const
localVue
=
createLocalVue
();
localVue
.
use
(
VueApollo
);
describe
(
'
BaseTab
'
,
()
=>
{
let
wrapper
;
let
router
;
let
requestHandler
;
// Props
const
projectPath
=
'
/namespace/project
'
;
// Finders
const
findTitle
=
()
=>
wrapper
.
findByTestId
(
'
tab-title
'
);
const
findTable
=
()
=>
wrapper
.
findComponent
(
GlTable
);
const
findEmptyState
=
()
=>
wrapper
.
findComponent
(
EmptyState
);
const
findPagination
=
()
=>
wrapper
.
findByTestId
(
'
pagination
'
);
// Helpers
const
createMockApolloProvider
=
()
=>
{
return
createMockApollo
([[
onDemandScansQuery
,
requestHandler
]]);
};
const
createComponent
=
(
propsData
)
=>
{
router
=
createRouter
();
wrapper
=
shallowMountExtended
(
BaseTab
,
{
propsData
,
localVue
,
apolloProvider
:
createMockApolloProvider
(),
router
,
propsData
:
{
title
:
'
All
'
,
query
:
onDemandScansQuery
,
itemsCount
:
0
,
fields
:
[{
name
:
'
ID
'
,
key
:
'
id
'
}],
...
propsData
,
},
provide
:
{
projectPath
,
},
stubs
:
{
GlTab
:
stubComponent
(
GlTab
,
{
template
:
`
...
...
@@ -25,22 +65,106 @@ describe('BaseTab', () => {
</div>
`
,
}),
GlTable
:
stubComponent
(
GlTable
,
{
props
:
[
'
items
'
],
}),
},
});
};
beforeEach
(()
=>
{
requestHandler
=
jest
.
fn
().
mockResolvedValue
(
allPipelinesWithPipelinesMock
);
});
afterEach
(()
=>
{
wrapper
.
destroy
();
router
=
null
;
requestHandler
=
null
;
});
describe
(
'
when the app loads
'
,
()
=>
{
it
(
'
fetches the pipelines
'
,
()
=>
{
createComponent
();
expect
(
requestHandler
).
toHaveBeenCalledWith
({
after
:
null
,
before
:
null
,
first
:
20
,
fullPath
:
projectPath
,
last
:
null
,
});
});
it
(
'
resets the route if no pipeline matches the cursor
'
,
async
()
=>
{
setWindowLocation
(
'
#?after=nothingToSeeHere
'
);
requestHandler
=
jest
.
fn
().
mockResolvedValue
(
allPipelinesWithoutPipelinesMock
);
createComponent
();
expect
(
router
.
currentRoute
.
query
.
after
).
toBe
(
'
nothingToSeeHere
'
);
await
waitForPromises
();
expect
(
router
.
currentRoute
.
query
.
after
).
toBeUndefined
();
});
});
describe
(
'
when there are pipelines
'
,
()
=>
{
beforeEach
(()
=>
{
createComponent
({
title
:
'
All
'
,
itemsCount
:
12
,
itemsCount
:
30
,
});
});
it
(
'
renders the title with the item count
'
,
()
=>
{
expect
(
findTitle
().
text
()).
toMatchInterpolatedText
(
'
All 12
'
);
expect
(
findTitle
().
text
()).
toMatchInterpolatedText
(
'
All 30
'
);
});
it
(
'
passes the pipelines to GlTable
'
,
()
=>
{
const
table
=
findTable
();
expect
(
table
.
exists
()).
toBe
(
true
);
expect
(
table
.
props
(
'
items
'
)).
toEqual
(
allPipelinesWithPipelinesMock
.
data
.
project
.
pipelines
.
nodes
,
);
});
it
(
'
when navigating to another page, scrolls back to the top
'
,
()
=>
{
findPagination
().
vm
.
$emit
(
'
next
'
);
expect
(
scrollToElement
).
toHaveBeenCalledWith
(
wrapper
.
vm
.
$el
);
});
it
(
'
when navigating to the next page, the route is updated and pipelines are fetched
'
,
async
()
=>
{
expect
(
Object
.
keys
(
router
.
currentRoute
.
query
)).
not
.
toContain
(
'
after
'
);
expect
(
requestHandler
).
toHaveBeenCalledTimes
(
1
);
findPagination
().
vm
.
$emit
(
'
next
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
Object
.
keys
(
router
.
currentRoute
.
query
)).
toContain
(
'
after
'
);
expect
(
requestHandler
).
toHaveBeenCalledTimes
(
2
);
});
it
(
'
when navigating back to the previous page, the route is updated and pipelines are fetched
'
,
async
()
=>
{
findPagination
().
vm
.
$emit
(
'
next
'
);
await
wrapper
.
vm
.
$nextTick
();
findPagination
().
vm
.
$emit
(
'
prev
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
Object
.
keys
(
router
.
currentRoute
.
query
)).
not
.
toContain
(
'
after
'
);
expect
(
Object
.
keys
(
router
.
currentRoute
.
query
)).
toContain
(
'
before
'
);
expect
(
requestHandler
).
toHaveBeenCalledTimes
(
3
);
});
});
describe
(
'
when there are no pipelines
'
,
()
=>
{
beforeEach
(()
=>
{
requestHandler
=
jest
.
fn
().
mockResolvedValue
(
allPipelinesWithoutPipelinesMock
);
createComponent
();
});
it
(
'
renders an empty state
'
,
()
=>
{
expect
(
findEmptyState
().
exists
()).
toBe
(
true
);
});
});
});
locale/gitlab.pot
View file @
43bf93be
...
...
@@ -23880,6 +23880,9 @@ msgstr ""
msgid "OnDemandScans|Scan name"
msgstr ""
msgid "OnDemandScans|Scan type"
msgstr ""
msgid "OnDemandScans|Scanner profile"
msgstr ""
...
...
@@ -23895,6 +23898,9 @@ msgstr ""
msgid "OnDemandScans|Start time"
msgstr ""
msgid "OnDemandScans|Target"
msgstr ""
msgid "OnDemandScans|Use existing scanner profile"
msgstr ""
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment