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
9b03a623
Commit
9b03a623
authored
Apr 14, 2021
by
Jiaan Louw
Committed by
Kushal Pandya
Apr 14, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add external approvals rules to project settings
parent
a6785099
Changes
17
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
741 additions
and
76 deletions
+741
-76
ee/app/assets/javascripts/approvals/components/approval_gate_icon.vue
...s/javascripts/approvals/components/approval_gate_icon.vue
+43
-0
ee/app/assets/javascripts/approvals/components/approver_type_select.vue
...javascripts/approvals/components/approver_type_select.vue
+53
-0
ee/app/assets/javascripts/approvals/components/modal_rule_remove.vue
...ts/javascripts/approvals/components/modal_rule_remove.vue
+46
-22
ee/app/assets/javascripts/approvals/components/project_settings/project_rules.vue
...s/approvals/components/project_settings/project_rules.vue
+13
-3
ee/app/assets/javascripts/approvals/components/rule_form.vue
ee/app/assets/javascripts/approvals/components/rule_form.vue
+124
-14
ee/app/assets/javascripts/approvals/constants.js
ee/app/assets/javascripts/approvals/constants.js
+1
-0
ee/app/assets/javascripts/approvals/mappers.js
ee/app/assets/javascripts/approvals/mappers.js
+1
-1
ee/app/controllers/ee/projects_controller.rb
ee/app/controllers/ee/projects_controller.rb
+4
-0
ee/spec/features/projects/settings/merge_requests_settings_spec.rb
...eatures/projects/settings/merge_requests_settings_spec.rb
+68
-9
ee/spec/frontend/approvals/components/__snapshots__/modal_rule_remove_spec.js.snap
...s/components/__snapshots__/modal_rule_remove_spec.js.snap
+16
-2
ee/spec/frontend/approvals/components/approval_gate_icon_spec.js
.../frontend/approvals/components/approval_gate_icon_spec.js
+42
-0
ee/spec/frontend/approvals/components/approver_type_select_spec.js
...rontend/approvals/components/approver_type_select_spec.js
+60
-0
ee/spec/frontend/approvals/components/modal_rule_remove_spec.js
...c/frontend/approvals/components/modal_rule_remove_spec.js
+22
-14
ee/spec/frontend/approvals/components/project_settings/project_rules_spec.js
...provals/components/project_settings/project_rules_spec.js
+24
-1
ee/spec/frontend/approvals/components/rule_form_spec.js
ee/spec/frontend/approvals/components/rule_form_spec.js
+184
-10
ee/spec/frontend/approvals/mocks.js
ee/spec/frontend/approvals/mocks.js
+7
-0
locale/gitlab.pot
locale/gitlab.pot
+33
-0
No files found.
ee/app/assets/javascripts/approvals/components/approval_gate_icon.vue
0 → 100644
View file @
9b03a623
<
script
>
import
{
GlIcon
,
GlPopover
}
from
'
@gitlab/ui
'
;
import
{
uniqueId
}
from
'
lodash
'
;
import
{
__
}
from
'
~/locale
'
;
export
default
{
components
:
{
GlIcon
,
GlPopover
,
},
props
:
{
url
:
{
type
:
String
,
required
:
true
,
},
},
computed
:
{
iconId
()
{
return
uniqueId
(
'
approval-icon-
'
);
},
containerId
()
{
return
uniqueId
(
'
approva-icon-container-
'
);
},
},
i18n
:
{
title
:
__
(
'
Approval Gate
'
),
},
};
</
script
>
<
template
>
<div
:id=
"containerId"
>
<gl-icon
:id=
"iconId"
name=
"api"
/>
<gl-popover
:target=
"iconId"
:container=
"containerId"
placement=
"top"
:title=
"$options.i18n.title"
triggers=
"hover focus"
:content=
"url"
/>
</div>
</
template
>
ee/app/assets/javascripts/approvals/components/approver_type_select.vue
0 → 100644
View file @
9b03a623
<
script
>
import
{
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
export
default
{
components
:
{
GlDropdown
,
GlDropdownItem
,
},
props
:
{
approverTypeOptions
:
{
type
:
Array
,
required
:
true
,
},
},
data
()
{
return
{
selected
:
null
,
};
},
computed
:
{
dropdownText
()
{
return
this
.
selected
.
text
;
},
},
created
()
{
const
[
firstOption
]
=
this
.
approverTypeOptions
;
this
.
onSelect
(
firstOption
);
},
methods
:
{
isSelectedType
(
type
)
{
return
this
.
selected
.
type
===
type
;
},
onSelect
(
option
)
{
this
.
selected
=
option
;
this
.
$emit
(
'
input
'
,
option
.
type
);
},
},
};
</
script
>
<
template
>
<gl-dropdown
class=
"gl-w-full gl-dropdown-menu-full-width"
:text=
"dropdownText"
>
<gl-dropdown-item
v-for=
"option in approverTypeOptions"
:key=
"option.type"
:is-check-item=
"true"
:is-checked=
"isSelectedType(option.type)"
@
click=
"onSelect(option)"
>
<span>
{{
option
.
text
}}
</span>
</gl-dropdown-item>
</gl-dropdown>
</
template
>
ee/app/assets/javascripts/approvals/components/modal_rule_remove.vue
View file @
9b03a623
...
@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui';
...
@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui';
import
{
mapActions
,
mapState
}
from
'
vuex
'
;
import
{
mapActions
,
mapState
}
from
'
vuex
'
;
import
{
n__
,
s__
,
__
}
from
'
~/locale
'
;
import
{
n__
,
s__
,
__
}
from
'
~/locale
'
;
import
GlModalVuex
from
'
~/vue_shared/components/gl_modal_vuex.vue
'
;
import
GlModalVuex
from
'
~/vue_shared/components/gl_modal_vuex.vue
'
;
import
{
RULE_TYPE_EXTERNAL_APPROVAL
}
from
'
../constants
'
;
const
i18n
=
{
const
i18n
=
{
cancelButtonText
:
__
(
'
Cancel
'
),
cancelButtonText
:
__
(
'
Cancel
'
),
primaryButtonText
:
__
(
'
Remove approvers
'
),
regularRule
:
{
modalTitle
:
__
(
'
Remove approvers?
'
),
primaryButtonText
:
__
(
'
Remove approvers
'
),
removeWarningText
:
s__
(
modalTitle
:
__
(
'
Remove approvers?
'
),
'
ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.
'
,
removeWarningText
:
s__
(
),
'
ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.
'
,
),
},
externalRule
:
{
primaryButtonText
:
s__
(
'
ApprovalRuleRemove|Remove approval gate
'
),
modalTitle
:
s__
(
'
ApprovalRuleRemove|Remove approval gate?
'
),
removeWarningText
:
s__
(
'
ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked.
'
,
),
},
};
};
export
default
{
export
default
{
...
@@ -28,6 +38,9 @@ export default {
...
@@ -28,6 +38,9 @@ export default {
...
mapState
(
'
deleteModal
'
,
{
...
mapState
(
'
deleteModal
'
,
{
rule
:
'
data
'
,
rule
:
'
data
'
,
}),
}),
isExternalApprovalRule
()
{
return
this
.
rule
?.
ruleType
===
RULE_TYPE_EXTERNAL_APPROVAL
;
},
membersText
()
{
membersText
()
{
return
n__
(
return
n__
(
'
ApprovalRuleRemove|%d member
'
,
'
ApprovalRuleRemove|%d member
'
,
...
@@ -42,24 +55,38 @@ export default {
...
@@ -42,24 +55,38 @@ export default {
this
.
rule
.
approvers
.
length
,
this
.
rule
.
approvers
.
length
,
);
);
},
},
modalTitle
()
{
return
this
.
isExternalApprovalRule
?
i18n
.
externalRule
.
modalTitle
:
i18n
.
regularRule
.
modalTitle
;
},
modalText
()
{
modalText
()
{
return
`
${
i18n
.
removeWarningText
}
${
this
.
revokeWarningText
}
`
;
return
this
.
isExternalApprovalRule
?
i18n
.
externalRule
.
removeWarningText
:
`
${
i18n
.
regularRule
.
removeWarningText
}
${
this
.
revokeWarningText
}
`
;
},
primaryButtonProps
()
{
const
text
=
this
.
isExternalApprovalRule
?
i18n
.
externalRule
.
primaryButtonText
:
i18n
.
regularRule
.
primaryButtonText
;
return
{
text
,
attributes
:
[{
variant
:
'
danger
'
}],
};
},
},
},
},
methods
:
{
methods
:
{
...
mapActions
([
'
deleteRule
'
]),
...
mapActions
([
'
deleteRule
'
,
'
deleteExternalApprovalRule
'
]),
submit
()
{
submit
()
{
this
.
deleteRule
(
this
.
rule
.
id
);
if
(
this
.
rule
.
externalUrl
)
{
this
.
deleteExternalApprovalRule
(
this
.
rule
.
id
);
}
else
{
this
.
deleteRule
(
this
.
rule
.
id
);
}
},
},
},
},
buttonActions
:
{
cancelButtonProps
:
{
primary
:
{
text
:
i18n
.
cancelButtonText
,
text
:
i18n
.
primaryButtonText
,
attributes
:
[{
variant
:
'
danger
'
}],
},
cancel
:
{
text
:
i18n
.
cancelButtonText
,
},
},
},
i18n
,
i18n
,
};
};
...
@@ -69,9 +96,9 @@ export default {
...
@@ -69,9 +96,9 @@ export default {
<gl-modal-vuex
<gl-modal-vuex
modal-module=
"deleteModal"
modal-module=
"deleteModal"
:modal-id=
"modalId"
:modal-id=
"modalId"
:title=
"
$options.i18n.
modalTitle"
:title=
"modalTitle"
:action-primary=
"
$options.buttonActions.primary
"
:action-primary=
"
primaryButtonProps
"
:action-cancel=
"$options.
buttonActions.cancel
"
:action-cancel=
"$options.
cancelButtonProps
"
@
ok.prevent=
"submit"
@
ok.prevent=
"submit"
>
>
<p
v-if=
"rule"
>
<p
v-if=
"rule"
>
...
@@ -82,9 +109,6 @@ export default {
...
@@ -82,9 +109,6 @@ export default {
<
template
#nMembers
>
<
template
#nMembers
>
<strong>
{{
membersText
}}
</strong>
<strong>
{{
membersText
}}
</strong>
</
template
>
</
template
>
<
template
#revokeWarning
>
{{
revokeWarningText
}}
</
template
>
</gl-sprintf>
</gl-sprintf>
</p>
</p>
</gl-modal-vuex>
</gl-modal-vuex>
...
...
ee/app/assets/javascripts/approvals/components/project_settings/project_rules.vue
View file @
9b03a623
...
@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue';
...
@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue';
import
{
n__
,
sprintf
}
from
'
~/locale
'
;
import
{
n__
,
sprintf
}
from
'
~/locale
'
;
import
UserAvatarList
from
'
~/vue_shared/components/user_avatar/user_avatar_list.vue
'
;
import
UserAvatarList
from
'
~/vue_shared/components/user_avatar/user_avatar_list.vue
'
;
import
glFeatureFlagsMixin
from
'
~/vue_shared/mixins/gl_feature_flags_mixin
'
;
import
glFeatureFlagsMixin
from
'
~/vue_shared/mixins/gl_feature_flags_mixin
'
;
import
{
RULE_TYPE_ANY_APPROVER
,
RULE_TYPE_REGULAR
}
from
'
../../constants
'
;
import
{
RULE_TYPE_EXTERNAL_APPROVAL
,
RULE_TYPE_ANY_APPROVER
,
RULE_TYPE_REGULAR
,
}
from
'
../../constants
'
;
import
ApprovalGateIcon
from
'
../approval_gate_icon.vue
'
;
import
EmptyRule
from
'
../empty_rule.vue
'
;
import
EmptyRule
from
'
../empty_rule.vue
'
;
import
RuleInput
from
'
../mr_edit/rule_input.vue
'
;
import
RuleInput
from
'
../mr_edit/rule_input.vue
'
;
import
RuleBranches
from
'
../rule_branches.vue
'
;
import
RuleBranches
from
'
../rule_branches.vue
'
;
...
@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se
...
@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se
export
default
{
export
default
{
components
:
{
components
:
{
ApprovalGateIcon
,
RuleControls
,
RuleControls
,
Rules
,
Rules
,
UserAvatarList
,
UserAvatarList
,
...
@@ -95,6 +101,9 @@ export default {
...
@@ -95,6 +101,9 @@ export default {
return
canEdit
&&
(
!
allowMultiRule
||
!
rule
.
hasSource
);
return
canEdit
&&
(
!
allowMultiRule
||
!
rule
.
hasSource
);
},
},
isExternalApprovalRule
({
ruleType
})
{
return
ruleType
===
RULE_TYPE_EXTERNAL_APPROVAL
;
},
},
},
};
};
</
script
>
</
script
>
...
@@ -132,13 +141,14 @@ export default {
...
@@ -132,13 +141,14 @@ export default {
class=
"js-members"
class=
"js-members"
:class=
"settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
:class=
"settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
>
>
<user-avatar-list
:items=
"rule.approvers"
:img-size=
"24"
empty-text=
""
/>
<approval-gate-icon
v-if=
"isExternalApprovalRule(rule)"
:url=
"rule.externalUrl"
/>
<user-avatar-list
v-else
:items=
"rule.approvers"
:img-size=
"24"
empty-text=
""
/>
</td>
</td>
<td
v-if=
"settings.allowMultiRule"
class=
"js-branches"
>
<td
v-if=
"settings.allowMultiRule"
class=
"js-branches"
>
<rule-branches
:rule=
"rule"
/>
<rule-branches
:rule=
"rule"
/>
</td>
</td>
<td
class=
"js-approvals-required"
>
<td
class=
"js-approvals-required"
>
<rule-input
:rule=
"rule"
/>
<rule-input
v-if=
"!isExternalApprovalRule(rule)"
:rule=
"rule"
/>
</td>
</td>
<td
class=
"text-nowrap px-2 w-0 js-controls"
>
<td
class=
"text-nowrap px-2 w-0 js-controls"
>
<rule-controls
v-if=
"canEdit(rule)"
:rule=
"rule"
/>
<rule-controls
v-if=
"canEdit(rule)"
:rule=
"rule"
/>
...
...
ee/app/assets/javascripts/approvals/components/rule_form.vue
View file @
9b03a623
<
script
>
<
script
>
import
{
groupBy
,
isNumber
}
from
'
lodash
'
;
import
{
groupBy
,
isNumber
}
from
'
lodash
'
;
import
{
mapState
,
mapActions
}
from
'
vuex
'
;
import
{
mapState
,
mapActions
}
from
'
vuex
'
;
import
{
sprintf
,
__
}
from
'
~/locale
'
;
import
{
isSafeURL
}
from
'
~/lib/utils/url_utility
'
;
import
{
TYPE_USER
,
TYPE_GROUP
,
TYPE_HIDDEN_GROUPS
}
from
'
../constants
'
;
import
{
sprintf
,
__
,
s__
}
from
'
~/locale
'
;
import
glFeatureFlagsMixin
from
'
~/vue_shared/mixins/gl_feature_flags_mixin
'
;
import
{
TYPE_USER
,
TYPE_GROUP
,
TYPE_HIDDEN_GROUPS
,
RULE_TYPE_EXTERNAL_APPROVAL
,
RULE_TYPE_USER_OR_GROUP_APPROVER
,
}
from
'
../constants
'
;
import
ApproverTypeSelect
from
'
./approver_type_select.vue
'
;
import
ApproversList
from
'
./approvers_list.vue
'
;
import
ApproversList
from
'
./approvers_list.vue
'
;
import
ApproversSelect
from
'
./approvers_select.vue
'
;
import
ApproversSelect
from
'
./approvers_select.vue
'
;
import
BranchesSelect
from
'
./branches_select.vue
'
;
import
BranchesSelect
from
'
./branches_select.vue
'
;
...
@@ -21,7 +30,9 @@ export default {
...
@@ -21,7 +30,9 @@ export default {
ApproversList
,
ApproversList
,
ApproversSelect
,
ApproversSelect
,
BranchesSelect
,
BranchesSelect
,
ApproverTypeSelect
,
},
},
mixins
:
[
glFeatureFlagsMixin
()],
props
:
{
props
:
{
initRule
:
{
initRule
:
{
type
:
Object
,
type
:
Object
,
...
@@ -44,6 +55,7 @@ export default {
...
@@ -44,6 +55,7 @@ export default {
name
:
this
.
defaultRuleName
,
name
:
this
.
defaultRuleName
,
approvalsRequired
:
1
,
approvalsRequired
:
1
,
minApprovalsRequired
:
0
,
minApprovalsRequired
:
0
,
externalUrl
:
null
,
approvers
:
[],
approvers
:
[],
approversToAdd
:
[],
approversToAdd
:
[],
branches
:
[],
branches
:
[],
...
@@ -52,6 +64,7 @@ export default {
...
@@ -52,6 +64,7 @@ export default {
isFallback
:
false
,
isFallback
:
false
,
containsHiddenGroups
:
false
,
containsHiddenGroups
:
false
,
serverValidationErrors
:
[],
serverValidationErrors
:
[],
ruleType
:
null
,
...
this
.
getInitialData
(),
...
this
.
getInitialData
(),
};
};
...
@@ -59,6 +72,17 @@ export default {
...
@@ -59,6 +72,17 @@ export default {
},
},
computed
:
{
computed
:
{
...
mapState
([
'
settings
'
]),
...
mapState
([
'
settings
'
]),
showApproverTypeSelect
()
{
return
(
this
.
glFeatures
.
ffComplianceApprovalGates
&&
!
this
.
isEditing
&&
!
this
.
isMrEdit
&&
!
READONLY_NAMES
.
includes
(
this
.
name
)
);
},
isExternalApprovalRule
()
{
return
this
.
ruleType
===
RULE_TYPE_EXTERNAL_APPROVAL
;
},
rule
()
{
rule
()
{
// If we are creating a new rule with a suggested approval name
// If we are creating a new rule with a suggested approval name
return
this
.
defaultRuleName
?
null
:
this
.
initRule
;
return
this
.
defaultRuleName
?
null
:
this
.
initRule
;
...
@@ -85,16 +109,32 @@ export default {
...
@@ -85,16 +109,32 @@ export default {
const
invalidObject
=
{
const
invalidObject
=
{
name
:
this
.
invalidName
,
name
:
this
.
invalidName
,
approvalsRequired
:
this
.
invalidApprovalsRequired
,
approvers
:
this
.
invalidApprovers
,
};
};
if
(
!
this
.
isMrEdit
)
{
if
(
!
this
.
isMrEdit
)
{
invalidObject
.
branches
=
this
.
invalidBranches
;
invalidObject
.
branches
=
this
.
invalidBranches
;
}
}
if
(
this
.
isExternalApprovalRule
)
{
invalidObject
.
externalUrl
=
this
.
invalidApprovalGateUrl
;
}
else
{
invalidObject
.
approvers
=
this
.
invalidApprovers
;
invalidObject
.
approvalsRequired
=
this
.
invalidApprovalsRequired
;
}
return
invalidObject
;
return
invalidObject
;
},
},
invalidApprovalGateUrl
()
{
let
error
=
''
;
if
(
this
.
serverValidationErrors
.
includes
(
'
External url has already been taken
'
))
{
error
=
__
(
'
External url has already been taken
'
);
}
else
if
(
!
this
.
externalUrl
||
!
isSafeURL
(
this
.
externalUrl
))
{
error
=
__
(
'
Please provide a valid URL
'
);
}
return
error
;
},
invalidName
()
{
invalidName
()
{
let
error
=
''
;
let
error
=
''
;
...
@@ -175,9 +215,24 @@ export default {
...
@@ -175,9 +215,24 @@ export default {
protectedBranchIds
:
this
.
branches
,
protectedBranchIds
:
this
.
branches
,
};
};
},
},
isEditing
()
{
return
Boolean
(
this
.
initRule
);
},
externalRuleSubmissionData
()
{
const
{
id
,
name
,
protectedBranchIds
}
=
this
.
submissionData
;
return
{
id
,
name
,
protectedBranchIds
,
externalUrl
:
this
.
externalUrl
,
};
},
showProtectedBranch
()
{
showProtectedBranch
()
{
return
!
this
.
isMrEdit
&&
this
.
settings
.
allowMultiRule
;
return
!
this
.
isMrEdit
&&
this
.
settings
.
allowMultiRule
;
},
},
approvalGateLabel
()
{
return
this
.
isEditing
?
this
.
$options
.
i18n
.
approvalGate
:
this
.
$options
.
i18n
.
addApprovalGate
;
},
},
},
watch
:
{
watch
:
{
approversToAdd
(
value
)
{
approversToAdd
(
value
)
{
...
@@ -188,7 +243,15 @@ export default {
...
@@ -188,7 +243,15 @@ export default {
},
},
},
},
methods
:
{
methods
:
{
...
mapActions
([
'
putFallbackRule
'
,
'
postRule
'
,
'
putRule
'
,
'
deleteRule
'
,
'
postRegularRule
'
]),
...
mapActions
([
'
putFallbackRule
'
,
'
putExternalApprovalRule
'
,
'
postExternalApprovalRule
'
,
'
postRule
'
,
'
putRule
'
,
'
deleteRule
'
,
'
postRegularRule
'
,
]),
addSelection
()
{
addSelection
()
{
if
(
!
this
.
approversToAdd
.
length
)
{
if
(
!
this
.
approversToAdd
.
length
)
{
return
;
return
;
...
@@ -219,9 +282,13 @@ export default {
...
@@ -219,9 +282,13 @@ export default {
}
}
submission
.
catch
((
failureResponse
)
=>
{
submission
.
catch
((
failureResponse
)
=>
{
this
.
serverValidationErrors
=
mapServerResponseToValidationErrors
(
if
(
this
.
isExternalApprovalRule
)
{
failureResponse
?.
response
?.
data
?.
message
||
{},
this
.
serverValidationErrors
=
failureResponse
?.
response
?.
data
?.
message
||
[];
);
}
else
{
this
.
serverValidationErrors
=
mapServerResponseToValidationErrors
(
failureResponse
?.
response
?.
data
?.
message
||
{},
);
}
});
});
return
submission
;
return
submission
;
...
@@ -230,12 +297,14 @@ export default {
...
@@ -230,12 +297,14 @@ export default {
* Submit the rule, by either put-ing or post-ing.
* Submit the rule, by either put-ing or post-ing.
*/
*/
submitRule
()
{
submitRule
()
{
if
(
this
.
isExternalApprovalRule
)
{
const
data
=
this
.
externalRuleSubmissionData
;
return
data
.
id
?
this
.
putExternalApprovalRule
(
data
)
:
this
.
postExternalApprovalRule
(
data
);
}
const
data
=
this
.
submissionData
;
const
data
=
this
.
submissionData
;
if
(
!
this
.
settings
.
allowMultiRule
&&
this
.
settings
.
prefix
===
'
mr-edit
'
)
{
if
(
!
this
.
settings
.
allowMultiRule
&&
this
.
settings
.
prefix
===
'
mr-edit
'
)
{
return
data
.
id
?
this
.
putRule
(
data
)
:
this
.
postRegularRule
(
data
);
return
data
.
id
?
this
.
putRule
(
data
)
:
this
.
postRegularRule
(
data
);
}
}
return
data
.
id
?
this
.
putRule
(
data
)
:
this
.
postRule
(
data
);
return
data
.
id
?
this
.
putRule
(
data
)
:
this
.
postRule
(
data
);
},
},
/**
/**
...
@@ -248,7 +317,7 @@ export default {
...
@@ -248,7 +317,7 @@ export default {
* Submit as a single rule. This is determined by the settings.
* Submit as a single rule. This is determined by the settings.
*/
*/
submitSingleRule
()
{
submitSingleRule
()
{
if
(
!
this
.
approvers
.
length
)
{
if
(
!
this
.
approvers
.
length
&&
!
this
.
isExternalApprovalRule
)
{
return
this
.
submitEmptySingleRule
();
return
this
.
submitEmptySingleRule
();
}
}
...
@@ -280,6 +349,16 @@ export default {
...
@@ -280,6 +349,16 @@ export default {
};
};
}
}
if
(
this
.
initRule
.
ruleType
===
RULE_TYPE_EXTERNAL_APPROVAL
)
{
return
{
name
:
this
.
initRule
.
name
||
''
,
externalUrl
:
this
.
initRule
.
externalUrl
,
branches
:
this
.
initRule
.
protectedBranches
?.
map
((
x
)
=>
x
.
id
)
||
[],
ruleType
:
this
.
initRule
.
ruleType
,
approvers
:
[],
};
}
const
{
containsHiddenGroups
=
false
,
removeHiddenGroups
=
false
}
=
this
.
initRule
;
const
{
containsHiddenGroups
=
false
,
removeHiddenGroups
=
false
}
=
this
.
initRule
;
const
users
=
this
.
initRule
.
users
.
map
((
x
)
=>
({
...
x
,
type
:
TYPE_USER
}));
const
users
=
this
.
initRule
.
users
.
map
((
x
)
=>
({
...
x
,
type
:
TYPE_USER
}));
...
@@ -290,6 +369,7 @@ export default {
...
@@ -290,6 +369,7 @@ export default {
name
:
this
.
initRule
.
name
||
''
,
name
:
this
.
initRule
.
name
||
''
,
approvalsRequired
:
this
.
initRule
.
approvalsRequired
||
0
,
approvalsRequired
:
this
.
initRule
.
approvalsRequired
||
0
,
minApprovalsRequired
:
this
.
initRule
.
minApprovalsRequired
||
0
,
minApprovalsRequired
:
this
.
initRule
.
minApprovalsRequired
||
0
,
ruleType
:
this
.
initRule
.
ruleType
,
containsHiddenGroups
,
containsHiddenGroups
,
approvers
:
groups
approvers
:
groups
.
concat
(
users
)
.
concat
(
users
)
...
@@ -300,6 +380,14 @@ export default {
...
@@ -300,6 +380,14 @@ export default {
};
};
},
},
},
},
i18n
:
{
approvalGate
:
s__
(
'
ApprovalRule|Approvel gate
'
),
addApprovalGate
:
s__
(
'
ApprovalRule|Add approvel gate
'
),
},
approverTypeOptions
:
[
{
type
:
RULE_TYPE_USER_OR_GROUP_APPROVER
,
text
:
s__
(
'
ApprovalRule|Users or groups
'
)
},
{
type
:
RULE_TYPE_EXTERNAL_APPROVAL
,
text
:
s__
(
'
ApprovalRule|Approval service API
'
)
},
],
};
};
</
script
>
</
script
>
...
@@ -334,7 +422,14 @@ export default {
...
@@ -334,7 +422,14 @@ export default {
{{
__
(
'
Apply this approval rule to any branch or a specific protected branch.
'
)
}}
{{
__
(
'
Apply this approval rule to any branch or a specific protected branch.
'
)
}}
</small>
</small>
</div>
</div>
<div
class=
"form-group gl-form-group"
>
<div
v-if=
"showApproverTypeSelect"
class=
"form-group gl-form-group"
>
<label
class=
"col-form-label"
>
{{
s__
(
'
ApprovalRule|Approver Type
'
)
}}
</label>
<approver-type-select
v-model=
"ruleType"
:approver-type-options=
"$options.approverTypeOptions"
/>
</div>
<div
v-if=
"!isExternalApprovalRule"
class=
"form-group gl-form-group"
>
<label
class=
"col-form-label"
>
{{
s__
(
'
ApprovalRule|Approvals required
'
)
}}
</label>
<label
class=
"col-form-label"
>
{{
s__
(
'
ApprovalRule|Approvals required
'
)
}}
</label>
<input
<input
v-model.number=
"approvalsRequired"
v-model.number=
"approvalsRequired"
...
@@ -347,7 +442,7 @@ export default {
...
@@ -347,7 +442,7 @@ export default {
/>
/>
<span
class=
"invalid-feedback"
>
{{
validation
.
approvalsRequired
}}
</span>
<span
class=
"invalid-feedback"
>
{{
validation
.
approvalsRequired
}}
</span>
</div>
</div>
<div
class=
"form-group gl-form-group"
>
<div
v-if=
"!isExternalApprovalRule"
class=
"form-group gl-form-group"
>
<label
class=
"col-form-label"
>
{{
s__
(
'
ApprovalRule|Add approvers
'
)
}}
</label>
<label
class=
"col-form-label"
>
{{
s__
(
'
ApprovalRule|Add approvers
'
)
}}
</label>
<approvers-select
<approvers-select
v-model=
"approversToAdd"
v-model=
"approversToAdd"
...
@@ -359,7 +454,22 @@ export default {
...
@@ -359,7 +454,22 @@ export default {
/>
/>
<span
class=
"invalid-feedback"
>
{{
validation
.
approvers
}}
</span>
<span
class=
"invalid-feedback"
>
{{
validation
.
approvers
}}
</span>
</div>
</div>
<div
class=
"bordered-box overflow-auto h-12em"
>
<div
v-if=
"isExternalApprovalRule"
class=
"form-group gl-form-group"
>
<label
class=
"col-form-label"
>
{{
approvalGateLabel
}}
</label>
<input
v-model=
"externalUrl"
:class=
"
{ 'is-invalid': validation.externalUrl }"
class="gl-form-input form-control"
name="approval_gate_url"
type="url"
data-qa-selector="external_url_field"
/>
<span
class=
"invalid-feedback"
>
{{
validation
.
externalUrl
}}
</span>
<small
class=
"form-text text-gl-muted"
>
{{
s__
(
'
ApprovalRule|Invoke an external API as part of the approvals
'
)
}}
</small>
</div>
<div
v-if=
"!isExternalApprovalRule"
class=
"bordered-box overflow-auto h-12em"
>
<approvers-list
v-model=
"approvers"
/>
<approvers-list
v-model=
"approvers"
/>
</div>
</div>
</form>
</form>
...
...
ee/app/assets/javascripts/approvals/constants.js
View file @
9b03a623
...
@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner';
...
@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner';
export
const
RULE_TYPE_ANY_APPROVER
=
'
any_approver
'
;
export
const
RULE_TYPE_ANY_APPROVER
=
'
any_approver
'
;
export
const
RULE_TYPE_EXTERNAL_APPROVAL
=
'
external_approval
'
;
export
const
RULE_TYPE_EXTERNAL_APPROVAL
=
'
external_approval
'
;
export
const
RULE_NAME_ANY_APPROVER
=
'
All Members
'
;
export
const
RULE_NAME_ANY_APPROVER
=
'
All Members
'
;
export
const
RULE_TYPE_USER_OR_GROUP_APPROVER
=
'
user_or_group
'
;
export
const
VULNERABILITY_CHECK_NAME
=
'
Vulnerability-Check
'
;
export
const
VULNERABILITY_CHECK_NAME
=
'
Vulnerability-Check
'
;
export
const
LICENSE_CHECK_NAME
=
'
License-Check
'
;
export
const
LICENSE_CHECK_NAME
=
'
License-Check
'
;
...
...
ee/app/assets/javascripts/approvals/mappers.js
View file @
9b03a623
...
@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({
...
@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({
});
});
export
const
mapExternalApprovalResponse
=
(
res
)
=>
({
export
const
mapExternalApprovalResponse
=
(
res
)
=>
({
rules
:
withDefaultEmptyRule
(
res
.
map
(
mapExternalApprovalRuleResponse
)
),
rules
:
res
.
map
(
mapExternalApprovalRuleResponse
),
});
});
export
const
mapApprovalSettingsResponse
=
(
res
)
=>
({
export
const
mapApprovalSettingsResponse
=
(
res
)
=>
({
...
...
ee/app/controllers/ee/projects_controller.rb
View file @
9b03a623
...
@@ -10,6 +10,10 @@ module EE
...
@@ -10,6 +10,10 @@ module EE
before_action
:log_archive_audit_event
,
only:
[
:archive
]
before_action
:log_archive_audit_event
,
only:
[
:archive
]
before_action
:log_unarchive_audit_event
,
only:
[
:unarchive
]
before_action
:log_unarchive_audit_event
,
only:
[
:unarchive
]
before_action
only: :edit
do
push_frontend_feature_flag
(
:ff_compliance_approval_gates
,
project
,
default_enabled: :yaml
)
end
before_action
only: :show
do
before_action
only: :show
do
push_frontend_feature_flag
(
:cve_id_request_button
,
project
)
push_frontend_feature_flag
(
:cve_id_request_button
,
project
)
end
end
...
...
ee/spec/features/projects/settings/merge_requests_settings_spec.rb
View file @
9b03a623
...
@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
...
@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
include
GitlabRoutingHelper
include
GitlabRoutingHelper
include
FeatureApprovalHelper
include
FeatureApprovalHelper
let
(
:user
)
{
create
(
:user
)
}
let
_it_be
(
:user
)
{
create
(
:user
)
}
let
(
:project
)
{
create
(
:project
)
}
let
_it_be
(
:project
)
{
create
(
:project
)
}
let
(
:group
)
{
create
(
:group
)
}
let
_it_be
(
:group
)
{
create
(
:group
)
}
let
(
:group_member
)
{
create
(
:user
)
}
let
_it_be
(
:group_member
)
{
create
(
:user
)
}
let
(
:non_member
)
{
create
(
:user
)
}
let
_it_be
(
:non_member
)
{
create
(
:user
)
}
let
!
(
:config_selector
)
{
'.js-approval-rules'
}
let
_it_be
(
:config_selector
)
{
'.js-approval-rules'
}
let
!
(
:modal_selector
)
{
'#project-settings-approvals-create-modal'
}
let
_it_be
(
:modal_selector
)
{
'#project-settings-approvals-create-modal'
}
before
do
before
do
stub_licensed_features
(
compliance_approval_gates:
true
)
sign_in
(
user
)
sign_in
(
user
)
project
.
add_maintainer
(
user
)
project
.
add_maintainer
(
user
)
group
.
add_developer
(
user
)
group
.
add_developer
(
user
)
...
@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
...
@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end
end
context
'with an approver group'
do
context
'with an approver group'
do
let
(
:non_group_approver
)
{
create
(
:user
)
}
let
_it_be
(
:non_group_approver
)
{
create
(
:user
)
}
let
!
(
:rule
)
{
create
(
:approval_project_rule
,
project:
project
,
groups:
[
group
],
users:
[
non_group_approver
])
}
let
_it_be
(
:rule
)
{
create
(
:approval_project_rule
,
project:
project
,
groups:
[
group
],
users:
[
non_group_approver
])
}
before
do
before
do
project
.
add_developer
(
non_group_approver
)
project
.
add_developer
(
non_group_approver
)
...
@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
...
@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end
end
end
end
it
'adds an approval gate'
do
visit
edit_project_path
(
project
)
open_modal
(
text:
'Add approval rule'
,
expand:
false
)
within
(
'.modal-content'
)
do
find
(
'button'
,
text:
"Users or groups"
).
click
find
(
'button'
,
text:
"Approval service API"
).
click
find
(
'[data-qa-selector="rule_name_field"]'
).
set
(
'My new rule'
)
find
(
'[data-qa-selector="external_url_field"]'
).
set
(
'https://api.gitlab.com'
)
click_button
'Add approval rule'
end
wait_for_requests
expect
(
first
(
'.js-name'
)).
to
have_content
(
'My new rule'
)
end
context
'with an approval gate'
do
let_it_be
(
:rule
)
{
create
(
:external_approval_rule
,
project:
project
)
}
it
'updates the approval gate'
do
visit
edit_project_path
(
project
)
expect
(
first
(
'.js-name'
)).
to
have_content
(
rule
.
name
)
open_modal
(
text:
'Edit'
,
expand:
false
)
within
(
'.modal-content'
)
do
find
(
'[data-qa-selector="rule_name_field"]'
).
set
(
'Something new'
)
click_button
'Update approval rule'
end
wait_for_requests
expect
(
first
(
'.js-name'
)).
to
have_content
(
'Something new'
)
end
it
'removes the approval gate'
do
visit
edit_project_path
(
project
)
expect
(
first
(
'.js-name'
)).
to
have_content
(
rule
.
name
)
first
(
'.js-controls'
).
find
(
'[data-testid="remove-icon"]'
).
click
within
(
'.modal-content'
)
do
click_button
'Remove approval gate'
end
wait_for_requests
expect
(
first
(
'.js-name'
)).
not_to
have_content
(
rule
.
name
)
end
end
context
'issuable default templates feature not available'
do
context
'issuable default templates feature not available'
do
before
do
before
do
stub_licensed_features
(
issuable_default_templates:
false
)
stub_licensed_features
(
issuable_default_templates:
false
)
...
...
ee/spec/frontend/approvals/components/__snapshots__/modal_rule_remove_spec.js.snap
View file @
9b03a623
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Approvals ModalRuleRemove shows message 1`] = `
exports[`Approvals ModalRuleRemove matches the snapshot for external approval 1`] = `
<div
title="Remove approval gate?"
>
<p>
You are about to remove the
<strong>
API Gate
</strong>
approval gate. Approval from this service is not revoked.
</p>
</div>
`;
exports[`Approvals ModalRuleRemove matches the snapshot for multiple approvers 1`] = `
<div
<div
title="Remove approvers?"
title="Remove approvers?"
>
>
...
@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = `
...
@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = `
</div>
</div>
`;
`;
exports[`Approvals ModalRuleRemove
shows singular message
1`] = `
exports[`Approvals ModalRuleRemove
matches the snapshot for singular approver
1`] = `
<div
<div
title="Remove approvers?"
title="Remove approvers?"
>
>
...
...
ee/spec/frontend/approvals/components/approval_gate_icon_spec.js
0 → 100644
View file @
9b03a623
import
{
GlPopover
,
GlIcon
}
from
'
@gitlab/ui
'
;
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
ApprovalGateIcon
from
'
ee/approvals/components/approval_gate_icon.vue
'
;
jest
.
mock
(
'
lodash/uniqueId
'
,
()
=>
(
id
)
=>
`
${
id
}
mock`
);
describe
(
'
ApprovalGateIcon
'
,
()
=>
{
let
wrapper
;
const
findPopover
=
()
=>
wrapper
.
findComponent
(
GlPopover
);
const
findIcon
=
()
=>
wrapper
.
findComponent
(
GlIcon
);
const
createComponent
=
()
=>
{
return
shallowMount
(
ApprovalGateIcon
,
{
propsData
:
{
url
:
'
https://gitlab.com/
'
,
},
});
};
afterEach
(()
=>
{
wrapper
.
destroy
();
});
beforeEach
(()
=>
{
wrapper
=
createComponent
();
});
it
(
'
renders the icon
'
,
()
=>
{
expect
(
findIcon
().
props
(
'
name
'
)).
toBe
(
'
api
'
);
expect
(
findIcon
().
attributes
(
'
id
'
)).
toBe
(
'
approval-icon-mock
'
);
});
it
(
'
renders the popover with the URL for the icon
'
,
()
=>
{
expect
(
findPopover
().
exists
()).
toBe
(
true
);
expect
(
findPopover
().
attributes
()).
toMatchObject
({
content
:
'
https://gitlab.com/
'
,
title
:
'
Approval Gate
'
,
target
:
'
approval-icon-mock
'
,
});
});
});
ee/spec/frontend/approvals/components/approver_type_select_spec.js
0 → 100644
View file @
9b03a623
import
{
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
nextTick
}
from
'
vue
'
;
import
ApprovalTypeSelect
from
'
ee/approvals/components/approver_type_select.vue
'
;
jest
.
mock
(
'
lodash/uniqueId
'
,
()
=>
(
id
)
=>
`
${
id
}
mock`
);
const
OPTIONS
=
[
{
type
:
'
x
'
,
text
:
'
foo
'
},
{
type
:
'
y
'
,
text
:
'
bar
'
},
];
describe
(
'
ApprovalTypeSelect
'
,
()
=>
{
let
wrapper
;
const
findDropdown
=
()
=>
wrapper
.
findComponent
(
GlDropdown
);
const
findDropdownItems
=
()
=>
wrapper
.
findAll
(
GlDropdownItem
);
const
createComponent
=
()
=>
{
return
shallowMount
(
ApprovalTypeSelect
,
{
propsData
:
{
approverTypeOptions
:
OPTIONS
,
},
});
};
afterEach
(()
=>
{
wrapper
.
destroy
();
});
beforeEach
(()
=>
{
wrapper
=
createComponent
();
});
it
(
'
should select the first option by default
'
,
()
=>
{
expect
(
findDropdownItems
().
at
(
0
).
props
(
'
isChecked
'
)).
toBe
(
true
);
});
it
(
'
renders the dropdown with the selected text
'
,
()
=>
{
expect
(
findDropdown
().
props
(
'
text
'
)).
toBe
(
OPTIONS
[
0
].
text
);
});
it
(
'
renders a dropdown item for each option
'
,
()
=>
{
OPTIONS
.
forEach
((
option
,
idx
)
=>
{
expect
(
findDropdownItems
().
at
(
idx
).
text
()).
toBe
(
option
.
text
);
});
});
it
(
'
should select an item when clicked
'
,
async
()
=>
{
const
item
=
findDropdownItems
().
at
(
1
);
expect
(
item
.
props
(
'
isChecked
'
)).
toBe
(
false
);
item
.
vm
.
$emit
(
'
click
'
);
await
nextTick
();
expect
(
item
.
props
(
'
isChecked
'
)).
toBe
(
true
);
});
});
ee/spec/frontend/approvals/components/modal_rule_remove_spec.js
View file @
9b03a623
...
@@ -4,6 +4,7 @@ import Vuex from 'vuex';
...
@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import
ModalRuleRemove
from
'
ee/approvals/components/modal_rule_remove.vue
'
;
import
ModalRuleRemove
from
'
ee/approvals/components/modal_rule_remove.vue
'
;
import
{
stubComponent
}
from
'
helpers/stub_component
'
;
import
{
stubComponent
}
from
'
helpers/stub_component
'
;
import
GlModalVuex
from
'
~/vue_shared/components/gl_modal_vuex.vue
'
;
import
GlModalVuex
from
'
~/vue_shared/components/gl_modal_vuex.vue
'
;
import
{
createExternalRule
}
from
'
../mocks
'
;
const
MODAL_MODULE
=
'
deleteModal
'
;
const
MODAL_MODULE
=
'
deleteModal
'
;
const
TEST_MODAL_ID
=
'
test-delete-modal-id
'
;
const
TEST_MODAL_ID
=
'
test-delete-modal-id
'
;
...
@@ -14,6 +15,11 @@ const TEST_RULE = {
...
@@ -14,6 +15,11 @@ const TEST_RULE = {
.
fill
(
1
)
.
fill
(
1
)
.
map
((
x
,
id
)
=>
({
id
})),
.
map
((
x
,
id
)
=>
({
id
})),
};
};
const
SINGLE_APPROVER
=
{
...
TEST_RULE
,
approvers
:
[{
id
:
1
}],
};
const
EXTERNAL_RULE
=
createExternalRule
();
const
localVue
=
createLocalVue
();
const
localVue
=
createLocalVue
();
localVue
.
use
(
Vuex
);
localVue
.
use
(
Vuex
);
...
@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => {
...
@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => {
};
};
actions
=
{
actions
=
{
deleteRule
:
jest
.
fn
(),
deleteRule
:
jest
.
fn
(),
deleteExternalApprovalRule
:
jest
.
fn
(),
};
};
});
});
...
@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => {
...
@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => {
);
);
});
});
it
(
'
shows message
'
,
()
=>
{
it
.
each
`
factory
();
type | rule
${
'
multiple approvers
'
}
|
${
TEST_RULE
}
expect
(
findModal
().
element
).
toMatchSnapshot
();
${
'
singular approver
'
}
|
${
SINGLE_APPROVER
}
});
${
'
external approval
'
}
|
${
EXTERNAL_RULE
}
`
(
'
matches the snapshot for $type
'
,
({
rule
})
=>
{
it
(
'
shows singular message
'
,
()
=>
{
deleteModalState
.
data
=
rule
;
deleteModalState
.
data
=
{
...
TEST_RULE
,
approvers
:
[{
id
:
1
}],
};
factory
();
factory
();
expect
(
findModal
().
element
).
toMatchSnapshot
();
expect
(
findModal
().
element
).
toMatchSnapshot
();
});
});
it
(
'
deletes rule when modal is submitted
'
,
()
=>
{
it
.
each
`
typeType | action | rule
${
'
regular
'
}
|
${
'
deleteRule
'
}
|
${
TEST_RULE
}
${
'
external
'
}
|
${
'
deleteExternalApprovalRule
'
}
|
${
EXTERNAL_RULE
}
`
(
'
calls $action when the modal is submitted for a $typeType rule
'
,
({
action
,
rule
})
=>
{
deleteModalState
.
data
=
rule
;
factory
();
factory
();
expect
(
actions
.
deleteRule
).
not
.
toHaveBeenCalled
();
expect
(
actions
[
action
]
).
not
.
toHaveBeenCalled
();
const
modal
=
findModal
();
const
modal
=
findModal
();
modal
.
vm
.
$emit
(
'
ok
'
,
new
Event
(
'
submit
'
));
modal
.
vm
.
$emit
(
'
ok
'
,
new
Event
(
'
submit
'
));
expect
(
actions
.
deleteRule
).
toHaveBeenCalledWith
(
expect
.
anything
(),
TEST_RULE
.
id
);
expect
(
actions
[
action
]).
toHaveBeenCalledWith
(
expect
.
anything
(),
rule
.
id
);
});
});
});
});
ee/spec/frontend/approvals/components/project_settings/project_rules_spec.js
View file @
9b03a623
import
{
mount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
{
mount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
Vuex
from
'
vuex
'
;
import
Vuex
from
'
vuex
'
;
import
ApprovalGateIcon
from
'
ee/approvals/components/approval_gate_icon.vue
'
;
import
RuleInput
from
'
ee/approvals/components/mr_edit/rule_input.vue
'
;
import
RuleInput
from
'
ee/approvals/components/mr_edit/rule_input.vue
'
;
import
ProjectRules
from
'
ee/approvals/components/project_settings/project_rules.vue
'
;
import
ProjectRules
from
'
ee/approvals/components/project_settings/project_rules.vue
'
;
import
RuleName
from
'
ee/approvals/components/rule_name.vue
'
;
import
RuleName
from
'
ee/approvals/components/rule_name.vue
'
;
...
@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur
...
@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur
import
{
createStoreOptions
}
from
'
ee/approvals/stores
'
;
import
{
createStoreOptions
}
from
'
ee/approvals/stores
'
;
import
projectSettingsModule
from
'
ee/approvals/stores/modules/project_settings
'
;
import
projectSettingsModule
from
'
ee/approvals/stores/modules/project_settings
'
;
import
UserAvatarList
from
'
~/vue_shared/components/user_avatar/user_avatar_list.vue
'
;
import
UserAvatarList
from
'
~/vue_shared/components/user_avatar/user_avatar_list.vue
'
;
import
{
createProjectRules
}
from
'
../../mocks
'
;
import
{
createProjectRules
,
createExternalRule
}
from
'
../../mocks
'
;
const
TEST_RULES
=
createProjectRules
();
const
TEST_RULES
=
createProjectRules
();
...
@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => {
...
@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => {
expect
(
wrapper
.
find
(
UnconfiguredSecurityRules
).
exists
()).
toBe
(
true
);
expect
(
wrapper
.
find
(
UnconfiguredSecurityRules
).
exists
()).
toBe
(
true
);
});
});
});
});
describe
(
'
when the rule is external
'
,
()
=>
{
const
rule
=
createExternalRule
();
beforeEach
(()
=>
{
store
.
modules
.
approvals
.
state
.
rules
=
[
rule
];
factory
();
});
it
(
'
renders the approval gate component with URL
'
,
()
=>
{
expect
(
wrapper
.
findComponent
(
ApprovalGateIcon
).
props
(
'
url
'
)).
toBe
(
rule
.
externalUrl
);
});
it
(
'
does not render a user avatar component
'
,
()
=>
{
expect
(
wrapper
.
findComponent
(
UserAvatarList
).
exists
()).
toBe
(
false
);
});
it
(
'
does not render the approvals required input
'
,
()
=>
{
expect
(
wrapper
.
findComponent
(
RuleInput
).
exists
()).
toBe
(
false
);
});
});
});
});
ee/spec/frontend/approvals/components/rule_form_spec.js
View file @
9b03a623
import
{
shallowMount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
{
shallowMount
,
createLocalVue
}
from
'
@vue/test-utils
'
;
import
{
nextTick
}
from
'
vue
'
;
import
Vuex
from
'
vuex
'
;
import
Vuex
from
'
vuex
'
;
import
ApproverTypeSelect
from
'
ee/approvals/components/approver_type_select.vue
'
;
import
ApproversList
from
'
ee/approvals/components/approvers_list.vue
'
;
import
ApproversList
from
'
ee/approvals/components/approvers_list.vue
'
;
import
ApproversSelect
from
'
ee/approvals/components/approvers_select.vue
'
;
import
ApproversSelect
from
'
ee/approvals/components/approvers_select.vue
'
;
import
BranchesSelect
from
'
ee/approvals/components/branches_select.vue
'
;
import
BranchesSelect
from
'
ee/approvals/components/branches_select.vue
'
;
import
RuleForm
from
'
ee/approvals/components/rule_form.vue
'
;
import
RuleForm
from
'
ee/approvals/components/rule_form.vue
'
;
import
{
TYPE_USER
,
TYPE_GROUP
,
TYPE_HIDDEN_GROUPS
}
from
'
ee/approvals/constants
'
;
import
{
TYPE_USER
,
TYPE_GROUP
,
TYPE_HIDDEN_GROUPS
,
RULE_TYPE_EXTERNAL_APPROVAL
,
}
from
'
ee/approvals/constants
'
;
import
{
createStoreOptions
}
from
'
ee/approvals/stores
'
;
import
{
createStoreOptions
}
from
'
ee/approvals/stores
'
;
import
projectSettingsModule
from
'
ee/approvals/stores/modules/project_settings
'
;
import
projectSettingsModule
from
'
ee/approvals/stores/modules/project_settings
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
{
createExternalRule
}
from
'
../mocks
'
;
const
TEST_PROJECT_ID
=
'
7
'
;
const
TEST_PROJECT_ID
=
'
7
'
;
const
TEST_RULE
=
{
const
TEST_RULE
=
{
...
@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = {
...
@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = {
approvalsRequired
:
1
,
approvalsRequired
:
1
,
isFallback
:
true
,
isFallback
:
true
,
};
};
const
TEST_EXTERNAL_APPROVAL_RULE
=
{
...
createExternalRule
(),
protectedBranches
:
TEST_PROTECTED_BRANCHES
,
};
const
TEST_LOCKED_RULE_NAME
=
'
LOCKED_RULE
'
;
const
TEST_LOCKED_RULE_NAME
=
'
LOCKED_RULE
'
;
const
nameTakenError
=
{
const
nameTakenError
=
{
response
:
{
response
:
{
...
@@ -37,6 +50,13 @@ const nameTakenError = {
...
@@ -37,6 +50,13 @@ const nameTakenError = {
},
},
},
},
};
};
const
urlTakenError
=
{
response
:
{
data
:
{
message
:
[
'
External url has already been taken
'
],
},
},
};
const
localVue
=
createLocalVue
();
const
localVue
=
createLocalVue
();
localVue
.
use
(
Vuex
);
localVue
.
use
(
Vuex
);
...
@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => {
...
@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => {
store
:
new
Vuex
.
Store
(
store
),
store
:
new
Vuex
.
Store
(
store
),
localVue
,
localVue
,
provide
:
{
provide
:
{
glFeatures
:
{
scopedApprovalRules
:
true
,
...
options
.
provide
?.
glFeatures
},
glFeatures
:
{
ffComplianceApprovalGates
:
true
,
scopedApprovalRules
:
true
,
...
options
.
provide
?.
glFeatures
,
},
},
},
});
});
};
};
...
@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => {
...
@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => {
const
findApproversValidation
=
()
=>
findValidation
(
findApproversSelect
(),
true
);
const
findApproversValidation
=
()
=>
findValidation
(
findApproversSelect
(),
true
);
const
findApproversList
=
()
=>
wrapper
.
find
(
ApproversList
);
const
findApproversList
=
()
=>
wrapper
.
find
(
ApproversList
);
const
findBranchesSelect
=
()
=>
wrapper
.
find
(
BranchesSelect
);
const
findBranchesSelect
=
()
=>
wrapper
.
find
(
BranchesSelect
);
const
findApproverTypeSelect
=
()
=>
wrapper
.
findComponent
(
ApproverTypeSelect
);
const
findExternalUrlInput
=
()
=>
wrapper
.
find
(
'
input[name=approval_gate_url
'
);
const
findExternalUrlValidation
=
()
=>
findValidation
(
findExternalUrlInput
(),
false
);
const
findBranchesValidation
=
()
=>
findValidation
(
findBranchesSelect
(),
true
);
const
findBranchesValidation
=
()
=>
findValidation
(
findBranchesSelect
(),
true
);
const
findValidations
=
()
=>
[
const
findValidations
=
()
=>
[
findNameValidation
(),
findNameValidation
(),
...
@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => {
...
@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => {
findBranchesValidation
(),
findBranchesValidation
(),
];
];
const
findValidationForExternal
=
()
=>
[
findNameValidation
(),
findExternalUrlValidation
(),
findBranchesValidation
(),
];
beforeEach
(()
=>
{
beforeEach
(()
=>
{
store
=
createStoreOptions
(
projectSettingsModule
(),
{
projectId
:
TEST_PROJECT_ID
});
store
=
createStoreOptions
(
projectSettingsModule
(),
{
projectId
:
TEST_PROJECT_ID
});
[
'
postRule
'
,
'
putRule
'
,
'
deleteRule
'
,
'
putFallbackRule
'
].
forEach
((
actionName
)
=>
{
[
'
postRule
'
,
'
putRule
'
,
'
deleteRule
'
,
'
putFallbackRule
'
,
'
postExternalApprovalRule
'
].
forEach
(
jest
.
spyOn
(
store
.
modules
.
approvals
.
actions
,
actionName
).
mockImplementation
(()
=>
{});
(
actionName
)
=>
{
});
jest
.
spyOn
(
store
.
modules
.
approvals
.
actions
,
actionName
).
mockImplementation
(()
=>
{});
},
);
({
actions
}
=
store
.
modules
.
approvals
);
({
actions
}
=
store
.
modules
.
approvals
);
});
});
...
@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => {
...
@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => {
});
});
});
});
describe
(
'
when the rule is an external rule
'
,
()
=>
{
describe
(
'
with initial rule
'
,
()
=>
{
beforeEach
(()
=>
{
createComponent
({
isMrEdit
:
false
,
initRule
:
TEST_EXTERNAL_APPROVAL_RULE
,
});
});
it
(
'
does not render the approver type select input
'
,
()
=>
{
expect
(
findApproverTypeSelect
().
exists
()).
toBe
(
false
);
});
it
(
'
on load, it populates the external URL
'
,
()
=>
{
expect
(
findExternalUrlInput
().
element
.
value
).
toBe
(
TEST_EXTERNAL_APPROVAL_RULE
.
externalUrl
,
);
});
});
describe
(
'
without an initial rule
'
,
()
=>
{
beforeEach
(()
=>
{
createComponent
({
isMrEdit
:
false
,
});
findApproverTypeSelect
().
vm
.
$emit
(
'
input
'
,
RULE_TYPE_EXTERNAL_APPROVAL
);
});
it
(
'
renders the approver type select input
'
,
()
=>
{
expect
(
findApproverTypeSelect
().
exists
()).
toBe
(
true
);
});
it
(
'
renders the inputs for external rules
'
,
()
=>
{
expect
(
findNameInput
().
exists
()).
toBe
(
true
);
expect
(
findExternalUrlInput
().
exists
()).
toBe
(
true
);
expect
(
findBranchesSelect
().
exists
()).
toBe
(
true
);
});
it
(
'
does not render the user and group input fields
'
,
()
=>
{
expect
(
findApprovalsRequiredInput
().
exists
()).
toBe
(
false
);
expect
(
findApproversList
().
exists
()).
toBe
(
false
);
expect
(
findApproversSelect
().
exists
()).
toBe
(
false
);
});
it
(
'
at first, shows no validation
'
,
()
=>
{
const
inputs
=
findValidationForExternal
();
const
invalidInputs
=
inputs
.
filter
((
x
)
=>
!
x
.
isValid
);
const
feedbacks
=
inputs
.
map
((
x
)
=>
x
.
feedback
);
expect
(
invalidInputs
.
length
).
toBe
(
0
);
expect
(
feedbacks
.
every
((
str
)
=>
!
str
.
length
)).
toBe
(
true
);
});
it
(
'
on submit, does not dispatch action
'
,
()
=>
{
wrapper
.
vm
.
submit
();
expect
(
actions
.
postExternalApprovalRule
).
not
.
toHaveBeenCalled
();
});
it
(
'
on submit, shows name validation
'
,
async
()
=>
{
findExternalUrlInput
().
setValue
(
''
);
wrapper
.
vm
.
submit
();
await
nextTick
();
expect
(
findExternalUrlValidation
()).
toEqual
({
isValid
:
false
,
feedback
:
'
Please provide a valid URL
'
,
});
});
describe
(
'
with valid data
'
,
()
=>
{
const
branches
=
TEST_PROTECTED_BRANCHES
.
map
((
x
)
=>
x
.
id
);
const
expected
=
{
id
:
null
,
name
:
'
Lorem
'
,
externalUrl
:
'
https://gitlab.com/
'
,
protectedBranchIds
:
branches
,
};
beforeEach
(()
=>
{
findNameInput
().
setValue
(
expected
.
name
);
findExternalUrlInput
().
setValue
(
expected
.
externalUrl
);
wrapper
.
vm
.
branches
=
expected
.
protectedBranchIds
;
});
it
(
'
on submit, posts external approval rule
'
,
()
=>
{
wrapper
.
vm
.
submit
();
expect
(
actions
.
postExternalApprovalRule
).
toHaveBeenCalledWith
(
expect
.
anything
(),
expected
,
);
});
it
(
'
when submitted with a duplicate external URL, shows the "url already taken" validation
'
,
async
()
=>
{
store
.
state
.
settings
.
prefix
=
'
project-settings
'
;
jest
.
spyOn
(
wrapper
.
vm
,
'
postExternalApprovalRule
'
).
mockRejectedValueOnce
(
urlTakenError
);
wrapper
.
vm
.
submit
();
await
waitForPromises
();
expect
(
findExternalUrlValidation
()).
toEqual
({
isValid
:
false
,
feedback
:
'
External url has already been taken
'
,
});
});
});
});
});
describe
(
'
without initRule
'
,
()
=>
{
describe
(
'
without initRule
'
,
()
=>
{
beforeEach
(()
=>
{
beforeEach
(()
=>
{
createComponent
();
createComponent
();
...
@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => {
...
@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => {
describe
(
'
with approval suggestions
'
,
()
=>
{
describe
(
'
with approval suggestions
'
,
()
=>
{
describe
.
each
`
describe
.
each
`
defaultRuleName | expectedDisabledAttribute
defaultRuleName | expectedDisabledAttribute
| approverTypeSelect
${
'
Vulnerability-Check
'
}
|
${
'
disabled
'
}
${
'
Vulnerability-Check
'
}
|
${
'
disabled
'
}
|
${
false
}
${
'
License-Check
'
}
|
${
'
disabled
'
}
${
'
License-Check
'
}
|
${
'
disabled
'
}
|
${
false
}
${
'
Foo Bar Baz
'
}
|
${
undefined
}
${
'
Foo Bar Baz
'
}
|
${
undefined
}
|
${
true
}
`
(
`
(
'
with defaultRuleName set to $defaultRuleName
'
,
'
with defaultRuleName set to $defaultRuleName
'
,
({
defaultRuleName
,
expectedDisabledAttribute
})
=>
{
({
defaultRuleName
,
expectedDisabledAttribute
,
approverTypeSelect
})
=>
{
beforeEach
(()
=>
{
beforeEach
(()
=>
{
createComponent
({
createComponent
({
initRule
:
null
,
initRule
:
null
,
isMrEdit
:
false
,
defaultRuleName
,
defaultRuleName
,
});
});
});
});
...
@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => {
...
@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => {
}
the name text field`
,
()
=>
{
}
the name text field`
,
()
=>
{
expect
(
findNameInput
().
attributes
(
'
disabled
'
)).
toBe
(
expectedDisabledAttribute
);
expect
(
findNameInput
().
attributes
(
'
disabled
'
)).
toBe
(
expectedDisabledAttribute
);
});
});
it
(
`
${
approverTypeSelect
?
'
renders
'
:
'
does not render
'
}
the approver type select`
,
()
=>
{
expect
(
findApproverTypeSelect
().
exists
()).
toBe
(
approverTypeSelect
);
});
},
},
);
);
});
});
...
@@ -727,4 +882,23 @@ describe('EE Approvals RuleForm', () => {
...
@@ -727,4 +882,23 @@ describe('EE Approvals RuleForm', () => {
});
});
});
});
});
});
describe
(
'
when the approval gates feature is disabled
'
,
()
=>
{
it
(
'
does not render the approver type select input
'
,
async
()
=>
{
createComponent
(
{
isMrEdit
:
false
},
{
provide
:
{
glFeatures
:
{
ffComplianceApprovalGates
:
false
,
},
},
},
);
await
nextTick
();
expect
(
findApproverTypeSelect
().
exists
()).
toBe
(
false
);
});
});
});
});
ee/spec/frontend/approvals/mocks.js
View file @
9b03a623
export
const
createExternalRule
=
()
=>
({
id
:
9
,
name
:
'
API Gate
'
,
externalUrl
:
'
https://gitlab.com
'
,
ruleType
:
'
external_approval
'
,
});
export
const
createProjectRules
=
()
=>
[
export
const
createProjectRules
=
()
=>
[
{
{
id
:
1
,
id
:
1
,
...
...
locale/gitlab.pot
View file @
9b03a623
...
@@ -3982,6 +3982,9 @@ msgstr ""
...
@@ -3982,6 +3982,9 @@ msgstr ""
msgid "Applying suggestions..."
msgid "Applying suggestions..."
msgstr ""
msgstr ""
msgid "Approval Gate"
msgstr ""
msgid "Approval Status"
msgid "Approval Status"
msgstr ""
msgstr ""
...
@@ -4004,6 +4007,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
...
@@ -4004,6 +4007,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
msgstr[0] ""
msgstr[0] ""
msgstr[1] ""
msgstr[1] ""
msgid "ApprovalRuleRemove|Remove approval gate"
msgstr ""
msgid "ApprovalRuleRemove|Remove approval gate?"
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked."
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgstr ""
msgstr ""
...
@@ -4017,21 +4029,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun
...
@@ -4017,21 +4029,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun
msgstr[0] ""
msgstr[0] ""
msgstr[1] ""
msgstr[1] ""
msgid "ApprovalRule|Add approvel gate"
msgstr ""
msgid "ApprovalRule|Add approvers"
msgid "ApprovalRule|Add approvers"
msgstr ""
msgstr ""
msgid "ApprovalRule|Approval rules"
msgid "ApprovalRule|Approval rules"
msgstr ""
msgstr ""
msgid "ApprovalRule|Approval service API"
msgstr ""
msgid "ApprovalRule|Approvals required"
msgid "ApprovalRule|Approvals required"
msgstr ""
msgstr ""
msgid "ApprovalRule|Approvel gate"
msgstr ""
msgid "ApprovalRule|Approver Type"
msgstr ""
msgid "ApprovalRule|Approvers"
msgid "ApprovalRule|Approvers"
msgstr ""
msgstr ""
msgid "ApprovalRule|Examples: QA, Security."
msgid "ApprovalRule|Examples: QA, Security."
msgstr ""
msgstr ""
msgid "ApprovalRule|Invoke an external API as part of the approvals"
msgstr ""
msgid "ApprovalRule|Name"
msgid "ApprovalRule|Name"
msgstr ""
msgstr ""
...
@@ -4041,6 +4068,9 @@ msgstr ""
...
@@ -4041,6 +4068,9 @@ msgstr ""
msgid "ApprovalRule|Target branch"
msgid "ApprovalRule|Target branch"
msgstr ""
msgstr ""
msgid "ApprovalRule|Users or groups"
msgstr ""
msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgstr ""
msgstr ""
...
@@ -13013,6 +13043,9 @@ msgstr ""
...
@@ -13013,6 +13043,9 @@ msgstr ""
msgid "External storage authentication token"
msgid "External storage authentication token"
msgstr ""
msgstr ""
msgid "External url has already been taken"
msgstr ""
msgid "ExternalAuthorizationService|Classification label"
msgid "ExternalAuthorizationService|Classification label"
msgstr ""
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