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
dc8f04ed
Commit
dc8f04ed
authored
Dec 04, 2020
by
David O'Regan
Committed by
Olena Horal-Koretska
Dec 04, 2020
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Allow for schedule update
Allow for schedule update for oncall schedules via GraphQL
parent
75fa9fd2
Changes
26
Show whitespace changes
Inline
Side-by-side
Showing
26 changed files
with
1300 additions
and
323 deletions
+1300
-323
ee/app/assets/javascripts/oncall_schedules/components/add_edit_schedule_form.vue
...ts/oncall_schedules/components/add_edit_schedule_form.vue
+161
-0
ee/app/assets/javascripts/oncall_schedules/components/add_schedule_modal.vue
...cripts/oncall_schedules/components/add_schedule_modal.vue
+15
-116
ee/app/assets/javascripts/oncall_schedules/components/delete_schedule_modal.vue
...pts/oncall_schedules/components/delete_schedule_modal.vue
+102
-0
ee/app/assets/javascripts/oncall_schedules/components/edit_schedule_modal.vue
...ripts/oncall_schedules/components/edit_schedule_modal.vue
+133
-0
ee/app/assets/javascripts/oncall_schedules/components/oncall_schedule.vue
...vascripts/oncall_schedules/components/oncall_schedule.vue
+29
-2
ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue
.../oncall_schedules/components/oncall_schedules_wrapper.vue
+2
-8
ee/app/assets/javascripts/oncall_schedules/components/schedule/components/schedule_timeline_section.vue
...ponents/schedule/components/schedule_timeline_section.vue
+38
-9
ee/app/assets/javascripts/oncall_schedules/graphql.js
ee/app/assets/javascripts/oncall_schedules/graphql.js
+15
-0
ee/app/assets/javascripts/oncall_schedules/graphql/mutations/create_oncall_schedule.mutation.graphql
...graphql/mutations/create_oncall_schedule.mutation.graphql
+0
-0
ee/app/assets/javascripts/oncall_schedules/graphql/mutations/destroy_oncall_schedule.mutation.graphql
...raphql/mutations/destroy_oncall_schedule.mutation.graphql
+11
-0
ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule.mutation.graphql
...graphql/mutations/update_oncall_schedule.mutation.graphql
+11
-0
ee/app/assets/javascripts/oncall_schedules/graphql/queries/get_oncall_schedules.query.graphql
...edules/graphql/queries/get_oncall_schedules.query.graphql
+0
-0
ee/app/assets/javascripts/oncall_schedules/index.js
ee/app/assets/javascripts/oncall_schedules/index.js
+1
-5
ee/app/assets/javascripts/oncall_schedules/utils/cache_updates.js
...ssets/javascripts/oncall_schedules/utils/cache_updates.js
+78
-0
ee/app/assets/javascripts/oncall_schedules/utils/error_messages.js
...sets/javascripts/oncall_schedules/utils/error_messages.js
+9
-0
ee/spec/frontend/oncall_schedule/__snapshots__/add_edit_schedule_form_spec.js.snap
...chedule/__snapshots__/add_edit_schedule_form_spec.js.snap
+119
-0
ee/spec/frontend/oncall_schedule/__snapshots__/add_schedule_modal_spec.js.snap
...ll_schedule/__snapshots__/add_schedule_modal_spec.js.snap
+5
-111
ee/spec/frontend/oncall_schedule/__snapshots__/delete_schedule_modal_spec.js.snap
...schedule/__snapshots__/delete_schedule_modal_spec.js.snap
+19
-0
ee/spec/frontend/oncall_schedule/__snapshots__/edit_schedule_modal_spec.js.snap
...l_schedule/__snapshots__/edit_schedule_modal_spec.js.snap
+20
-0
ee/spec/frontend/oncall_schedule/add_edit_schedule_form_spec.js
...c/frontend/oncall_schedule/add_edit_schedule_form_spec.js
+125
-0
ee/spec/frontend/oncall_schedule/add_schedule_modal_spec.js
ee/spec/frontend/oncall_schedule/add_schedule_modal_spec.js
+15
-70
ee/spec/frontend/oncall_schedule/delete_schedule_modal_spec.js
...ec/frontend/oncall_schedule/delete_schedule_modal_spec.js
+139
-0
ee/spec/frontend/oncall_schedule/edit_schedule_modal_spec.js
ee/spec/frontend/oncall_schedule/edit_schedule_modal_spec.js
+142
-0
ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js
ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js
+67
-0
ee/spec/frontend/oncall_schedule/schedule/components/schedule_timeline_section_spec.js
...ule/schedule/components/schedule_timeline_section_spec.js
+20
-2
locale/gitlab.pot
locale/gitlab.pot
+24
-0
No files found.
ee/app/assets/javascripts/oncall_schedules/components/add_edit_schedule_form.vue
0 → 100644
View file @
dc8f04ed
<
script
>
import
{
isEqual
,
isEmpty
}
from
'
lodash
'
;
import
{
GlForm
,
GlFormGroup
,
GlFormInput
,
GlDropdown
,
GlDropdownItem
,
GlSearchBoxByType
,
}
from
'
@gitlab/ui
'
;
import
{
s__
,
__
}
from
'
~/locale
'
;
export
const
i18n
=
{
selectTimezone
:
s__
(
'
OnCallSchedules|Select timezone
'
),
search
:
__
(
'
Search
'
),
noResults
:
__
(
'
No matching results
'
),
fields
:
{
name
:
{
title
:
__
(
'
Name
'
),
validation
:
{
empty
:
__
(
"
Can't be empty
"
),
},
},
description
:
{
title
:
__
(
'
Description (optional)
'
)
},
timezone
:
{
title
:
__
(
'
Timezone
'
),
description
:
s__
(
'
OnCallSchedules|Sets the default timezone for the schedule, for all participants
'
,
),
validation
:
{
empty
:
__
(
"
Can't be empty
"
),
},
},
},
errorMsg
:
s__
(
'
OnCallSchedules|Failed to add schedule
'
),
};
export
default
{
i18n
,
inject
:
[
'
projectPath
'
,
'
timezones
'
],
components
:
{
GlForm
,
GlFormGroup
,
GlFormInput
,
GlDropdown
,
GlDropdownItem
,
GlSearchBoxByType
,
},
props
:
{
form
:
{
type
:
Object
,
required
:
true
,
},
isNameInvalid
:
{
type
:
Boolean
,
required
:
true
,
},
isTimezoneInvalid
:
{
type
:
Boolean
,
required
:
true
,
},
schedule
:
{
type
:
Object
,
required
:
false
,
default
:
()
=>
({}),
},
},
data
()
{
return
{
tzSearchTerm
:
''
,
};
},
computed
:
{
filteredTimezones
()
{
const
lowerCaseTzSearchTerm
=
this
.
tzSearchTerm
.
toLowerCase
();
return
this
.
timezones
.
filter
(
tz
=>
this
.
getFormattedTimezone
(
tz
)
.
toLowerCase
()
.
includes
(
lowerCaseTzSearchTerm
),
);
},
noResults
()
{
return
!
this
.
filteredTimezones
.
length
;
},
selectedTimezone
()
{
return
isEmpty
(
this
.
form
.
timezone
)
?
i18n
.
selectTimezone
:
this
.
getFormattedTimezone
(
this
.
form
.
timezone
);
},
},
methods
:
{
getFormattedTimezone
(
tz
)
{
return
__
(
`(UTC
${
tz
.
formatted_offset
}
)
${
tz
.
abbr
}
${
tz
.
name
}
`
);
},
isTimezoneSelected
(
tz
)
{
return
isEqual
(
tz
,
this
.
form
.
timezone
);
},
},
};
</
script
>
<
template
>
<gl-form>
<gl-form-group
:label=
"$options.i18n.fields.name.title"
:invalid-feedback=
"$options.i18n.fields.name.validation.empty"
label-size=
"sm"
label-for=
"schedule-name"
>
<gl-form-input
id=
"schedule-name"
:value=
"form.name"
:state=
"!isNameInvalid"
@
input=
"$emit('update-schedule-form',
{ type: 'name', value: $event })"
/>
</gl-form-group>
<gl-form-group
:label=
"$options.i18n.fields.description.title"
label-size=
"sm"
label-for=
"schedule-description"
>
<gl-form-input
id=
"schedule-description"
:value=
"form.description"
@
input=
"$emit('update-schedule-form',
{ type: 'description', value: $event })"
/>
</gl-form-group>
<gl-form-group
:label=
"$options.i18n.fields.timezone.title"
label-size=
"sm"
label-for=
"schedule-timezone"
:description=
"$options.i18n.fields.timezone.description"
:state=
"!isTimezoneInvalid"
:invalid-feedback=
"$options.i18n.fields.timezone.validation.empty"
>
<gl-dropdown
id=
"schedule-timezone"
:text=
"selectedTimezone"
class=
"timezone-dropdown gl-w-full"
:header-text=
"$options.i18n.selectTimezone"
:class=
"
{ 'invalid-dropdown': isTimezoneInvalid }"
>
<gl-search-box-by-type
v-model.trim=
"tzSearchTerm"
/>
<gl-dropdown-item
v-for=
"tz in filteredTimezones"
:key=
"getFormattedTimezone(tz)"
:is-checked=
"isTimezoneSelected(tz)"
is-check-item
@
click=
"$emit('update-schedule-form',
{ type: 'timezone', value: tz })"
>
<span
class=
"gl-white-space-nowrap"
>
{{
getFormattedTimezone
(
tz
)
}}
</span>
</gl-dropdown-item>
<gl-dropdown-item
v-if=
"noResults"
>
{{
$options
.
i18n
.
noResults
}}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</gl-form>
</
template
>
ee/app/assets/javascripts/oncall_schedules/components/add_schedule_modal.vue
View file @
dc8f04ed
<
script
>
import
{
isEqual
,
isEmpty
}
from
'
lodash
'
;
import
{
GlModal
,
GlForm
,
GlFormGroup
,
GlFormInput
,
GlDropdown
,
GlDropdownItem
,
GlSearchBoxByType
,
GlAlert
,
}
from
'
@gitlab/ui
'
;
import
{
isEmpty
}
from
'
lodash
'
;
import
{
GlModal
,
GlAlert
}
from
'
@gitlab/ui
'
;
import
{
s__
,
__
}
from
'
~/locale
'
;
import
createOncallScheduleMutation
from
'
../graphql/create_oncall_schedule.mutation.graphql
'
;
import
{
getFormattedTimezone
}
from
'
../utils
'
;
import
createOncallScheduleMutation
from
'
../graphql/
mutations/
create_oncall_schedule.mutation.graphql
'
;
import
AddEditScheduleForm
from
'
./add_edit_schedule_form.vue
'
;
export
const
i18n
=
{
selectTimezone
:
s__
(
'
OnCallSchedules|Select timezone
'
),
search
:
__
(
'
Search
'
),
noResults
:
__
(
'
No matching results
'
),
cancel
:
__
(
'
Cancel
'
),
addSchedule
:
s__
(
'
OnCallSchedules|Add schedule
'
),
fields
:
{
name
:
{
title
:
__
(
'
Name
'
),
validation
:
{
empty
:
__
(
"
Can't be empty
"
),
},
},
description
:
{
title
:
__
(
'
Description (optional)
'
)
},
timezone
:
{
title
:
__
(
'
Timezone
'
),
description
:
s__
(
'
OnCallSchedules|Sets the default timezone for the schedule, for all participants
'
,
),
validation
:
{
empty
:
__
(
"
Can't be empty
"
),
},
},
},
errorMsg
:
s__
(
'
OnCallSchedules|Failed to add schedule
'
),
};
...
...
@@ -46,13 +16,8 @@ export default {
inject
:
[
'
projectPath
'
,
'
timezones
'
],
components
:
{
GlModal
,
GlForm
,
GlFormGroup
,
GlFormInput
,
GlDropdown
,
GlDropdownItem
,
GlSearchBoxByType
,
GlAlert
,
AddEditScheduleForm
,
},
props
:
{
modalId
:
{
...
...
@@ -63,11 +28,10 @@ export default {
data
()
{
return
{
loading
:
false
,
tzSearchTerm
:
''
,
form
:
{
name
:
''
,
description
:
''
,
timezone
:
{}
,
timezone
:
''
,
},
error
:
null
,
};
...
...
@@ -88,22 +52,6 @@ export default {
},
};
},
filteredTimezones
()
{
const
lowerCaseTzSearchTerm
=
this
.
tzSearchTerm
.
toLowerCase
();
return
this
.
timezones
.
filter
(
tz
=>
this
.
getFormattedTimezone
(
tz
)
.
toLowerCase
()
.
includes
(
lowerCaseTzSearchTerm
),
);
},
noResults
()
{
return
!
this
.
filteredTimezones
.
length
;
},
selectedTimezone
()
{
return
isEmpty
(
this
.
form
.
timezone
)
?
i18n
.
selectTimezone
:
this
.
getFormattedTimezone
(
this
.
form
.
timezone
);
},
isNameInvalid
()
{
return
!
this
.
form
.
name
.
length
;
},
...
...
@@ -142,18 +90,12 @@ export default {
this
.
loading
=
false
;
});
},
setSelectedTimezone
(
tz
)
{
this
.
form
.
timezone
=
tz
;
},
getFormattedTimezone
(
tz
)
{
return
getFormattedTimezone
(
tz
);
},
isTimezoneSelected
(
tz
)
{
return
isEqual
(
tz
,
this
.
form
.
timezone
);
},
hideErrorAlert
()
{
this
.
error
=
null
;
},
updateScheduleForm
({
type
,
value
})
{
this
.
form
[
type
]
=
value
;
},
},
};
</
script
>
...
...
@@ -171,54 +113,11 @@ export default {
<gl-alert
v-if=
"error"
variant=
"danger"
class=
"gl-mt-n3 gl-mb-3"
@
dismiss=
"hideErrorAlert"
>
{{
error
||
$options
.
i18n
.
errorMsg
}}
</gl-alert>
<gl-form>
<gl-form-group
:label=
"$options.i18n.fields.name.title"
:invalid-feedback=
"$options.i18n.fields.name.validation.empty"
label-size=
"sm"
label-for=
"schedule-name"
>
<gl-form-input
id=
"schedule-name"
v-model=
"form.name"
:state=
"!isNameInvalid"
/>
</gl-form-group>
<gl-form-group
:label=
"$options.i18n.fields.description.title"
label-size=
"sm"
label-for=
"schedule-description"
>
<gl-form-input
id=
"schedule-description"
v-model=
"form.description"
/>
</gl-form-group>
<gl-form-group
:label=
"$options.i18n.fields.timezone.title"
label-size=
"sm"
label-for=
"schedule-timezone"
:description=
"$options.i18n.fields.timezone.description"
:state=
"!isTimezoneInvalid"
:invalid-feedback=
"$options.i18n.fields.timezone.validation.empty"
>
<gl-dropdown
id=
"schedule-timezone"
:text=
"selectedTimezone"
class=
"timezone-dropdown gl-w-full"
:header-text=
"$options.i18n.selectTimezone"
:class=
"
{ 'invalid-dropdown': isTimezoneInvalid }"
>
<gl-search-box-by-type
v-model.trim=
"tzSearchTerm"
/>
<gl-dropdown-item
v-for=
"tz in filteredTimezones"
:key=
"getFormattedTimezone(tz)"
:is-checked=
"isTimezoneSelected(tz)"
is-check-item
@
click=
"setSelectedTimezone(tz)"
>
<span
class=
"gl-white-space-nowrap"
>
{{
getFormattedTimezone
(
tz
)
}}
</span>
</gl-dropdown-item>
<gl-dropdown-item
v-if=
"noResults"
>
{{
$options
.
i18n
.
noResults
}}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</gl-form>
<add-edit-schedule-form
:is-name-invalid=
"isNameInvalid"
:is-timezone-invalid=
"isTimezoneInvalid"
:form=
"form"
@
update-schedule-form=
"updateScheduleForm"
/>
</gl-modal>
</
template
>
ee/app/assets/javascripts/oncall_schedules/components/delete_schedule_modal.vue
0 → 100644
View file @
dc8f04ed
<
script
>
import
{
GlSprintf
,
GlModal
,
GlAlert
}
from
'
@gitlab/ui
'
;
import
destroyOncallScheduleMutation
from
'
../graphql/mutations/destroy_oncall_schedule.mutation.graphql
'
;
import
getOncallSchedulesQuery
from
'
../graphql/queries/get_oncall_schedules.query.graphql
'
;
import
{
updateStoreAfterScheduleDelete
}
from
'
../utils/cache_updates
'
;
import
{
s__
,
__
}
from
'
~/locale
'
;
export
const
i18n
=
{
deleteSchedule
:
s__
(
'
OnCallSchedules|Delete schedule
'
),
deleteScheduleMessage
:
s__
(
'
OnCallSchedules|Are you sure you want to delete the "%{deleteSchedule}" schedule? This action cannot be undone.
'
,
),
};
export
default
{
i18n
,
components
:
{
GlSprintf
,
GlModal
,
GlAlert
,
},
inject
:
[
'
projectPath
'
],
props
:
{
schedule
:
{
type
:
Object
,
required
:
true
,
},
},
data
()
{
return
{
loading
:
false
,
error
:
null
,
};
},
computed
:
{
primaryProps
()
{
return
{
text
:
this
.
$options
.
i18n
.
deleteSchedule
,
attributes
:
[{
category
:
'
primary
'
},
{
variant
:
'
danger
'
},
{
loading
:
this
.
loading
}],
};
},
cancelProps
()
{
return
{
text
:
__
(
'
Cancel
'
),
};
},
},
methods
:
{
deleteSchedule
()
{
const
{
projectPath
}
=
this
;
this
.
loading
=
true
;
this
.
$apollo
.
mutate
({
mutation
:
destroyOncallScheduleMutation
,
variables
:
{
id
:
this
.
schedule
.
id
,
projectPath
,
},
update
(
store
,
{
data
})
{
updateStoreAfterScheduleDelete
(
store
,
getOncallSchedulesQuery
,
data
,
{
projectPath
});
},
})
.
then
(({
data
:
{
oncallScheduleDestroy
}
=
{}
}
=
{})
=>
{
const
error
=
oncallScheduleDestroy
.
errors
[
0
];
if
(
error
)
{
throw
error
;
}
this
.
$refs
.
deleteScheduleModal
.
hide
();
})
.
catch
(
error
=>
{
this
.
error
=
error
;
})
.
finally
(()
=>
{
this
.
loading
=
false
;
});
},
hideErrorAlert
()
{
this
.
error
=
null
;
},
},
};
</
script
>
<
template
>
<gl-modal
ref=
"deleteScheduleModal"
modal-id=
"deleteScheduleModal"
size=
"sm"
:title=
"$options.i18n.deleteSchedule"
:action-primary=
"primaryProps"
:action-cancel=
"cancelProps"
@
primary.prevent=
"deleteSchedule"
>
<gl-alert
v-if=
"error"
variant=
"danger"
class=
"gl-mt-n3 gl-mb-3"
@
dismiss=
"hideErrorAlert"
>
{{
error
||
$options
.
i18n
.
errorMsg
}}
</gl-alert>
<gl-sprintf
:message=
"$options.i18n.deleteScheduleMessage"
>
<template
#deleteSchedule
>
{{
schedule
.
name
}}
</
template
>
</gl-sprintf>
</gl-modal>
</template>
ee/app/assets/javascripts/oncall_schedules/components/edit_schedule_modal.vue
0 → 100644
View file @
dc8f04ed
<
script
>
import
{
isEmpty
}
from
'
lodash
'
;
import
{
GlModal
,
GlAlert
}
from
'
@gitlab/ui
'
;
import
{
s__
,
__
}
from
'
~/locale
'
;
import
updateOncallScheduleMutation
from
'
../graphql/mutations/update_oncall_schedule.mutation.graphql
'
;
import
getOncallSchedulesQuery
from
'
../graphql/queries/get_oncall_schedules.query.graphql
'
;
import
{
updateStoreAfterScheduleEdit
}
from
'
../utils/cache_updates
'
;
import
AddEditScheduleForm
from
'
./add_edit_schedule_form.vue
'
;
export
const
i18n
=
{
cancel
:
__
(
'
Cancel
'
),
editSchedule
:
s__
(
'
OnCallSchedules|Edit schedule
'
),
errorMsg
:
s__
(
'
OnCallSchedules|Failed to edit schedule
'
),
};
export
default
{
i18n
,
inject
:
[
'
projectPath
'
,
'
timezones
'
],
components
:
{
GlModal
,
GlAlert
,
AddEditScheduleForm
,
},
props
:
{
schedule
:
{
type
:
Object
,
required
:
true
,
},
},
data
()
{
return
{
loading
:
false
,
error
:
null
,
form
:
{
name
:
this
.
schedule
.
name
,
description
:
this
.
schedule
.
description
,
timezone
:
this
.
timezones
.
find
(({
identifier
})
=>
this
.
schedule
.
timezone
===
identifier
),
},
};
},
computed
:
{
actionsProps
()
{
return
{
primary
:
{
text
:
i18n
.
editSchedule
,
attributes
:
[
{
variant
:
'
info
'
},
{
loading
:
this
.
loading
},
{
disabled
:
this
.
isFormInvalid
},
],
},
cancel
:
{
text
:
i18n
.
cancel
,
},
};
},
isNameInvalid
()
{
return
!
this
.
form
.
name
.
length
;
},
isTimezoneInvalid
()
{
return
isEmpty
(
this
.
form
.
timezone
);
},
isFormInvalid
()
{
return
this
.
isNameInvalid
||
this
.
isTimezoneInvalid
;
},
editScheduleVariables
()
{
return
{
projectPath
:
this
.
projectPath
,
...
this
.
form
,
timezone
:
this
.
form
.
timezone
.
identifier
,
};
},
},
methods
:
{
editSchedule
()
{
const
{
projectPath
}
=
this
;
this
.
loading
=
true
;
this
.
$apollo
.
mutate
({
mutation
:
updateOncallScheduleMutation
,
variables
:
{
oncallScheduleEditInput
:
this
.
editScheduleVariables
,
},
update
(
store
,
{
data
})
{
updateStoreAfterScheduleEdit
(
store
,
getOncallSchedulesQuery
,
data
,
{
projectPath
});
},
})
.
then
(({
data
:
{
oncallScheduleEdit
:
{
errors
:
[
error
]
}
}
})
=>
{
if
(
error
)
{
throw
error
;
}
this
.
$refs
.
updateScheduleModal
.
hide
();
})
.
catch
(
error
=>
{
this
.
error
=
error
;
})
.
finally
(()
=>
{
this
.
loading
=
false
;
});
},
hideErrorAlert
()
{
this
.
error
=
null
;
},
updateScheduleForm
({
type
,
value
})
{
this
.
form
[
type
]
=
value
;
},
},
};
</
script
>
<
template
>
<gl-modal
ref=
"updateScheduleModal"
modal-id=
"updateScheduleModal"
size=
"sm"
:title=
"$options.i18n.editSchedule"
:action-primary=
"actionsProps.primary"
:action-cancel=
"actionsProps.cancel"
@
primary.prevent=
"editSchedule"
>
<gl-alert
v-if=
"error"
variant=
"danger"
class=
"gl-mt-n3 gl-mb-3"
@
dismiss=
"hideErrorAlert"
>
{{
error
||
$options
.
i18n
.
errorMsg
}}
</gl-alert>
<add-edit-schedule-form
:is-name-invalid=
"isNameInvalid"
:is-timezone-invalid=
"isTimezoneInvalid"
:form=
"form"
:schedule=
"schedule"
@
update-schedule-form=
"updateScheduleForm"
/>
</gl-modal>
</
template
>
ee/app/assets/javascripts/oncall_schedules/components/oncall_schedule.vue
View file @
dc8f04ed
<
script
>
import
{
GlSprintf
,
GlCard
}
from
'
@gitlab/ui
'
;
import
{
GlSprintf
,
GlCard
,
GlButtonGroup
,
GlButton
,
GlModalDirective
}
from
'
@gitlab/ui
'
;
import
{
s__
}
from
'
~/locale
'
;
import
ScheduleTimelineSection
from
'
./schedule/components/schedule_timeline_section.vue
'
;
import
DeleteScheduleModal
from
'
./delete_schedule_modal.vue
'
;
import
EditScheduleModal
from
'
./edit_schedule_modal.vue
'
;
import
{
getTimeframeForWeeksView
}
from
'
./schedule/utils
'
;
import
{
PRESET_TYPES
}
from
'
./schedule/constants
'
;
import
{
getFormattedTimezone
}
from
'
../utils
'
;
...
...
@@ -9,6 +11,8 @@ import { getFormattedTimezone } from '../utils';
export
const
i18n
=
{
title
:
s__
(
'
OnCallSchedules|On-call schedule
'
),
scheduleForTz
:
s__
(
'
OnCallSchedules|On-call schedule for the %{tzShort}
'
),
updateScheduleLabel
:
s__
(
'
OnCallSchedules|Edit schedule
'
),
destroyScheduleLabel
:
s__
(
'
OnCallSchedules|Delete schedule
'
),
};
export
default
{
...
...
@@ -19,6 +23,13 @@ export default {
GlSprintf
,
GlCard
,
ScheduleTimelineSection
,
GlButtonGroup
,
GlButton
,
DeleteScheduleModal
,
EditScheduleModal
,
},
directives
:
{
GlModal
:
GlModalDirective
,
},
props
:
{
schedule
:
{
...
...
@@ -43,7 +54,21 @@ export default {
<h2>
{{
$options
.
i18n
.
title
}}
</h2>
<gl-card>
<template
#header
>
<h3
class=
"gl-font-weight-bold gl-font-lg gl-m-0"
>
{{
schedule
.
name
}}
</h3>
<div
class=
"gl-display-flex gl-justify-content-space-between gl-m-0"
>
<span
class=
"gl-font-weight-bold gl-font-lg"
>
{{
schedule
.
name
}}
</span>
<gl-button-group>
<gl-button
v-gl-modal
.
updateScheduleModal
icon=
"pencil"
:aria-label=
"$options.i18n.updateScheduleLabel"
/>
<gl-button
v-gl-modal
.
deleteScheduleModal
icon=
"remove"
:aria-label=
"$options.i18n.destroyScheduleLabel"
/>
</gl-button-group>
</div>
</
template
>
<p
class=
"gl-text-gray-500 gl-mb-5"
>
...
...
@@ -57,5 +82,7 @@ export default {
<schedule-timeline-section
:preset-type=
"$options.presetType"
:timeframe=
"timeframe"
/>
</div>
</gl-card>
<delete-schedule-modal
:schedule=
"schedule"
/>
<edit-schedule-modal
:schedule=
"schedule"
/>
</div>
</template>
ee/app/assets/javascripts/oncall_schedules/components/oncall_schedules_wrapper.vue
View file @
dc8f04ed
...
...
@@ -2,10 +2,9 @@
import
{
GlEmptyState
,
GlButton
,
GlLoadingIcon
,
GlModalDirective
}
from
'
@gitlab/ui
'
;
import
*
as
Sentry
from
'
~/sentry/wrapper
'
;
import
AddScheduleModal
from
'
./add_schedule_modal.vue
'
;
import
AddRotationModal
from
'
./rotations/add_rotation_modal.vue
'
;
import
OncallSchedule
from
'
./oncall_schedule.vue
'
;
import
{
s__
}
from
'
~/locale
'
;
import
getOncallSchedules
from
'
../graphql
/get_oncall_schedules.query.graphql
'
;
import
getOncallSchedules
Query
from
'
../graphql/queries
/get_oncall_schedules.query.graphql
'
;
import
{
fetchPolicies
}
from
'
~/lib/graphql
'
;
const
addScheduleModalId
=
'
addScheduleModal
'
;
...
...
@@ -27,7 +26,6 @@ export default {
GlButton
,
GlLoadingIcon
,
AddScheduleModal
,
AddRotationModal
,
OncallSchedule
,
},
directives
:
{
...
...
@@ -41,7 +39,7 @@ export default {
apollo
:
{
schedule
:
{
fetchPolicy
:
fetchPolicies
.
CACHE_AND_NETWORK
,
query
:
getOncallSchedules
,
query
:
getOncallSchedules
Query
,
variables
()
{
return
{
projectPath
:
this
.
projectPath
,
...
...
@@ -77,12 +75,8 @@ export default {
<gl-button
v-gl-modal=
"$options.addScheduleModalId"
variant=
"info"
>
{{
$options
.
i18n
.
emptyState
.
button
}}
</gl-button>
<gl-button
v-gl-modal=
"'create-schedule-rotation-modal'"
variant=
"danger"
>
{{
$options
.
i18n
.
emptyState
.
button
}}
</gl-button>
</
template
>
</gl-empty-state>
<add-schedule-modal
:modal-id=
"$options.addScheduleModalId"
/>
<add-rotation-modal
/>
</div>
</template>
ee/app/assets/javascripts/oncall_schedules/components/schedule/components/schedule_timeline_section.vue
View file @
dc8f04ed
<
script
>
import
{
GlCard
,
GlButton
,
GlModalDirective
}
from
'
@gitlab/ui
'
;
import
{
s__
}
from
'
~/locale
'
;
import
WeeksHeaderItem
from
'
./preset_weeks/weeks_header_item.vue
'
;
import
AddRotationModal
from
'
../../rotations/add_rotation_modal.vue
'
;
export
const
i18n
=
{
rotationTitle
:
s__
(
'
OnCallSchedules|Rotations
'
),
addARotation
:
s__
(
'
OnCallSchedules|Add a rotation
'
),
};
export
default
{
i18n
,
components
:
{
GlButton
,
GlCard
,
WeeksHeaderItem
,
AddRotationModal
,
},
directives
:
{
GlModal
:
GlModalDirective
,
},
props
:
{
presetType
:
{
...
...
@@ -19,6 +34,17 @@ export default {
</
script
>
<
template
>
<div>
<gl-card
header-class=
"gl-bg-transparent"
>
<template
#header
>
<div
class=
"gl-display-flex gl-justify-content-space-between"
>
<h6
class=
"gl-m-0"
>
{{
$options
.
i18n
.
rotationTitle
}}
</h6>
<gl-button
v-gl-modal=
"'create-schedule-rotation-modal'"
variant=
"link"
>
{{
$options
.
i18n
.
addARotation
}}
</gl-button>
</div>
</
template
>
<div
class=
"timeline-section clearfix"
>
<span
class=
"timeline-header-blank"
></span>
<weeks-header-item
...
...
@@ -29,4 +55,7 @@ export default {
:timeframe=
"timeframe"
/>
</div>
</gl-card>
<add-rotation-modal
/>
</div>
</template>
ee/app/assets/javascripts/oncall_schedules/graphql.js
0 → 100644
View file @
dc8f04ed
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
createDefaultClient
from
'
~/lib/graphql
'
;
Vue
.
use
(
VueApollo
);
export
default
new
VueApollo
({
defaultClient
:
createDefaultClient
(
{},
{
cacheConfig
:
{},
assumeImmutableResults
:
true
,
},
),
});
ee/app/assets/javascripts/oncall_schedules/graphql/create_oncall_schedule.mutation.graphql
→
ee/app/assets/javascripts/oncall_schedules/graphql/
mutations/
create_oncall_schedule.mutation.graphql
View file @
dc8f04ed
File moved
ee/app/assets/javascripts/oncall_schedules/graphql/mutations/destroy_oncall_schedule.mutation.graphql
0 → 100644
View file @
dc8f04ed
mutation
oncallScheduleDestroy
(
$oncallScheduleDestroyInput
:
OncallScheduleDestroyInput
!)
{
oncallScheduleDestroy
(
input
:
$oncallScheduleDestroyInput
)
{
errors
oncallSchedule
{
iid
name
description
timezone
}
}
}
ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule.mutation.graphql
0 → 100644
View file @
dc8f04ed
mutation
oncallScheduleUpdate
(
$oncallScheduleUpdateInput
:
oncallScheduleUpdateInput
!)
{
oncallScheduleUpdate
(
input
:
$oncallScheduleUpdateInput
)
{
errors
oncallSchedule
{
iid
name
description
timezone
}
}
}
ee/app/assets/javascripts/oncall_schedules/graphql/get_oncall_schedules.query.graphql
→
ee/app/assets/javascripts/oncall_schedules/graphql/
queries/
get_oncall_schedules.query.graphql
View file @
dc8f04ed
File moved
ee/app/assets/javascripts/oncall_schedules/index.js
View file @
dc8f04ed
import
Vue
from
'
vue
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
OnCallSchedulesWrapper
from
'
./components/oncall_schedules_wrapper.vue
'
;
import
createDefaultClient
from
'
~/lib
/graphql
'
;
import
apolloProvider
from
'
.
/graphql
'
;
Vue
.
use
(
VueApollo
);
...
...
@@ -12,10 +12,6 @@ export default () => {
const
{
projectPath
,
emptyOncallSchedulesSvgPath
,
timezones
}
=
el
.
dataset
;
const
apolloProvider
=
new
VueApollo
({
defaultClient
:
createDefaultClient
(),
});
return
new
Vue
({
el
,
apolloProvider
,
...
...
ee/app/assets/javascripts/oncall_schedules/utils/cache_updates.js
0 → 100644
View file @
dc8f04ed
import
produce
from
'
immer
'
;
import
createFlash
from
'
~/flash
'
;
import
{
DELETE_SCHEDULE_ERROR
,
UPDATE_SCHEDULE_ERROR
}
from
'
./error_messages
'
;
const
deleteScheduleFromStore
=
(
store
,
query
,
{
oncallScheduleDestroy
},
variables
)
=>
{
const
schedule
=
oncallScheduleDestroy
?.
oncallSchedule
;
if
(
!
schedule
)
{
return
;
}
const
sourceData
=
store
.
readQuery
({
query
,
variables
,
});
const
data
=
produce
(
sourceData
,
draftData
=>
{
// eslint-disable-next-line no-param-reassign
draftData
.
project
.
incidentManagementOncallSchedules
.
nodes
=
draftData
.
project
.
incidentManagementOncallSchedules
.
nodes
.
filter
(
({
id
})
=>
id
!==
schedule
.
id
,
);
});
store
.
writeQuery
({
query
,
variables
,
data
,
});
};
const
updateScheduleFromStore
=
(
store
,
query
,
{
oncallScheduleUpdate
},
variables
)
=>
{
const
schedule
=
oncallScheduleUpdate
?.
oncallSchedule
;
if
(
!
schedule
)
{
return
;
}
const
sourceData
=
store
.
readQuery
({
query
,
variables
,
});
const
data
=
produce
(
sourceData
,
draftData
=>
{
// eslint-disable-next-line no-param-reassign
draftData
.
project
.
incidentManagementOncallSchedules
.
nodes
=
[
...
draftData
.
project
.
incidentManagementOncallSchedules
.
nodes
,
schedule
,
];
});
store
.
writeQuery
({
query
,
variables
,
data
,
});
};
const
onError
=
(
data
,
message
)
=>
{
createFlash
({
message
});
throw
new
Error
(
data
.
errors
);
};
export
const
hasErrors
=
({
errors
=
[]
})
=>
errors
?.
length
;
export
const
updateStoreAfterScheduleDelete
=
(
store
,
query
,
data
,
variables
)
=>
{
if
(
hasErrors
(
data
))
{
onError
(
data
,
DELETE_SCHEDULE_ERROR
);
}
else
{
deleteScheduleFromStore
(
store
,
query
,
data
,
variables
);
}
};
export
const
updateStoreAfterScheduleEdit
=
(
store
,
query
,
data
,
variables
)
=>
{
if
(
hasErrors
(
data
))
{
onError
(
data
,
UPDATE_SCHEDULE_ERROR
);
}
else
{
updateScheduleFromStore
(
store
,
query
,
data
,
variables
);
}
};
ee/app/assets/javascripts/oncall_schedules/utils/error_messages.js
0 → 100644
View file @
dc8f04ed
import
{
s__
}
from
'
~/locale
'
;
export
const
DELETE_SCHEDULE_ERROR
=
s__
(
'
OnCallSchedules|The schedule could not be deleted. Please try again.
'
,
);
export
const
UPDATE_SCHEDULE_ERROR
=
s__
(
'
OnCallSchedules|The schedule could not be updated. Please try again.
'
,
);
ee/spec/frontend/oncall_schedule/__snapshots__/add_edit_schedule_form_spec.js.snap
0 → 100644
View file @
dc8f04ed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddEditScheduleForm renders modal layout 1`] = `
<gl-form-stub
modalid="modalId"
>
<gl-form-group-stub
invalid-feedback="Can't be empty"
label="Name"
label-for="schedule-name"
label-size="sm"
>
<gl-form-input-stub
id="schedule-name"
state="true"
value="Test schedule"
/>
</gl-form-group-stub>
<gl-form-group-stub
label="Description (optional)"
label-for="schedule-description"
label-size="sm"
>
<gl-form-input-stub
id="schedule-description"
value="Description 1 lives here"
/>
</gl-form-group-stub>
<gl-form-group-stub
description="Sets the default timezone for the schedule, for all participants"
invalid-feedback="Can't be empty"
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
state="true"
>
<gl-dropdown-stub
category="primary"
class="timezone-dropdown gl-w-full"
headertext="Select timezone"
id="schedule-timezone"
size="medium"
text="(UTC-12:00) -12 International Date Line West"
variant="default"
>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
value=""
/>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischecked="true"
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-12:00) -12 International Date Line West
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST American Samoa
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST Midway Island
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-10:00) HST Hawaii
</span>
</gl-dropdown-item-stub>
<!---->
</gl-dropdown-stub>
</gl-form-group-stub>
</gl-form-stub>
`;
ee/spec/frontend/oncall_schedule/__snapshots__/add_schedule_modal_spec.js.snap
View file @
dc8f04ed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Add
schedule m
odal renders modal layout 1`] = `
exports[`Add
ScheduleM
odal renders modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
...
...
@@ -12,115 +12,9 @@ exports[`Add schedule modal renders modal layout 1`] = `
>
<!---->
<gl-form-stub>
<gl-form-group-stub
invalid-feedback="Can't be empty"
label="Name"
label-for="schedule-name"
label-size="sm"
>
<gl-form-input-stub
id="schedule-name"
value=""
<add-edit-schedule-form-stub
form="[object Object]"
schedule="[object Object]"
/>
</gl-form-group-stub>
<gl-form-group-stub
label="Description (optional)"
label-for="schedule-description"
label-size="sm"
>
<gl-form-input-stub
id="schedule-description"
value=""
/>
</gl-form-group-stub>
<gl-form-group-stub
description="Sets the default timezone for the schedule, for all participants"
invalid-feedback="Can't be empty"
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
>
<gl-dropdown-stub
category="primary"
class="timezone-dropdown gl-w-full invalid-dropdown"
headertext="Select timezone"
id="schedule-timezone"
size="medium"
text="Select timezone"
variant="default"
>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
value=""
/>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-12:00) -12 International Date Line West
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST American Samoa
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST Midway Island
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-10:00) HST Hawaii
</span>
</gl-dropdown-item-stub>
<!---->
</gl-dropdown-stub>
</gl-form-group-stub>
</gl-form-stub>
</gl-modal-stub>
`;
ee/spec/frontend/oncall_schedule/__snapshots__/delete_schedule_modal_spec.js.snap
0 → 100644
View file @
dc8f04ed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeleteScheduleModal renders delete schedule modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
modalclass=""
modalid="deleteScheduleModal"
size="sm"
title="Delete schedule"
titletag="h4"
>
<!---->
<gl-sprintf-stub
message="Are you sure you want to delete the \\"%{deleteSchedule}\\" schedule? This action cannot be undone."
/>
</gl-modal-stub>
`;
ee/spec/frontend/oncall_schedule/__snapshots__/edit_schedule_modal_spec.js.snap
0 → 100644
View file @
dc8f04ed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UpdateScheduleModal renders update schedule modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
modalclass=""
modalid="updateScheduleModal"
size="sm"
title="Edit schedule"
titletag="h4"
>
<!---->
<add-edit-schedule-form-stub
form="[object Object]"
schedule="[object Object]"
/>
</gl-modal-stub>
`;
ee/spec/frontend/oncall_schedule/add_edit_schedule_form_spec.js
0 → 100644
View file @
dc8f04ed
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
GlSearchBoxByType
,
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
import
AddEditScheduleForm
,
{
i18n
,
}
from
'
ee/oncall_schedules/components/add_edit_schedule_form.vue
'
;
import
{
getOncallSchedulesQueryResponse
}
from
'
./mocks/apollo_mock
'
;
import
mockTimezones
from
'
./mocks/mockTimezones.json
'
;
describe
(
'
AddEditScheduleForm
'
,
()
=>
{
let
wrapper
;
const
projectPath
=
'
group/project
'
;
const
mutate
=
jest
.
fn
();
const
mockSchedule
=
getOncallSchedulesQueryResponse
.
data
.
project
.
incidentManagementOncallSchedules
.
nodes
[
0
];
const
createComponent
=
({
props
=
{}
}
=
{})
=>
{
wrapper
=
shallowMount
(
AddEditScheduleForm
,
{
propsData
:
{
modalId
:
'
modalId
'
,
form
:
{
name
:
mockSchedule
.
name
,
description
:
mockSchedule
.
description
,
timezone
:
mockTimezones
[
0
],
},
isNameInvalid
:
false
,
isTimezoneInvalid
:
false
,
schedule
:
mockSchedule
,
...
props
,
},
provide
:
{
projectPath
,
timezones
:
mockTimezones
,
},
mocks
:
{
$apollo
:
{
mutate
,
},
},
stubs
:
{
GlFormGroup
:
false
,
},
});
};
beforeEach
(()
=>
{
createComponent
();
});
afterEach
(()
=>
{
wrapper
.
destroy
();
wrapper
=
null
;
});
const
findTimezoneDropdown
=
()
=>
wrapper
.
find
(
GlDropdown
);
const
findDropdownOptions
=
()
=>
wrapper
.
findAll
(
GlDropdownItem
);
const
findTimezoneSearchBox
=
()
=>
wrapper
.
find
(
GlSearchBoxByType
);
it
(
'
renders modal layout
'
,
()
=>
{
expect
(
wrapper
.
element
).
toMatchSnapshot
();
});
describe
(
'
Timezone select
'
,
()
=>
{
it
(
'
has options based on provided BE data
'
,
()
=>
{
expect
(
findDropdownOptions
()).
toHaveLength
(
mockTimezones
.
length
);
});
it
(
'
formats each option
'
,
()
=>
{
findDropdownOptions
().
wrappers
.
forEach
((
option
,
index
)
=>
{
const
tz
=
mockTimezones
[
index
];
const
expectedValue
=
`(UTC
${
tz
.
formatted_offset
}
)
${
tz
.
abbr
}
${
tz
.
name
}
`
;
expect
(
option
.
text
()).
toBe
(
expectedValue
);
});
});
describe
(
'
timezones filtering
'
,
()
=>
{
it
(
'
should filter options based on search term
'
,
async
()
=>
{
const
searchTerm
=
'
Hawaii
'
;
findTimezoneSearchBox
().
vm
.
$emit
(
'
input
'
,
searchTerm
);
await
wrapper
.
vm
.
$nextTick
();
const
options
=
findDropdownOptions
();
expect
(
options
).
toHaveLength
(
1
);
expect
(
options
.
at
(
0
).
text
()).
toContain
(
searchTerm
);
});
it
(
'
should display no results item when there are no filter matches
'
,
async
()
=>
{
const
searchTerm
=
'
someUnexistentTZ
'
;
findTimezoneSearchBox
().
vm
.
$emit
(
'
input
'
,
searchTerm
);
await
wrapper
.
vm
.
$nextTick
();
const
options
=
findDropdownOptions
();
expect
(
options
).
toHaveLength
(
1
);
expect
(
options
.
at
(
0
).
text
()).
toContain
(
i18n
.
noResults
);
});
});
it
(
'
should add a checkmark to the selected option
'
,
async
()
=>
{
const
selectedTZOption
=
findDropdownOptions
().
at
(
0
);
selectedTZOption
.
vm
.
$emit
(
'
click
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
selectedTZOption
.
attributes
(
'
ischecked
'
)).
toBe
(
'
true
'
);
});
});
describe
(
'
Form validation
'
,
()
=>
{
describe
(
'
Timezone select
'
,
()
=>
{
it
(
'
has red border when nothing selected
'
,
()
=>
{
createComponent
({
props
:
{
schedule
:
null
,
form
:
{
name
:
''
,
description
:
''
,
timezone
:
''
},
isTimezoneInvalid
:
true
,
},
});
expect
(
findTimezoneDropdown
().
classes
()).
toContain
(
'
invalid-dropdown
'
);
});
it
(
"
doesn't have a red border when there is selected option
"
,
async
()
=>
{
findDropdownOptions
()
.
at
(
1
)
.
vm
.
$emit
(
'
click
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
findTimezoneDropdown
().
classes
()).
not
.
toContain
(
'
invalid-dropdown
'
);
});
});
});
});
ee/spec/frontend/oncall_schedule/add_schedule_modal_spec.js
View file @
dc8f04ed
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
Gl
SearchBoxByType
,
GlDropdown
,
GlDropdownItem
,
Gl
Modal
,
GlAlert
}
from
'
@gitlab/ui
'
;
import
{
GlModal
,
GlAlert
}
from
'
@gitlab/ui
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
AddScheduleModal
,
{
i18n
}
from
'
ee/oncall_schedules/components/add_schedule_modal.vue
'
;
import
AddScheduleModal
from
'
ee/oncall_schedules/components/add_schedule_modal.vue
'
;
import
{
getOncallSchedulesQueryResponse
}
from
'
./mocks/apollo_mock
'
;
import
mockTimezones
from
'
./mocks/mockTimezones.json
'
;
describe
(
'
Add
schedule m
odal
'
,
()
=>
{
describe
(
'
Add
ScheduleM
odal
'
,
()
=>
{
let
wrapper
;
const
projectPath
=
'
group/project
'
;
const
mutate
=
jest
.
fn
();
const
mockHideModal
=
jest
.
fn
();
function
mountComponent
()
{
const
createComponent
=
({
data
=
{},
props
=
{}
}
=
{})
=>
{
wrapper
=
shallowMount
(
AddScheduleModal
,
{
data
()
{
return
{
form
:
getOncallSchedulesQueryResponse
.
data
.
project
.
incidentManagementOncallSchedules
.
nodes
[
0
],
...
data
,
};
},
propsData
:
{
modalId
:
'
modalId
'
,
...
props
,
},
provide
:
{
projectPath
,
...
...
@@ -24,17 +33,13 @@ describe('Add schedule modal', () => {
mutate
,
},
},
stubs
:
{
GlFormGroup
:
false
,
},
});
wrapper
.
vm
.
$refs
.
createScheduleModal
.
hide
=
mockHideModal
;
}
}
;
beforeEach
(()
=>
{
mount
Component
();
create
Component
();
});
afterEach
(()
=>
{
...
...
@@ -44,55 +49,11 @@ describe('Add schedule modal', () => {
const
findModal
=
()
=>
wrapper
.
find
(
GlModal
);
const
findAlert
=
()
=>
wrapper
.
find
(
GlAlert
);
const
findTimezoneDropdown
=
()
=>
wrapper
.
find
(
GlDropdown
);
const
findDropdownOptions
=
()
=>
wrapper
.
findAll
(
GlDropdownItem
);
const
findTimezoneSearchBox
=
()
=>
wrapper
.
find
(
GlSearchBoxByType
);
it
(
'
renders modal layout
'
,
()
=>
{
expect
(
wrapper
.
element
).
toMatchSnapshot
();
});
describe
(
'
Timezone select
'
,
()
=>
{
it
(
'
has options based on provided BE data
'
,
()
=>
{
expect
(
findDropdownOptions
().
length
).
toBe
(
mockTimezones
.
length
);
});
it
(
'
formats each option
'
,
()
=>
{
findDropdownOptions
().
wrappers
.
forEach
((
option
,
index
)
=>
{
const
tz
=
mockTimezones
[
index
];
const
expectedValue
=
`(UTC
${
tz
.
formatted_offset
}
)
${
tz
.
abbr
}
${
tz
.
name
}
`
;
expect
(
option
.
text
()).
toBe
(
expectedValue
);
});
});
describe
(
'
timezones filtering
'
,
()
=>
{
it
(
'
should filter options based on search term
'
,
async
()
=>
{
const
searchTerm
=
'
Hawaii
'
;
findTimezoneSearchBox
().
vm
.
$emit
(
'
input
'
,
searchTerm
);
await
wrapper
.
vm
.
$nextTick
();
const
options
=
findDropdownOptions
();
expect
(
options
.
length
).
toBe
(
1
);
expect
(
options
.
at
(
0
).
text
()).
toContain
(
searchTerm
);
});
it
(
'
should display no results item when there are no filter matches
'
,
async
()
=>
{
const
searchTerm
=
'
someUnexistentTZ
'
;
findTimezoneSearchBox
().
vm
.
$emit
(
'
input
'
,
searchTerm
);
await
wrapper
.
vm
.
$nextTick
();
const
options
=
findDropdownOptions
();
expect
(
options
.
length
).
toBe
(
1
);
expect
(
options
.
at
(
0
).
text
()).
toContain
(
i18n
.
noResults
);
});
});
it
(
'
should add a checkmark to the selected option
'
,
async
()
=>
{
const
selectedTZOption
=
findDropdownOptions
().
at
(
0
);
selectedTZOption
.
vm
.
$emit
(
'
click
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
selectedTZOption
.
attributes
(
'
ischecked
'
)).
toBe
(
'
true
'
);
});
});
describe
(
'
Schedule create
'
,
()
=>
{
it
(
'
makes a request with form data to create a schedule
'
,
()
=>
{
mutate
.
mockResolvedValueOnce
({});
...
...
@@ -121,20 +82,4 @@ describe('Add schedule modal', () => {
expect
(
alert
.
text
()).
toContain
(
error
);
});
});
describe
(
'
Form validation
'
,
()
=>
{
describe
(
'
Timezone select
'
,
()
=>
{
it
(
'
has red border when nothing selected
'
,
()
=>
{
expect
(
findTimezoneDropdown
().
classes
()).
toContain
(
'
invalid-dropdown
'
);
});
it
(
"
doesn't have a red border when there is selected opeion
"
,
async
()
=>
{
findDropdownOptions
()
.
at
(
1
)
.
vm
.
$emit
(
'
click
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
findTimezoneDropdown
().
classes
()).
not
.
toContain
(
'
invalid-dropdown
'
);
});
});
});
});
ee/spec/frontend/oncall_schedule/delete_schedule_modal_spec.js
0 → 100644
View file @
dc8f04ed
/* eslint-disable no-unused-vars */
import
{
shallowMount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
createMockApollo
from
'
jest/helpers/mock_apollo_helper
'
;
import
{
GlModal
,
GlAlert
,
GlSprintf
}
from
'
@gitlab/ui
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
destroyOncallScheduleMutation
from
'
ee/oncall_schedules/graphql/mutations/destroy_oncall_schedule.mutation.graphql
'
;
import
DeleteScheduleModal
,
{
i18n
,
}
from
'
ee/oncall_schedules/components/delete_schedule_modal.vue
'
;
import
{
getOncallSchedulesQueryResponse
,
destroyScheduleResponse
}
from
'
./mocks/apollo_mock
'
;
const
localVue
=
createLocalVue
();
const
projectPath
=
'
group/project
'
;
const
mutate
=
jest
.
fn
();
const
mockHideModal
=
jest
.
fn
();
localVue
.
use
(
VueApollo
);
describe
(
'
DeleteScheduleModal
'
,
()
=>
{
let
wrapper
;
let
fakeApollo
;
let
destroyScheduleHandler
;
const
findModal
=
()
=>
wrapper
.
find
(
GlModal
);
const
findModalText
=
()
=>
wrapper
.
find
(
GlSprintf
);
const
findAlert
=
()
=>
wrapper
.
find
(
GlAlert
);
async
function
awaitApolloDomMock
()
{
await
wrapper
.
vm
.
$nextTick
();
// kick off the DOM update
await
jest
.
runOnlyPendingTimers
();
// kick off the mocked GQL stuff (promises)
await
wrapper
.
vm
.
$nextTick
();
// kick off the DOM update for flash
}
async
function
destroySchedule
(
localWrapper
)
{
await
jest
.
runOnlyPendingTimers
();
await
localWrapper
.
vm
.
$nextTick
();
localWrapper
.
vm
.
$emit
(
'
primary
'
);
}
const
createComponent
=
({
data
=
{},
props
=
{}
}
=
{})
=>
{
wrapper
=
shallowMount
(
DeleteScheduleModal
,
{
data
()
{
return
{
...
data
,
};
},
propsData
:
{
schedule
:
getOncallSchedulesQueryResponse
.
data
.
project
.
incidentManagementOncallSchedules
.
nodes
[
0
],
...
props
,
},
provide
:
{
projectPath
,
},
mocks
:
{
$apollo
:
{
mutate
,
},
},
stubs
:
{
GlSprintf
:
false
},
});
wrapper
.
vm
.
$refs
.
deleteScheduleModal
.
hide
=
mockHideModal
;
};
function
createComponentWithApollo
({
destroyHandler
=
jest
.
fn
().
mockResolvedValue
(
destroyScheduleResponse
),
}
=
{})
{
localVue
.
use
(
VueApollo
);
destroyScheduleHandler
=
destroyHandler
;
const
requestHandlers
=
[[
destroyOncallScheduleMutation
,
destroyScheduleHandler
]];
fakeApollo
=
createMockApollo
(
requestHandlers
);
wrapper
=
shallowMount
(
DeleteScheduleModal
,
{
localVue
,
apolloProvider
:
fakeApollo
,
provide
:
{
projectPath
,
},
});
}
beforeEach
(()
=>
{
createComponent
();
});
afterEach
(()
=>
{
wrapper
.
destroy
();
wrapper
=
null
;
});
it
(
'
renders delete schedule modal layout
'
,
()
=>
{
expect
(
wrapper
.
element
).
toMatchSnapshot
();
});
describe
(
'
renders delete modal with the correct schedule information
'
,
()
=>
{
it
(
'
renders name of schedule to destroy
'
,
()
=>
{
expect
(
findModalText
().
attributes
(
'
message
'
)).
toBe
(
i18n
.
deleteScheduleMessage
);
});
});
describe
(
'
Schedule destroy apollo API call
'
,
()
=>
{
it
(
'
makes a request with `oncallScheduleDestroy` to delete a schedule
'
,
()
=>
{
mutate
.
mockResolvedValueOnce
({});
findModal
().
vm
.
$emit
(
'
primary
'
,
{
preventDefault
:
jest
.
fn
()
});
expect
(
mutate
).
toHaveBeenCalledWith
({
mutation
:
expect
.
any
(
Object
),
update
:
expect
.
anything
(),
// TODO: Once the BE is complete for the mutation update this spec to use the correct params
variables
:
expect
.
anything
(),
});
});
it
(
'
hides the modal on successful schedule deletion
'
,
async
()
=>
{
mutate
.
mockResolvedValueOnce
({
data
:
{
oncallScheduleDestroy
:
{
errors
:
[]
}
}
});
findModal
().
vm
.
$emit
(
'
primary
'
,
{
preventDefault
:
jest
.
fn
()
});
await
waitForPromises
();
expect
(
mockHideModal
).
toHaveBeenCalled
();
});
it
(
"
doesn't hide the modal on deletion fail
"
,
async
()
=>
{
const
error
=
'
some error
'
;
mutate
.
mockResolvedValueOnce
({
data
:
{
oncallScheduleDestroy
:
{
errors
:
[
error
]
}
}
});
findModal
().
vm
.
$emit
(
'
primary
'
,
{
preventDefault
:
jest
.
fn
()
});
await
waitForPromises
();
const
alert
=
findAlert
();
expect
(
mockHideModal
).
not
.
toHaveBeenCalled
();
expect
(
alert
.
exists
()).
toBe
(
true
);
expect
(
alert
.
text
()).
toContain
(
error
);
});
});
describe
(
'
with mocked Apollo client
'
,
()
=>
{
// TODO: Once the BE is complete for the mutation add specs here for that via a destroyHandler
});
});
ee/spec/frontend/oncall_schedule/edit_schedule_modal_spec.js
0 → 100644
View file @
dc8f04ed
/* eslint-disable no-unused-vars */
import
{
shallowMount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
createMockApollo
from
'
jest/helpers/mock_apollo_helper
'
;
import
{
GlModal
}
from
'
@gitlab/ui
'
;
import
VueApollo
from
'
vue-apollo
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
updateOncallScheduleMutation
from
'
ee/oncall_schedules/graphql/mutations/update_oncall_schedule.mutation.graphql
'
;
import
UpdateScheduleModal
,
{
i18n
}
from
'
ee/oncall_schedules/components/edit_schedule_modal.vue
'
;
import
{
UPDATE_SCHEDULE_ERROR
}
from
'
ee/oncall_schedules/utils/error_messages
'
;
import
{
getOncallSchedulesQueryResponse
,
updateScheduleResponse
}
from
'
./mocks/apollo_mock
'
;
import
mockTimezones
from
'
./mocks/mockTimezones.json
'
;
const
localVue
=
createLocalVue
();
const
projectPath
=
'
group/project
'
;
const
mutate
=
jest
.
fn
();
const
mockHideModal
=
jest
.
fn
();
localVue
.
use
(
VueApollo
);
describe
(
'
UpdateScheduleModal
'
,
()
=>
{
let
wrapper
;
let
fakeApollo
;
let
updateScheduleHandler
;
const
findModal
=
()
=>
wrapper
.
find
(
GlModal
);
async
function
awaitApolloDomMock
()
{
await
wrapper
.
vm
.
$nextTick
();
// kick off the DOM update
await
jest
.
runOnlyPendingTimers
();
// kick off the mocked GQL stuff (promises)
await
wrapper
.
vm
.
$nextTick
();
// kick off the DOM update for flash
}
async
function
destroySchedule
(
localWrapper
)
{
await
jest
.
runOnlyPendingTimers
();
await
localWrapper
.
vm
.
$nextTick
();
localWrapper
.
vm
.
$emit
(
'
primary
'
);
}
const
createComponent
=
({
data
=
{},
props
=
{}
}
=
{})
=>
{
wrapper
=
shallowMount
(
UpdateScheduleModal
,
{
data
()
{
return
{
...
data
,
form
:
getOncallSchedulesQueryResponse
.
data
.
project
.
incidentManagementOncallSchedules
.
nodes
[
0
],
};
},
propsData
:
{
schedule
:
getOncallSchedulesQueryResponse
.
data
.
project
.
incidentManagementOncallSchedules
.
nodes
[
0
],
...
props
,
},
provide
:
{
projectPath
,
timezones
:
mockTimezones
,
},
mocks
:
{
$apollo
:
{
mutate
,
},
},
});
wrapper
.
vm
.
$refs
.
updateScheduleModal
.
hide
=
mockHideModal
;
};
function
createComponentWithApollo
({
updateHandler
=
jest
.
fn
().
mockResolvedValue
(
updateScheduleResponse
),
}
=
{})
{
localVue
.
use
(
VueApollo
);
updateScheduleHandler
=
updateHandler
;
const
requestHandlers
=
[[
updateOncallScheduleMutation
,
updateScheduleHandler
]];
fakeApollo
=
createMockApollo
(
requestHandlers
);
wrapper
=
shallowMount
(
UpdateScheduleModal
,
{
localVue
,
apolloProvider
:
fakeApollo
,
provide
:
{
projectPath
,
timezones
:
mockTimezones
,
},
});
}
beforeEach
(()
=>
{
createComponent
();
});
afterEach
(()
=>
{
wrapper
.
destroy
();
wrapper
=
null
;
});
it
(
'
renders update schedule modal layout
'
,
()
=>
{
expect
(
wrapper
.
element
).
toMatchSnapshot
();
});
describe
(
'
renders update modal with the correct schedule information
'
,
()
=>
{
it
(
'
renders name of correct modal id
'
,
()
=>
{
expect
(
findModal
().
attributes
(
'
modalid
'
)).
toBe
(
'
updateScheduleModal
'
);
});
it
(
'
renders name of schedule to update
'
,
()
=>
{
expect
(
findModal
().
html
()).
toContain
(
i18n
.
editSchedule
);
});
});
describe
(
'
Schedule update apollo API call
'
,
()
=>
{
it
(
'
makes a request with `oncallScheduleUpdate` to update a schedule
'
,
()
=>
{
mutate
.
mockResolvedValueOnce
({});
findModal
().
vm
.
$emit
(
'
primary
'
,
{
preventDefault
:
jest
.
fn
()
});
expect
(
mutate
).
toHaveBeenCalledWith
({
mutation
:
expect
.
any
(
Object
),
update
:
expect
.
anything
(),
// TODO: Once the BE is complete for the mutation update this spec to use the correct params
variables
:
expect
.
anything
(),
});
});
it
(
'
hides the modal on successful schedule creation
'
,
async
()
=>
{
mutate
.
mockResolvedValueOnce
({
data
:
{
oncallScheduleUpdate
:
{
errors
:
[]
}
}
});
findModal
().
vm
.
$emit
(
'
primary
'
,
{
preventDefault
:
jest
.
fn
()
});
await
waitForPromises
();
// TODO: Once the BE is complete for the mutation update this spec to use the call
expect
(
mockHideModal
).
not
.
toHaveBeenCalled
();
});
it
(
"
doesn't hide the modal on fail
"
,
async
()
=>
{
const
error
=
'
some error
'
;
mutate
.
mockResolvedValueOnce
({
data
:
{
oncallScheduleUpdate
:
{
errors
:
[
error
]
}
}
});
findModal
().
vm
.
$emit
(
'
primary
'
,
{
preventDefault
:
jest
.
fn
()
});
await
waitForPromises
();
expect
(
mockHideModal
).
not
.
toHaveBeenCalled
();
});
});
describe
(
'
with mocked Apollo client
'
,
()
=>
{
// TODO: Once the BE is complete for the mutation add specs here for that via a destroyHandler
});
});
ee/spec/frontend/oncall_schedule/mocks/apollo_mock.js
View file @
dc8f04ed
...
...
@@ -14,3 +14,70 @@ export const participants = [
avatarUrl
:
''
,
},
];
export
const
errorMsg
=
'
Something went wrong
'
;
export
const
getOncallSchedulesQueryResponse
=
{
data
:
{
project
:
{
incidentManagementOncallSchedules
:
{
nodes
:
[
{
iid
:
'
37
'
,
name
:
'
Test schedule
'
,
description
:
'
Description 1 lives here
'
,
timezone
:
'
Pacific/Honolulu
'
,
},
],
},
},
},
};
export
const
scheduleToDestroy
=
{
iid
:
'
37
'
,
name
:
'
Test schedule
'
,
description
:
'
Description 1 lives here
'
,
timezone
:
'
Pacific/Honolulu
'
,
};
export
const
destroyScheduleResponse
=
{
data
:
{
oncallScheduleDestroy
:
{
errors
:
[],
oncallSchedule
:
{
iid
:
'
37
'
,
name
:
'
Test schedule
'
,
description
:
'
Description 1 lives here
'
,
timezone
:
'
Pacific/Honolulu
'
,
},
},
},
};
export
const
destroyScheduleResponseWithErrors
=
{
data
:
{
oncallScheduleDestroy
:
{
errors
:
[
'
Houston, we have a problem
'
],
oncallSchedule
:
{
iid
:
'
37
'
,
name
:
'
Test schedule
'
,
description
:
'
Description 1 lives here
'
,
timezone
:
'
Pacific/Honolulu
'
,
},
},
},
};
export
const
updateScheduleResponse
=
{
data
:
{
oncallScheduleDestroy
:
{
errors
:
[],
oncallSchedule
:
{
iid
:
'
37
'
,
name
:
'
Test schedule 2
'
,
description
:
'
Description 2 lives here
'
,
timezone
:
'
Pacific/Honolulu
'
,
},
},
},
};
ee/spec/frontend/oncall_schedule/schedule/components/schedule_timeline_section_spec.js
View file @
dc8f04ed
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
ScheduleTimelineSection
from
'
ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue
'
;
import
{
GlCard
,
GlButton
}
from
'
@gitlab/ui
'
;
import
ScheduleTimelineSection
,
{
i18n
,
}
from
'
ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue
'
;
import
WeeksHeaderItem
from
'
ee/oncall_schedules/components/schedule/components/preset_weeks/weeks_header_item.vue
'
;
import
{
getTimeframeForWeeksView
}
from
'
ee/oncall_schedules/components/schedule/utils
'
;
import
{
PRESET_TYPES
}
from
'
ee/oncall_schedules/components/schedule/constants
'
;
...
...
@@ -9,6 +12,9 @@ describe('RoadmapTimelineSectionComponent', () => {
const
mockTimeframeInitialDate
=
new
Date
(
2018
,
0
,
1
);
const
mockTimeframeWeeks
=
getTimeframeForWeeksView
(
mockTimeframeInitialDate
);
const
findRotations
=
()
=>
wrapper
.
find
(
GlCard
);
const
findAddRotation
=
()
=>
wrapper
.
find
(
GlButton
);
function
mountComponent
({
presetType
=
PRESET_TYPES
.
WEEKS
,
timeframe
=
mockTimeframeWeeks
,
...
...
@@ -18,6 +24,9 @@ describe('RoadmapTimelineSectionComponent', () => {
presetType
,
timeframe
,
},
stubs
:
{
GlCard
,
},
});
}
...
...
@@ -33,7 +42,7 @@ describe('RoadmapTimelineSectionComponent', () => {
});
it
(
'
renders component container element with class `timeline-section`
'
,
()
=>
{
expect
(
wrapper
.
classes
()).
toContain
(
'
timeline-section
'
);
expect
(
wrapper
.
html
()).
toContain
(
'
timeline-section
'
);
});
it
(
'
renders empty header cell element with class `timeline-header-blank`
'
,
()
=>
{
...
...
@@ -43,4 +52,13 @@ describe('RoadmapTimelineSectionComponent', () => {
it
(
'
renders weeks header items based on timeframe data
'
,
()
=>
{
expect
(
wrapper
.
findAll
(
WeeksHeaderItem
).
length
).
toBe
(
mockTimeframeWeeks
.
length
);
});
it
(
'
renders the rotation card wrapper
'
,
()
=>
{
expect
(
findRotations
().
exists
()).
toBe
(
true
);
});
it
(
'
renders the add rotation button in the rotation card wrapper
'
,
()
=>
{
expect
(
findAddRotation
().
exists
()).
toBe
(
true
);
expect
(
findAddRotation
().
text
()).
toBe
(
i18n
.
addARotation
);
});
});
locale/gitlab.pot
View file @
dc8f04ed
...
...
@@ -19097,6 +19097,9 @@ msgstr ""
msgid "On-call schedules"
msgstr ""
msgid "OnCallSchedules|Add a rotation"
msgstr ""
msgid "OnCallSchedules|Add a schedule"
msgstr ""
...
...
@@ -19106,15 +19109,27 @@ msgstr ""
msgid "OnCallSchedules|Add schedule"
msgstr ""
msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone."
msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr ""
msgid "OnCallSchedules|Delete schedule"
msgstr ""
msgid "OnCallSchedules|Edit schedule"
msgstr ""
msgid "OnCallSchedules|Failed to add rotation"
msgstr ""
msgid "OnCallSchedules|Failed to add schedule"
msgstr ""
msgid "OnCallSchedules|Failed to edit schedule"
msgstr ""
msgid "OnCallSchedules|On-call schedule"
msgstr ""
...
...
@@ -19133,6 +19148,9 @@ msgstr ""
msgid "OnCallSchedules|Rotation start date cannot be empty"
msgstr ""
msgid "OnCallSchedules|Rotations"
msgstr ""
msgid "OnCallSchedules|Route alerts directly to specific members of your team"
msgstr ""
...
...
@@ -19145,6 +19163,12 @@ msgstr ""
msgid "OnCallSchedules|Sets the default timezone for the schedule, for all participants"
msgstr ""
msgid "OnCallSchedules|The schedule could not be deleted. Please try again."
msgstr ""
msgid "OnCallSchedules|The schedule could not be updated. Please try again."
msgstr ""
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
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