Commit 690fbca8 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into 31162-small-fixes

* master:
  Unit test for collapsible container
  Find namespace by env name instead of slug
  Update issue template to not use CE and EE
  Rename GitLab CE to FOSS in GitHub issue templates
  Rename toggleCreateItemForm to toggleCreateEpicForm
  Resolve "Dependency List is not up-to-date - backend"
  Fix bug that caused a merge to show an error message
  Fixes CSS leaks for job log
  Update gitlab-shell config for HPA and maxReplicas
  Update MR templates to avoid redirect or dead link
  Update badging for restrictions
  Fix stylelint errors in epics.scss
  Automatically run 'schedule:package-and-qa' on .com schedules
parents e3cac350 6bb540cb
We’re closing our issue tracker on GitHub so we can focus on the GitLab.com project and respond to issues more quickly.
We encourage you to open an issue on the [GitLab.com issue tracker](https://gitlab.com/gitlab-org/gitlab-ce/issues). You can log into GitLab.com using your GitHub account.
We encourage you to open an issue on the [GitLab.com issue tracker](https://gitlab.com/gitlab-org/gitlab/issues). You can log into GitLab.com using your GitHub account.
Thank you for taking the time to contribute back to GitLab!
Please open a merge request [on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests), we look forward to reviewing your contribution! You can log into GitLab.com using your GitHub account.
Please open a merge request [on GitLab.com](https://gitlab.com/gitlab-org/gitlab/merge_requests), we look forward to reviewing your contribution! You can log into GitLab.com using your GitHub account.
......@@ -24,17 +24,6 @@ package-and-qa-manual:
when: manual
needs: ["build-qa-image", "gitlab:assets:compile pull-cache"]
package-and-qa-manual:master:
extends:
- .package-and-qa-base
- .only-code-qa-changes
only:
refs:
- master@gitlab-org/gitlab-foss
- master@gitlab-org/gitlab
when: manual
needs: ["build-qa-image", "gitlab:assets:compile"]
package-and-qa:
extends:
- .package-and-qa-base
......@@ -44,3 +33,14 @@ package-and-qa:
- master
needs: ["build-qa-image", "gitlab:assets:compile pull-cache"]
allow_failure: true
schedule:package-and-qa:
extends:
- .package-and-qa-base
- .only-code-qa-changes
only:
refs:
- schedules@gitlab-org/gitlab
- schedules@gitlab-org/gitlab-foss
needs: ["build-qa-image", "gitlab:assets:compile"]
allow_failure: true
......@@ -2,17 +2,10 @@
Please read this!
Before opening a new issue, make sure to search for keywords in the issues
filtered by the "regression" or "bug" label.
filtered by the "regression" or "bug" label:
For the Community Edition issue tracker:
- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=regression
- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=bug
For the Enterprise Edition issue tracker:
- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=regression
- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug
- https://gitlab.com/gitlab-org/gitlab/issues?label_name%5B%5D=regression
- https://gitlab.com/gitlab-org/gitlab/issues?label_name%5B%5D=bug
and verify the issue you're about to submit isn't a duplicate.
--->
......
......@@ -24,7 +24,7 @@ Remove the `:feature_name` feature flag ...
If applicable, any groups/projects that are happy to have this feature turned on early. Some organizations may wish to test big changes they are interested in with a small subset of users ahead of time for example.
- `gitlab-org/gitlab-ce`/`gitlab-org/gitlab-ee` projects
- `gitlab-org/gitlab` project
- `gitlab-org`/`gitlab-com` groups
- ...
......
......@@ -26,7 +26,7 @@
## Confidence
<!-- How do we know this is a problem? Please provide and link to any supporting information (e.g. data, customer verbatims) and use this basis to provide a numerical assessment on our confidence level in this problem's severity:
<!-- How do we know this is a problem? Please provide and link to any supporting information (e.g. data, customer verbatims) and use this basis to provide a numerical assessment on our confidence level in this problem's severity:
100% = High confidence
80% = Medium confidence
......@@ -34,7 +34,7 @@
## Effort
<!-- How much effort do we think it will be to solve this problem? Please include all counterparts (Product, UX, Engineering, etc) in your assessment and quantify the number of person-months needed to dedicate to the effort.
<!-- How much effort do we think it will be to solve this problem? Please include all counterparts (Product, UX, Engineering, etc) in your assessment and quantify the number of person-months needed to dedicate to the effort.
For example, if the solution will take a product manager, designer, and engineer two weeks of effort - you may quantify this as 1.5 (based on 0.5 months x 3 people). -->
......
......@@ -18,13 +18,7 @@ Set the title to: `Security Release: 12.2.X, 12.1.X, and 12.0.X`
## Security Issues:
### CE
* {https://gitlab.com/gitlab-org/gitlab-ce/issues link}
### EE
* {https://gitlab.com/gitlab-org/gitlab-ee/issues link}
* {https://gitlab.com/gitlab-org/gitlab/issues link}
## Security Issues in dev.gitlab.org:
......
......@@ -2,7 +2,7 @@
<!-- This issue outlines testing activities related to a particular issue or epic.
[Here is an example test plan](https://gitlab.com/gitlab-org/gitlab-ce/issues/50353)
[Here is an example test plan](https://gitlab.com/gitlab-org/gitlab-foss/issues/50353)
This and other comments should be removed as you write the plan -->
......@@ -63,7 +63,7 @@ intersection of Components and Attributes.
Some features might be simple enough that they only involve one Component, while
more complex features could involve multiple or even all.
Example (from https://gitlab.com/gitlab-org/gitlab-ce/issues/50353):
Example (from https://gitlab.com/gitlab-org/gitlab-foss/issues/50353):
* Repository is
* Intuitive
* It's easy to select the desired file template
......
......@@ -37,7 +37,7 @@ When adding foreign keys to existing tables:
When adding tables:
- [ ] Ordered columns based on the [Ordering Table Columns](https://docs.gitlab.com/ee/development/ordering_table_columns.html#ordering-table-columns) guidelines
- [ ] Ordered columns based on the [Ordering Table Columns](https://docs.gitlab.com/ee/development/ordering_table_columns.html) guidelines
- [ ] Added foreign keys to any columns pointing to data in other tables
- [ ] Added indexes for fields that are used in statements such as `WHERE`, `ORDER BY`, `GROUP BY`, and `JOIN`s
......
......@@ -35,6 +35,6 @@ All reviewers can help ensure accuracy, clarity, completeness, and adherence to
1. [ ] Review by assigned maintainer, who can always request/require the above reviews. Maintainer's review can occur before or after a technical writer review.
1. [ ] Ensure a release milestone is set and that you merge the equivalent EE MR before the CE MR if both exist.
1. [ ] If there has not been a technical writer review, [create an issue for one using the Doc Review template](https://gitlab.com/gitlab-org/gitlab-ce/issues/new?issuable_template=Doc%20Review).
1. [ ] If there has not been a technical writer review, [create an issue for one using the Doc Review template](https://gitlab.com/gitlab-org/gitlab/issues/new?issuable_template=Doc%20Review).
/label ~documentation
......@@ -9,5 +9,7 @@ export default {
};
</script>
<template>
<div class="duration rounded align-self-start px-2 ml-2 flex-shrink-0">{{ duration }}</div>
<div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0">
{{ duration }}
</div>
</template>
......@@ -19,7 +19,7 @@ export default {
</script>
<template>
<div class="line">
<div class="log-line">
<line-number :line-number="line.lineNumber" :path="path" />
<span v-for="(content, i) in line.content" :key="i" :class="content.style">{{
content.text
......
......@@ -43,7 +43,7 @@ export default {
<template>
<div
class="line collapsible-line d-flex justify-content-between"
class="log-line collapsible-line d-flex justify-content-between"
role="button"
@click="handleOnClick"
>
......
......@@ -47,7 +47,7 @@ export default {
dockerConnectionErrorText() {
return sprintf(
s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
issue with your project name or path.
issue with your project name or path.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`,
......@@ -58,8 +58,8 @@ export default {
},
introText() {
return sprintf(
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
project can have its own space to store its Docker images.
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
......@@ -109,7 +109,7 @@ export default {
:svg-path="containersErrorImage"
>
<template #description>
<p v-html="dockerConnectionErrorText"></p>
<p class="js-character-error-text" v-html="dockerConnectionErrorText"></p>
</template>
</gl-empty-state>
......
......@@ -49,7 +49,7 @@ export default {
}
},
handleDeleteRepository() {
this.deleteItem(this.repo)
return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
......@@ -67,7 +67,8 @@ export default {
<div class="container-image">
<div class="container-image-head">
<gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
<icon :name="iconName" /> {{ repo.name }}
<icon :name="iconName" />
{{ repo.name }}
</gl-button>
<clipboard-button
......
......@@ -198,8 +198,9 @@ export default {
:title="s__('ContainerRegistry|Remove selected images')"
:aria-label="s__('ContainerRegistry|Remove selected images')"
@click="deleteMultipleItems()"
><icon name="remove"
/></gl-button>
>
<icon name="remove" />
</gl-button>
</th>
</tr>
</thead>
......@@ -223,9 +224,9 @@ export default {
/>
</td>
<td>
<span v-gl-tooltip.bottom class="monospace" :title="item.revision">
{{ item.shortRevision }}
</span>
<span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{
item.shortRevision
}}</span>
</td>
<td>
{{ formatSize(item.size) }}
......@@ -236,9 +237,9 @@ export default {
</td>
<td>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">
{{ timeFormated(item.createdAt) }}
</span>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
timeFormated(item.createdAt)
}}</span>
</td>
<td class="content action-buttons">
......@@ -262,6 +263,7 @@ export default {
v-if="shouldRenderPagination"
:change="onPageChange"
:page-info="repo.pagination"
class="js-registry-pagination"
/>
<gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">
......
......@@ -387,6 +387,7 @@ img.emoji {
.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
.prepend-top-32 { margin-top: 32px; }
.prepend-left-2 { margin-left: 2px; }
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-8 { margin-left: 8px; }
......
......@@ -41,7 +41,7 @@
}
}
.duration {
.log-duration-badge {
background: $gl-gray-400;
}
......
......@@ -2,12 +2,12 @@
module Clusters
class KubernetesNamespaceFinder
attr_reader :cluster, :project, :environment_slug
attr_reader :cluster, :project, :environment_name
def initialize(cluster, project:, environment_slug:, allow_blank_token: false)
def initialize(cluster, project:, environment_name:, allow_blank_token: false)
@cluster = cluster
@project = project
@environment_slug = environment_slug
@environment_name = environment_name
@allow_blank_token = allow_blank_token
end
......@@ -20,7 +20,11 @@ module Clusters
attr_reader :allow_blank_token
def find_namespace(with_environment:)
relation = with_environment ? namespaces.with_environment_slug(environment_slug) : namespaces
relation = if with_environment
namespaces.with_environment_name(environment_name)
else
namespaces
end
relation.find_by_project_id(project.id)
end
......
......@@ -172,7 +172,7 @@ module Clusters
persisted_namespace = Clusters::KubernetesNamespaceFinder.new(
self,
project: project,
environment_slug: environment.slug
environment_name: environment.name
).execute
persisted_namespace&.namespace || Gitlab::Kubernetes::DefaultNamespace.new(self, project: project).from_environment_slug(environment.slug)
......
......@@ -27,7 +27,7 @@ module Clusters
algorithm: 'aes-256-cbc'
scope :has_service_account_token, -> { where.not(encrypted_service_account_token: nil) }
scope :with_environment_slug, -> (slug) { joins(:environment).where(environments: { slug: slug }) }
scope :with_environment_name, -> (name) { joins(:environment).where(environments: { name: name }) }
def token_name
"#{namespace}-token"
......
......@@ -105,19 +105,11 @@ module Clusters
private
##
# Environment slug can be predicted given an environment
# name, so even if the environment isn't persisted yet we
# still know what to look for.
def environment_slug(name)
Gitlab::Slug::Environment.new(name).generate
end
def find_persisted_namespace(project, environment_name:)
Clusters::KubernetesNamespaceFinder.new(
cluster,
project: project,
environment_slug: environment_slug(environment_name)
environment_name: environment_name
).execute
end
......
---
title: Fix stylelint errors in epics.scss
merge_request: 17243
author:
type: fixed
---
title: Fix CSS leak in job log
merge_request:
author:
type: fixed
---
title: Fix bug that caused a merge to show an error message
merge_request: 17466
author:
type: fixed
......@@ -134,7 +134,7 @@ graph RL;
M[coverage];
N[pages];
O[static-analysis];
P["package-and-qa-manual:master<br/>(master schedule only)"];
P["schedule:package-and-qa<br/>(master schedule only)"];
Q[package-and-qa];
R[package-and-qa-manual];
......
......@@ -334,10 +334,10 @@ This will disable the option for all users who previously had permissions to
operate project memberships, so no new users can be added. Furthermore, any
request to add a new user to a project through API will not be possible.
#### IP access restriction **(ULTIMATE ONLY)**
#### IP access restriction **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/1985) in
[GitLab Ultimate](https://about.gitlab.com/pricing/) 12.0.
[GitLab Ultimate and Gold](https://about.gitlab.com/pricing/) 12.0.
To make sure only people from within your organization can access particular
resources, you have the option to restrict access to groups and their
......@@ -353,10 +353,10 @@ Restriction currently applies to UI and API access, Git actions via ssh are not
To avoid accidental lock-out, admins and group owners are are able to access
the group regardless of the IP restriction.
#### Allowed domain restriction **(PREMIUM ONLY)**
#### Allowed domain restriction **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7297) in
[GitLab Premium](https://about.gitlab.com/pricing/) 12.2.
[GitLab Premium and Silver](https://about.gitlab.com/pricing/) 12.2.
You can restrict access to groups and their underlying projects by
allowing only users with email addresses in particular domains to be added to the group.
......
......@@ -220,7 +220,7 @@ export default {
<div class="value">
<div
:class="{ 'is-option-selected': selectedDateIsFixed, 'd-flex': !editing }"
class="value-type-fixed"
class="value-type-fixed text-secondary"
>
<input
v-if="canUpdate && !editing"
......@@ -237,7 +237,7 @@ export default {
@newDateSelected="newDateSelected"
@hidePicker="stopEditing"
/>
<span v-else class="d-flex value-content">
<span v-else class="d-flex value-content prepend-left-2">
<template v-if="dateFixed">
<span>{{ dateFixedWords }}</span>
<icon
......@@ -265,7 +265,7 @@ export default {
v-tooltip
:title="dateFromMilestonesTooltip"
:class="{ 'is-option-selected': !selectedDateIsFixed }"
class="value-type-dynamic d-flex prepend-top-10"
class="value-type-dynamic text-secondary d-flex prepend-top-10"
data-placement="bottom"
data-html="true"
>
......@@ -277,7 +277,7 @@ export default {
@click="toggleDateType(false)"
/>
<span class="prepend-left-5">{{ __('From milestones:') }}</span>
<span class="value-content">{{ dateFromMilestonesWords }}</span>
<span class="value-content prepend-left-2">{{ dateFromMilestonesWords }}</span>
<icon
v-if="isDateInvalid && !selectedDateIsFixed"
v-popover="dateInvalidPopoverOptions"
......
......@@ -33,10 +33,10 @@ export default {
},
methods: {
onFormSubmit() {
this.$emit('createItemFormSubmit', this.inputValue.trim());
this.$emit('createEpicFormSubmit', this.inputValue.trim());
},
onFormCancel() {
this.$emit('createItemFormCancel');
this.$emit('createEpicFormCancel');
},
},
};
......
......@@ -4,7 +4,7 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import AddItemForm from 'ee/related_issues/components/add_issuable_form.vue';
import CreateItemForm from './create_item_form.vue';
import CreateEpicForm from './create_epic_form.vue';
import TreeItemRemoveModal from './tree_item_remove_modal.vue';
import RelatedItemsTreeHeader from './related_items_tree_header.vue';
......@@ -21,7 +21,7 @@ export default {
RelatedItemsTreeHeader,
RelatedItemsTreeBody,
AddItemForm,
CreateItemForm,
CreateEpicForm,
TreeItemRemoveModal,
},
computed: {
......@@ -32,7 +32,7 @@ export default {
'itemAddInProgress',
'itemCreateInProgress',
'showAddItemForm',
'showCreateItemForm',
'showCreateEpicForm',
'autoCompleteEpics',
'autoCompleteIssues',
'pendingReferences',
......@@ -55,7 +55,7 @@ export default {
...mapActions([
'fetchItems',
'toggleAddItemForm',
'toggleCreateItemForm',
'toggleCreateEpicForm',
'setPendingReferences',
'addPendingReferences',
'removePendingReference',
......@@ -84,7 +84,7 @@ export default {
this.addItem();
}
},
handleCreateItemFormSubmit(newValue) {
handleCreateEpicFormSubmit(newValue) {
this.createItem({
itemTitle: newValue,
});
......@@ -94,8 +94,8 @@ export default {
this.setPendingReferences([]);
this.setItemInputValue('');
},
handleCreateItemFormCancel() {
this.toggleCreateItemForm({ toggleState: false, actionType: this.actionType });
handleCreateEpicFormCancel() {
this.toggleCreateEpicForm({ toggleState: false, actionType: this.actionType });
this.setItemInputValue('');
},
},
......@@ -117,7 +117,7 @@ export default {
>
<related-items-tree-header :class="{ 'border-bottom-0': itemsFetchResultEmpty }" />
<div
v-if="showAddItemForm || showCreateItemForm"
v-if="showAddItemForm || showCreateEpicForm"
class="card-body add-item-form-container"
:class="{ 'border-bottom-0': itemsFetchResultEmpty }"
>
......@@ -135,11 +135,11 @@ export default {
@addIssuableFormSubmit="handleAddItemFormSubmit"
@addIssuableFormCancel="handleAddItemFormCancel"
/>
<create-item-form
v-if="showCreateItemForm"
<create-epic-form
v-if="showCreateEpicForm"
:is-submitting="itemCreateInProgress"
@createItemFormSubmit="handleCreateItemFormSubmit"
@createItemFormCancel="handleCreateItemFormCancel"
@createEpicFormSubmit="handleCreateEpicFormSubmit"
@createEpicFormCancel="handleCreateEpicFormCancel"
/>
</div>
<related-items-tree-body
......
......@@ -32,7 +32,7 @@ export default {
},
},
methods: {
...mapActions(['toggleAddItemForm', 'toggleCreateItemForm']),
...mapActions(['toggleAddItemForm', 'toggleCreateEpicForm']),
handleActionClick({ id, actionType }) {
if (id === 0) {
this.toggleAddItemForm({
......@@ -40,7 +40,7 @@ export default {
toggleState: true,
});
} else {
this.toggleCreateItemForm({
this.toggleCreateEpicForm({
actionType,
toggleState: true,
});
......
......@@ -244,8 +244,8 @@ export const removeItem = ({ dispatch }, { parentItem, item }) => {
};
export const toggleAddItemForm = ({ commit }, data) => commit(types.TOGGLE_ADD_ITEM_FORM, data);
export const toggleCreateItemForm = ({ commit }, data) =>
commit(types.TOGGLE_CREATE_ITEM_FORM, data);
export const toggleCreateEpicForm = ({ commit }, data) =>
commit(types.TOGGLE_CREATE_EPIC_FORM, data);
export const setPendingReferences = ({ commit }, data) =>
commit(types.SET_PENDING_REFERENCES, data);
......@@ -342,7 +342,7 @@ export const receiveCreateItemSuccess = (
isSubItem: false,
});
dispatch('toggleCreateItemForm', {
dispatch('toggleCreateEpicForm', {
actionType,
toggleState: false,
});
......
......@@ -21,7 +21,7 @@ export const EXPAND_ITEM = 'EXPAND_ITEM';
export const COLLAPSE_ITEM = 'COLLAPSE_ITEM';
export const TOGGLE_ADD_ITEM_FORM = 'TOGGLE_ADD_ITEM_FORM';
export const TOGGLE_CREATE_ITEM_FORM = 'TOGGLE_CREATE_ITEM_FORM';
export const TOGGLE_CREATE_EPIC_FORM = 'TOGGLE_CREATE_EPIC_FORM';
export const SET_PENDING_REFERENCES = 'SET_PENDING_REFERENCES';
export const ADD_PENDING_REFERENCES = 'ADD_PENDING_REFERENCES';
......
......@@ -123,12 +123,12 @@ export default {
[types.TOGGLE_ADD_ITEM_FORM](state, { actionType, toggleState }) {
state.actionType = actionType;
state.showAddItemForm = toggleState;
state.showCreateItemForm = false;
state.showCreateEpicForm = false;
},
[types.TOGGLE_CREATE_ITEM_FORM](state, { actionType, toggleState }) {
[types.TOGGLE_CREATE_EPIC_FORM](state, { actionType, toggleState }) {
state.actionType = actionType;
state.showCreateItemForm = toggleState;
state.showCreateEpicForm = toggleState;
state.showAddItemForm = false;
},
......
......@@ -22,7 +22,7 @@ export default () => ({
itemAddInProgress: false,
itemCreateInProgress: false,
showAddItemForm: false,
showCreateItemForm: false,
showCreateEpicForm: false,
autoCompleteEpics: false,
autoCompleteIssues: false,
removeItemModalProps: {
......
@include media-breakpoint-down(sm) {
.epics-other-filters .filter-dropdown-container {
.epics-other-filters {
.epics-sort-btn i {
position: absolute;
top: 11px;
......@@ -17,46 +17,35 @@
}
.epic-sidebar {
.block.date {
.help-icon,
.date-warning-icon {
&:hover {
cursor: pointer;
}
}
.help-icon {
color: $gl-text-color-secondary;
}
.btn-sidebar-action {
line-height: $gl-font-size;
.help-icon,
.date-warning-icon {
&:hover {
cursor: pointer;
}
}
.btn-sidebar-date-remove {
height: $gl-font-size;
line-height: $gl-btn-horz-padding;
}
.help-icon {
color: $gl-text-color-secondary;
}
.date-warning-icon {
color: $orange-500;
margin-top: -1px;
}
.btn-sidebar-action {
line-height: $gl-font-size;
}
.value-type-fixed,
.value-type-dynamic {
color: $gl-text-color-secondary;
.btn-sidebar-date-remove {
height: $gl-font-size;
line-height: $gl-btn-horz-padding;
}
.value-content {
margin-left: 2px;
}
.date-warning-icon {
color: $orange-500;
margin-top: -1px;
}
&.is-option-selected {
> span {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
}
}
.is-option-selected {
> span {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
}
}
}
......
......@@ -14,6 +14,10 @@ class DependencyListEntity < Grape::Entity
expose :job_path, if: ->(_, options) { options[:build] && can_read_job_path? } do |_, options|
project_build_path(project, options[:build].id)
end
expose :generated_at, if: ->(_, options) { options[:build] && can_read_job_path? } do |_, options|
options[:build].finished_at
end
end
private
......
......@@ -30,10 +30,26 @@ module ApprovalRules
end
end
# This freezes the approval state at the time of merge. By copying
# project-level rules as merge request-level rules, the approval
# state will be unaffected if project rules get changed or removed.
def copy_project_approval_rules
rules_by_name = merge_request.approval_rules.index_by(&:name)
merge_request.target_project.approval_rules.each do |project_rule|
users = project_rule.approvers
groups = project_rule.groups.public_or_visible_to_user(merge_request.author)
name = project_rule.name
next unless name.present?
rule = rules_by_name[name]
# If the rule already exists, we just skip this one without
# updating the current state. If the approval rules were changed
# after merging a merge request, syncing the data might make it
# appear as though this merge request hadn't been approved.
next if rule
merge_request.approval_rules.create!(
project_rule.attributes.slice('approvals_required', 'name').merge(users: users, groups: groups)
......
---
title: Expose time when the build was generated
merge_request: 17113
author:
type: added
......@@ -13,7 +13,8 @@
},
"report": {
"status": { "type": "string" },
"job_path": { "type": "string" }
"job_path": { "type": "string" },
"generated_at": { "type": "string" }
}
},
"additionalProperties": false
......
......@@ -364,7 +364,7 @@ describe('RelatedItemsTree', () => {
});
describe(types.TOGGLE_ADD_ITEM_FORM, () => {
it('should set value of `actionType`, `showAddItemForm` as it is and `showCreateItemForm` as false on state', () => {
it('should set value of `actionType`, `showAddItemForm` as it is and `showCreateEpicForm` as false on state', () => {
const data = {
actionType: 'Epic',
toggleState: true,
......@@ -374,21 +374,21 @@ describe('RelatedItemsTree', () => {
expect(state.actionType).toBe(data.actionType);
expect(state.showAddItemForm).toBe(data.toggleState);
expect(state.showCreateItemForm).toBe(false);
expect(state.showCreateEpicForm).toBe(false);
});
});
describe(types.TOGGLE_CREATE_ITEM_FORM, () => {
it('should set value of `actionType`, `showCreateItemForm` as it is and `showAddItemForm` as false on state', () => {
describe(types.TOGGLE_CREATE_EPIC_FORM, () => {
it('should set value of `actionType`, `showCreateEpicForm` as it is and `showAddItemForm` as false on state', () => {
const data = {
actionType: 'Epic',
toggleState: true,
};
mutations[types.TOGGLE_CREATE_ITEM_FORM](state, data);
mutations[types.TOGGLE_CREATE_EPIC_FORM](state, data);
expect(state.actionType).toBe(data.actionType);
expect(state.showCreateItemForm).toBe(data.toggleState);
expect(state.showCreateEpicForm).toBe(data.toggleState);
expect(state.showAddItemForm).toBe(false);
});
});
......
import { mount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import CreateItemForm from 'ee/related_items_tree/components/create_item_form.vue';
import CreateEpicForm from 'ee/related_items_tree/components/create_epic_form.vue';
const createComponent = (isSubmitting = false) => {
const localVue = createLocalVue();
return mount(CreateItemForm, {
return mount(CreateEpicForm, {
localVue,
propsData: {
isSubmitting,
......@@ -15,7 +15,7 @@ const createComponent = (isSubmitting = false) => {
};
describe('RelatedItemsTree', () => {
describe('CreateItemForm', () => {
describe('CreateEpicForm', () => {
let wrapper;
beforeEach(() => {
......@@ -68,22 +68,22 @@ describe('RelatedItemsTree', () => {
describe('methods', () => {
describe('onFormSubmit', () => {
it('emits `createItemFormSubmit` event on component with input value as param', () => {
it('emits `createEpicFormSubmit` event on component with input value as param', () => {
const value = 'foo';
wrapper.find('input.form-control').setValue(value);
wrapper.vm.onFormSubmit();
expect(wrapper.emitted().createItemFormSubmit).toBeTruthy();
expect(wrapper.emitted().createItemFormSubmit[0]).toEqual([value]);
expect(wrapper.emitted().createEpicFormSubmit).toBeTruthy();
expect(wrapper.emitted().createEpicFormSubmit[0]).toEqual([value]);
});
});
describe('onFormCancel', () => {
it('emits `createItemFormCancel` event on component', () => {
it('emits `createEpicFormCancel` event on component', () => {
wrapper.vm.onFormCancel();
expect(wrapper.emitted().createItemFormCancel).toBeTruthy();
expect(wrapper.emitted().createEpicFormCancel).toBeTruthy();
});
});
});
......
......@@ -105,12 +105,12 @@ describe('RelatedItemsTreeApp', () => {
});
});
describe('handleCreateItemFormSubmit', () => {
describe('handleCreateEpicFormSubmit', () => {
it('calls `createItem` action with `itemTitle` param', () => {
const newValue = 'foo';
spyOn(wrapper.vm, 'createItem');
wrapper.vm.handleCreateItemFormSubmit(newValue);
wrapper.vm.handleCreateEpicFormSubmit(newValue);
expect(wrapper.vm.createItem).toHaveBeenCalledWith({
itemTitle: newValue,
......@@ -147,13 +147,13 @@ describe('RelatedItemsTreeApp', () => {
});
});
describe('handleCreateItemFormCancel', () => {
it('calls `toggleCreateItemForm` actions with params `toggleState` and `actionType`', () => {
spyOn(wrapper.vm, 'toggleCreateItemForm');
describe('handleCreateEpicFormCancel', () => {
it('calls `toggleCreateEpicForm` actions with params `toggleState` and `actionType`', () => {
spyOn(wrapper.vm, 'toggleCreateEpicForm');
wrapper.vm.handleCreateItemFormCancel();
wrapper.vm.handleCreateEpicFormCancel();
expect(wrapper.vm.toggleCreateItemForm).toHaveBeenCalledWith({
expect(wrapper.vm.toggleCreateEpicForm).toHaveBeenCalledWith({
toggleState: false,
actionType: '',
});
......@@ -162,7 +162,7 @@ describe('RelatedItemsTreeApp', () => {
it('calls `setItemInputValue` action with empty string', () => {
spyOn(wrapper.vm, 'setItemInputValue');
wrapper.vm.handleCreateItemFormCancel();
wrapper.vm.handleCreateEpicFormCancel();
expect(wrapper.vm.setItemInputValue).toHaveBeenCalledWith('');
});
......
......@@ -70,15 +70,15 @@ describe('RelatedItemsTree', () => {
});
});
it('calls `toggleCreateItemForm` action when provided `id` param value is not `0`', () => {
spyOn(wrapper.vm, 'toggleCreateItemForm');
it('calls `toggleCreateEpicForm` action when provided `id` param value is not `0`', () => {
spyOn(wrapper.vm, 'toggleCreateEpicForm');
wrapper.vm.handleActionClick({
id: 1,
actionType,
});
expect(wrapper.vm.toggleCreateItemForm).toHaveBeenCalledWith({
expect(wrapper.vm.toggleCreateEpicForm).toHaveBeenCalledWith({
actionType,
toggleState: true,
});
......
......@@ -732,13 +732,13 @@ describe('RelatedItemTree', () => {
});
});
describe('toggleCreateItemForm', () => {
it('should set `state.showCreateItemForm` to true', done => {
describe('toggleCreateEpicForm', () => {
it('should set `state.showCreateEpicForm` to true', done => {
testAction(
actions.toggleCreateItemForm,
actions.toggleCreateEpicForm,
{},
{},
[{ type: types.TOGGLE_CREATE_ITEM_FORM, payload: {} }],
[{ type: types.TOGGLE_CREATE_EPIC_FORM, payload: {} }],
[],
done,
);
......@@ -997,7 +997,7 @@ describe('RelatedItemTree', () => {
payload: { children: [createdEpic], isSubItem: false },
},
{
type: 'toggleCreateItemForm',
type: 'toggleCreateEpicForm',
payload: { actionType: ActionType.Epic, toggleState: false },
},
],
......
......@@ -33,6 +33,7 @@ describe DependencyListEntity do
expect(subject[:dependencies][0][:name]).to eq('nokogiri')
expect(subject[:report][:status]).to eq(:ok)
expect(subject[:report][:job_path]).to eq(job_path)
expect(subject[:report][:generated_at]).to eq(ci_build.finished_at)
end
end
......@@ -69,6 +70,7 @@ describe DependencyListEntity do
it 'has only status failed_job' do
expect(subject[:report][:status]).to eq(:job_failed)
expect(subject[:report]).not_to include(:job_path)
expect(subject[:report]).not_to include(:generated_at)
end
end
end
......@@ -81,6 +83,7 @@ describe DependencyListEntity do
it 'has status job_not_set_up and no job_path' do
expect(subject[:report][:status]).to eq(:job_not_set_up)
expect(subject[:report][:job_path]).not_to be_present
expect(subject[:report][:generated_at]).not_to be_present
end
end
end
......
......@@ -62,6 +62,31 @@ describe ApprovalRules::FinalizeService do
expect(rule.approved_approvers).to contain_exactly(user1, group1_user)
end
shared_examples 'idempotent approval tests' do |rule_type|
before do
project_rule.destroy
rule = create(:approval_project_rule, project: project, name: 'another rule', approvals_required: 2, rule_type: rule_type)
rule.users = [user1]
rule.groups << group1
# Emulate merge requests approval rules synced with project rule
mr_rule = create(:approval_merge_request_rule, merge_request: merge_request, name: rule.name, approvals_required: 2, rule_type: rule_type)
mr_rule.users = rule.users
mr_rule.groups = rule.groups
end
it 'does not create a new rule if one exists' do
expect do
2.times { subject.execute }
end.not_to change { ApprovalMergeRequestRule.count }
end
end
ApprovalProjectRule.rule_types.except(:code_owner, :report_approver).each do |rule_type, _value|
it_behaves_like 'idempotent approval tests', rule_type
end
end
end
......
......@@ -36,7 +36,7 @@ module Gitlab
Clusters::KubernetesNamespaceFinder.new(
deployment_cluster,
project: environment.project,
environment_slug: environment.slug,
environment_name: environment.name,
allow_blank_token: true
).execute
end
......
......@@ -40,6 +40,9 @@ gitlab:
limits:
cpu: 140m
memory: 40M
maxReplicas: 3
hpa:
targetAverageValue: 130m
sidekiq:
resources:
requests:
......
......@@ -7,7 +7,7 @@ RSpec.describe Clusters::KubernetesNamespaceFinder do
described_class.new(
cluster,
project: project,
environment_slug: 'production',
environment_name: 'production',
allow_blank_token: allow_blank_token
)
end
......@@ -22,8 +22,8 @@ RSpec.describe Clusters::KubernetesNamespaceFinder do
end
describe '#execute' do
let(:production) { create(:environment, project: project, slug: 'production') }
let(:staging) { create(:environment, project: project, slug: 'staging') }
let(:production) { create(:environment, project: project, name: 'production') }
let(:staging) { create(:environment, project: project, name: 'staging') }
let(:cluster) { create(:cluster, :group, :provided_by_user) }
let(:project) { create(:project) }
......
import registry from '~/registry/components/app.vue';
import { mount } from '@vue/test-utils';
import { TEST_HOST } from '../../helpers/test_constants';
import { reposServerResponse, parsedReposServerResponse } from '../mock_data';
describe('Registry List', () => {
let wrapper;
const findCollapsibleContainer = w => w.findAll({ name: 'CollapsibeContainerRegisty' });
const findNoContainerImagesText = w => w.find('.js-no-container-images-text');
const findSpinner = w => w.find('.gl-spinner');
const findCharacterErrorText = w => w.find('.js-character-error-text');
const propsData = {
endpoint: `${TEST_HOST}/foo`,
helpPagePath: 'foo',
noContainersImage: 'foo',
containersErrorImage: 'foo',
repositoryUrl: 'foo',
};
const setMainEndpoint = jest.fn();
const fetchRepos = jest.fn();
const methods = {
setMainEndpoint,
fetchRepos,
};
beforeEach(() => {
wrapper = mount(registry, {
propsData,
computed: {
repos() {
return parsedReposServerResponse;
},
},
methods,
});
});
describe('with data', () => {
it('should render a list of CollapsibeContainerRegisty', () => {
const containers = findCollapsibleContainer(wrapper);
expect(wrapper.vm.repos.length).toEqual(reposServerResponse.length);
expect(containers.length).toEqual(reposServerResponse.length);
});
});
describe('without data', () => {
let localWrapper;
beforeEach(() => {
localWrapper = mount(registry, {
propsData,
computed: {
repos() {
return [];
},
},
methods,
});
});
it('should render empty message', () => {
const noContainerImagesText = findNoContainerImagesText(localWrapper);
expect(noContainerImagesText.text()).toEqual(
'With the Container Registry, every project can have its own space to store its Docker images. More Information',
);
});
});
describe('while loading data', () => {
let localWrapper;
beforeEach(() => {
localWrapper = mount(registry, {
propsData,
computed: {
repos() {
return [];
},
isLoading() {
return true;
},
},
methods,
});
});
it('should render a loading spinner', () => {
const spinner = findSpinner(localWrapper);
expect(spinner.exists()).toBe(true);
});
});
describe('invalid characters in path', () => {
let localWrapper;
beforeEach(() => {
localWrapper = mount(registry, {
propsData: {
...propsData,
characterError: true,
},
computed: {
repos() {
return [];
},
},
methods,
});
});
it('should render invalid characters error message', () => {
const characterErrorText = findCharacterErrorText(localWrapper);
expect(characterErrorText.text()).toEqual(
'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More Information',
);
});
});
});
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import { repoPropsData } from '../mock_data';
import createFlash from '~/flash';
jest.mock('~/flash.js');
describe('collapsible registry container', () => {
let wrapper;
const findDeleteBtn = w => w.find('.js-remove-repo');
const findContainerImageTags = w => w.find('.container-image-tags');
const findToggleRepos = w => w.findAll('.js-toggle-repo');
beforeEach(() => {
createFlash.mockClear();
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(collapsibleComponent, {
propsData: {
repo: repoPropsData,
},
});
});
afterEach(() => {
Vue.config.silent = false;
});
describe('toggle', () => {
beforeEach(() => {
const fetchList = jest.fn();
wrapper.setMethods({ fetchList });
});
const expectIsClosed = () => {
const container = findContainerImageTags(wrapper);
expect(container.exists()).toBe(false);
expect(wrapper.vm.iconName).toEqual('angle-right');
};
it('should be closed by default', () => {
expectIsClosed();
});
it('should be open when user clicks on closed repo', () => {
const toggleRepos = findToggleRepos(wrapper);
toggleRepos.at(0).trigger('click');
const container = findContainerImageTags(wrapper);
expect(container.exists()).toBe(true);
expect(wrapper.vm.fetchList).toHaveBeenCalled();
});
it('should be closed when the user clicks on an opened repo', done => {
const toggleRepos = findToggleRepos(wrapper);
toggleRepos.at(0).trigger('click');
Vue.nextTick(() => {
toggleRepos.at(0).trigger('click');
Vue.nextTick(() => {
expectIsClosed();
done();
});
});
});
});
describe('delete repo', () => {
it('should be possible to delete a repo', () => {
const deleteBtn = findDeleteBtn(wrapper);
expect(deleteBtn.exists()).toBe(true);
});
it('should call deleteItem when confirming deletion', () => {
const deleteItem = jest.fn().mockResolvedValue();
const fetchRepos = jest.fn().mockResolvedValue();
wrapper.setMethods({ deleteItem, fetchRepos });
wrapper.vm.handleDeleteRepository();
expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(wrapper.vm.repo);
});
it('should show an error when there is API error', () => {
const deleteItem = jest.fn().mockRejectedValue('error');
wrapper.setMethods({ deleteItem });
return wrapper.vm.handleDeleteRepository().then(() => {
expect(createFlash).toHaveBeenCalled();
});
});
});
});
import Vue from 'vue';
import tableRegistry from '~/registry/components/table_registry.vue';
import { mount } from '@vue/test-utils';
import { repoPropsData } from '../mock_data';
const [firstImage, secondImage] = repoPropsData.list;
describe('table registry', () => {
let wrapper;
const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
const findDeleteButton = w => w.find('.js-delete-registry');
const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row');
const findPagination = w => w.find('.js-registry-pagination');
const bulkDeletePath = 'path';
beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(tableRegistry, {
propsData: {
repo: repoPropsData,
},
});
});
afterEach(() => {
Vue.config.silent = false;
});
describe('rendering', () => {
it('should render a table with the registry list', () => {
expect(wrapper.findAll('.registry-image-row').length).toEqual(repoPropsData.list.length);
});
it('should render registry tag', () => {
const tds = wrapper.findAll('.registry-image-row td');
expect(tds.at(0).classes()).toContain('check');
expect(tds.at(1).html()).toContain(repoPropsData.list[0].tag);
expect(tds.at(2).html()).toContain(repoPropsData.list[0].shortRevision);
expect(tds.at(3).html()).toContain(repoPropsData.list[0].layers);
expect(tds.at(3).html()).toContain(repoPropsData.list[0].size);
expect(tds.at(4).html()).toContain(wrapper.vm.timeFormated(repoPropsData.list[0].createdAt));
});
});
describe('multi select', () => {
it('selecting a row should enable delete button', done => {
const deleteBtn = findDeleteButton(wrapper);
const checkboxes = findSelectCheckboxes(wrapper);
expect(deleteBtn.attributes('disabled')).toBe('disabled');
checkboxes.at(0).trigger('click');
Vue.nextTick(() => {
expect(deleteBtn.attributes('disabled')).toEqual(undefined);
done();
});
});
it('selecting all checkbox should select all rows and enable delete button', done => {
const selectAll = findSelectAllCheckbox(wrapper);
const checkboxes = findSelectCheckboxes(wrapper);
selectAll.trigger('click');
Vue.nextTick(() => {
const checked = checkboxes.filter(w => w.element.checked);
expect(checked.length).toBe(checkboxes.length);
done();
});
});
it('deselecting select all checkbox should deselect all rows and disable delete button', done => {
const checkboxes = findSelectCheckboxes(wrapper);
const selectAll = findSelectAllCheckbox(wrapper);
selectAll.trigger('click');
selectAll.trigger('click');
Vue.nextTick(() => {
const checked = checkboxes.filter(w => !w.element.checked);
expect(checked.length).toBe(checkboxes.length);
done();
});
});
it('should delete multiple items when multiple items are selected', done => {
const multiDeleteItems = jest.fn().mockResolvedValue();
wrapper.setMethods({ multiDeleteItems });
const selectAll = findSelectAllCheckbox(wrapper);
selectAll.trigger('click');
Vue.nextTick(() => {
const deleteBtn = findDeleteButton(wrapper);
expect(wrapper.vm.itemsToBeDeleted).toEqual([0, 1]);
expect(deleteBtn.attributes('disabled')).toEqual(undefined);
wrapper.vm.handleMultipleDelete();
Vue.nextTick(() => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath,
items: [firstImage.tag, secondImage.tag],
});
done();
});
});
});
it('should show an error message if bulkDeletePath is not set', () => {
const showError = jest.fn();
wrapper.setMethods({ showError });
wrapper.setProps({
repo: {
...repoPropsData,
tagsPath: null,
},
});
wrapper.vm.handleMultipleDelete();
expect(wrapper.vm.showError).toHaveBeenCalled();
});
});
describe('delete registry', () => {
beforeEach(() => {
wrapper.setData({ itemsToBeDeleted: [0] });
});
it('should be possible to delete a registry', () => {
const deleteBtn = findDeleteButton(wrapper);
const deleteBtns = findDeleteButtonsRow(wrapper);
expect(wrapper.vm.itemsToBeDeleted).toEqual([0]);
expect(deleteBtn).toBeDefined();
expect(deleteBtn.attributes('disable')).toBe(undefined);
expect(deleteBtns.is('button')).toBe(true);
});
it('should allow deletion row by row', () => {
const deleteBtns = findDeleteButtonsRow(wrapper);
const deleteSingleItem = jest.fn();
const deleteItem = jest.fn().mockResolvedValue();
wrapper.setMethods({ deleteSingleItem, deleteItem });
deleteBtns.at(0).trigger('click');
expect(wrapper.vm.deleteSingleItem).toHaveBeenCalledWith(0);
wrapper.vm.handleSingleDelete(1);
expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(1);
});
});
describe('pagination', () => {
let localWrapper = null;
const repo = {
repoPropsData,
pagination: {
total: 20,
perPage: 2,
nextPage: 2,
},
};
beforeEach(() => {
localWrapper = mount(tableRegistry, {
propsData: {
repo,
},
});
});
it('should exist', () => {
const pagination = findPagination(localWrapper);
expect(pagination.exists()).toBe(true);
});
it('should be visible when pagination is needed', () => {
const pagination = findPagination(localWrapper);
expect(pagination.isVisible()).toBe(true);
localWrapper.setProps({
repo: {
pagination: {
total: 0,
perPage: 10,
},
},
});
expect(localWrapper.vm.shouldRenderPagination).toBe(false);
});
it('should have a change function that update the list when run', () => {
const fetchList = jest.fn().mockResolvedValue();
localWrapper.setMethods({ fetchList });
localWrapper.vm.onPageChange(1);
expect(localWrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
});
});
describe('modal content', () => {
it('should show the singular title and image name when deleting a single image', () => {
wrapper.setData({ itemsToBeDeleted: [1] });
wrapper.vm.setModalDescription(0);
expect(wrapper.vm.modalTitle).toBe('Remove image');
expect(wrapper.vm.modalDescription).toContain(firstImage.tag);
});
it('should show the plural title and image count when deleting more than one image', () => {
wrapper.setData({ itemsToBeDeleted: [1, 2] });
wrapper.vm.setModalDescription();
expect(wrapper.vm.modalTitle).toBe('Remove images');
expect(wrapper.vm.modalDescription).toContain('<b>2</b> images');
});
});
});
......@@ -2,81 +2,121 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/stores/actions';
import * as types from '~/registry/stores/mutation_types';
import state from '~/registry/stores/state';
import { TEST_HOST } from 'spec/test_constants';
import { TEST_HOST } from '../../helpers/test_constants';
import testAction from '../../helpers/vuex_action_helper';
import createFlash from '~/flash';
import {
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';
jest.mock('~/flash.js');
describe('Actions Registry Store', () => {
let mockedState;
let mock;
let state;
beforeEach(() => {
mockedState = state();
mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
state = {
endpoint: `${TEST_HOST}/endpoint.json`,
};
});
afterEach(() => {
mock.restore();
});
describe('server requests', () => {
describe('fetchRepos', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
describe('fetchRepos', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
it('should set receveived repos', done => {
testAction(
actions.fetchRepos,
null,
mockedState,
[
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.SET_REPOS_LIST, payload: reposServerResponse },
],
[],
done,
);
});
it('should set receveived repos', done => {
testAction(
actions.fetchRepos,
null,
state,
[
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.SET_REPOS_LIST, payload: reposServerResponse },
],
[],
done,
);
});
describe('fetchList', () => {
let repo;
beforeEach(() => {
mockedState.repos = parsedReposServerResponse;
[, repo] = mockedState.repos;
it('should create flash on API error', done => {
testAction(
actions.fetchRepos,
null,
{
endpoint: null,
},
[{ type: types.TOGGLE_MAIN_LOADING }, { type: types.TOGGLE_MAIN_LOADING }],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});
describe('fetchList', () => {
let repo;
beforeEach(() => {
state.repos = parsedReposServerResponse;
[, repo] = state.repos;
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});
it('should set received list', done => {
testAction(
actions.fetchList,
{ repo },
mockedState,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{
type: types.SET_REGISTRY_LIST,
payload: {
repo,
resp: registryServerResponse,
headers: jasmine.anything(),
},
it('should set received list', done => {
testAction(
actions.fetchList,
{ repo },
state,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{
type: types.SET_REGISTRY_LIST,
payload: {
repo,
resp: registryServerResponse,
headers: expect.anything(),
},
],
[],
done,
);
});
},
],
[],
done,
);
});
it('should create flash on API error', done => {
const updatedRepo = {
...repo,
tagsPath: null,
};
testAction(
actions.fetchList,
{
repo: updatedRepo,
},
state,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
......@@ -85,7 +125,7 @@ describe('Actions Registry Store', () => {
testAction(
actions.setMainEndpoint,
'endpoint',
mockedState,
state,
[{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }],
[],
done,
......@@ -98,7 +138,7 @@ describe('Actions Registry Store', () => {
testAction(
actions.toggleLoading,
null,
mockedState,
state,
[{ type: types.TOGGLE_MAIN_LOADING }],
[],
done,
......@@ -106,25 +146,42 @@ describe('Actions Registry Store', () => {
});
});
describe('deleteItem', () => {
it('should perform DELETE request on destroyPath', done => {
const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;
let deleted = false;
describe('deleteItem and multiDeleteItems', () => {
let deleted;
const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;
const expectDelete = done => {
expect(mock.history.delete.length).toBe(1);
expect(deleted).toBe(true);
done();
};
beforeEach(() => {
deleted = false;
mock.onDelete(destroyPath).replyOnce(() => {
deleted = true;
return [200];
});
});
it('deleteItem should perform DELETE request on destroyPath', done => {
testAction(
actions.deleteItem,
{
destroyPath,
},
mockedState,
state,
)
.then(() => {
expect(mock.history.delete.length).toBe(1);
expect(deleted).toBe(true);
done();
expectDelete(done);
})
.catch(done.fail);
});
it('multiDeleteItems should perform DELETE request on path', done => {
testAction(actions.multiDeleteItems, { path: destroyPath, items: [1] }, state)
.then(() => {
expectDelete(done);
})
.catch(done.fail);
});
......
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import registry from '~/registry/components/app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { TEST_HOST } from 'spec/test_constants';
import { reposServerResponse } from '../mock_data';
describe('Registry List', () => {
const Component = Vue.extend(registry);
const props = {
endpoint: `${TEST_HOST}/foo`,
helpPagePath: 'foo',
noContainersImage: 'foo',
containersErrorImage: 'foo',
repositoryUrl: 'foo',
};
let vm;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
vm.$destroy();
});
describe('with data', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, reposServerResponse);
vm = mountComponent(Component, { ...props });
});
it('should render a list of repos', done => {
setTimeout(() => {
expect(vm.$store.state.repos.length).toEqual(reposServerResponse.length);
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('.container-image').length).toEqual(
reposServerResponse.length,
);
done();
});
}, 0);
});
describe('delete repository', () => {
it('should be possible to delete a repo', done => {
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-head .js-remove-repo')).toBeDefined();
done();
});
}, 0);
});
});
describe('toggle repository', () => {
it('should open the container', done => {
setTimeout(() => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-toggle-repo use').getAttribute('xlink:href'),
).toContain('angle-up');
done();
});
});
}, 0);
});
});
});
describe('without data', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
vm = mountComponent(Component, { ...props });
});
it('should render empty message', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-no-container-images-text').textContent).toEqual(
'With the Container Registry, every project can have its own space to store its Docker images. More Information',
);
done();
}, 0);
});
});
describe('while loading data', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
vm = mountComponent(Component, { ...props });
});
it('should render a loading spinner', done => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.gl-spinner')).not.toBe(null);
done();
});
});
});
describe('invalid characters in path', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
vm = mountComponent(Component, {
...props,
characterError: true,
});
});
it('should render invalid characters error message', done => {
setTimeout(() => {
expect(vm.$el.querySelector('p')).not.toContain(
'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More information',
);
done();
});
});
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import store from '~/registry/stores';
import * as types from '~/registry/stores/mutation_types';
import { repoPropsData, registryServerResponse, reposServerResponse } from '../mock_data';
describe('collapsible registry container', () => {
let vm;
let mock;
const Component = Vue.extend(collapsibleComponent);
const findDeleteBtn = () => vm.$el.querySelector('.js-remove-repo');
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(repoPropsData.tagsPath).replyOnce(200, registryServerResponse, {});
store.commit(types.SET_REPOS_LIST, reposServerResponse);
vm = new Component({
store,
propsData: {
repo: repoPropsData,
},
}).$mount();
});
afterEach(() => {
mock.restore();
vm.$destroy();
});
describe('toggle', () => {
it('should be closed by default', () => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
expect(vm.iconName).toEqual('angle-right');
});
it('should be open when user clicks on closed repo', done => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-tags')).not.toBeNull();
expect(vm.iconName).toEqual('angle-up');
done();
});
});
it('should be closed when the user clicks on an opened repo', done => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
expect(vm.iconName).toEqual('angle-right');
done();
});
});
});
});
});
describe('delete repo', () => {
it('should be possible to delete a repo', () => {
expect(findDeleteBtn()).not.toBeNull();
});
it('should call deleteItem when confirming deletion', done => {
findDeleteBtn().click();
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());
Vue.nextTick(() => {
document.querySelector(`#${vm.modalId} .btn-danger`).click();
expect(vm.deleteItem).toHaveBeenCalledWith(vm.repo);
done();
});
});
});
});
import Vue from 'vue';
import tableRegistry from '~/registry/components/table_registry.vue';
import store from '~/registry/stores';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { repoPropsData } from '../mock_data';
const [firstImage, secondImage] = repoPropsData.list;
describe('table registry', () => {
let vm;
const Component = Vue.extend(tableRegistry);
const bulkDeletePath = 'path';
const findDeleteBtn = () => vm.$el.querySelector('.js-delete-registry');
const findDeleteBtnRow = () => vm.$el.querySelector('.js-delete-registry-row');
const findSelectAllCheckbox = () => vm.$el.querySelector('.js-select-all-checkbox > input');
const findAllRowCheckboxes = () =>
Array.from(vm.$el.querySelectorAll('.js-select-checkbox input'));
const confirmationModal = (child = '') => document.querySelector(`#${vm.modalId} ${child}`);
const createComponent = () => {
vm = mountComponentWithStore(Component, {
store,
props: {
repo: repoPropsData,
},
});
};
const selectAllCheckboxes = () => vm.selectAll();
const deselectAllCheckboxes = () => vm.deselectAll();
beforeEach(() => {
createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('rendering', () => {
it('should render a table with the registry list', () => {
expect(vm.$el.querySelectorAll('table tbody tr').length).toEqual(repoPropsData.list.length);
});
it('should render registry tag', () => {
const textRendered = vm.$el
.querySelector('.table tbody tr')
.textContent.trim()
// replace additional whitespace characters (e.g. new lines) with a single empty space
.replace(/\s\s+/g, ' ');
expect(textRendered).toContain(repoPropsData.list[0].tag);
expect(textRendered).toContain(repoPropsData.list[0].shortRevision);
expect(textRendered).toContain(repoPropsData.list[0].layers);
expect(textRendered).toContain(repoPropsData.list[0].size);
});
});
describe('multi select', () => {
it('should support multiselect and selecting a row should enable delete button', done => {
findSelectAllCheckbox().click();
selectAllCheckboxes();
expect(findSelectAllCheckbox().checked).toBe(true);
Vue.nextTick(() => {
expect(findDeleteBtn().disabled).toBe(false);
done();
});
});
it('selecting all checkbox should select all rows and enable delete button', done => {
selectAllCheckboxes();
Vue.nextTick(() => {
const checkedValues = findAllRowCheckboxes().filter(x => x.checked);
expect(checkedValues.length).toBe(repoPropsData.list.length);
done();
});
});
it('deselecting select all checkbox should deselect all rows and disable delete button', done => {
selectAllCheckboxes();
deselectAllCheckboxes();
Vue.nextTick(() => {
const checkedValues = findAllRowCheckboxes().filter(x => x.checked);
expect(checkedValues.length).toBe(0);
done();
});
});
it('should delete multiple items when multiple items are selected', done => {
selectAllCheckboxes();
Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([0, 1]);
expect(findDeleteBtn().disabled).toBe(false);
findDeleteBtn().click();
spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve());
Vue.nextTick(() => {
const modal = confirmationModal();
confirmationModal('.btn-danger').click();
expect(modal).toExist();
Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([]);
expect(vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath,
items: [firstImage.tag, secondImage.tag],
});
done();
});
});
});
});
});
describe('delete registry', () => {
beforeEach(() => {
vm.itemsToBeDeleted = [0];
});
it('should be possible to delete a registry', done => {
Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([0]);
expect(findDeleteBtn()).toBeDefined();
expect(findDeleteBtn().disabled).toBe(false);
expect(findDeleteBtnRow()).toBeDefined();
done();
});
});
it('should call deleteItems and reset itemsToBeDeleted when confirming deletion', done => {
Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([0]);
expect(findDeleteBtn().disabled).toBe(false);
findDeleteBtn().click();
spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve());
Vue.nextTick(() => {
confirmationModal('.btn-danger').click();
expect(vm.itemsToBeDeleted).toEqual([]);
expect(vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath,
items: [firstImage.tag],
});
done();
});
});
});
});
describe('pagination', () => {
it('should be possible to change the page', () => {
expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
});
});
describe('modal content', () => {
it('should show the singular title and image name when deleting a single image', done => {
findDeleteBtnRow().click();
Vue.nextTick(() => {
expect(vm.modalTitle).toBe('Remove image');
expect(vm.modalDescription).toContain(firstImage.tag);
done();
});
});
it('should show the plural title and image count when deleting more than one image', done => {
selectAllCheckboxes();
vm.setModalDescription();
Vue.nextTick(() => {
expect(vm.modalTitle).toBe('Remove images');
expect(vm.modalDescription).toContain('<b>2</b> images');
done();
});
});
});
});
......@@ -546,7 +546,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
before do
expect(Clusters::KubernetesNamespaceFinder).to receive(:new)
.with(cluster, project: environment.project, environment_slug: environment.slug)
.with(cluster, project: environment.project, environment_name: environment.name)
.and_return(double(execute: persisted_namespace))
end
......
......@@ -24,13 +24,13 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do
end
end
describe '.with_environment_slug' do
describe '.with_environment_name' do
let(:cluster) { create(:cluster, :group) }
let(:environment) { create(:environment, slug: slug) }
let(:environment) { create(:environment, name: name) }
let(:slug) { 'production' }
let(:name) { 'production' }
subject { described_class.with_environment_slug(slug) }
subject { described_class.with_environment_name(name) }
context 'there is no associated environment' do
let!(:namespace) { create(:cluster_kubernetes_namespace, cluster: cluster, project: environment.project) }
......@@ -48,12 +48,12 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do
)
end
context 'with a matching slug' do
context 'with a matching name' do
it { is_expected.to eq [namespace] }
end
context 'without a matching slug' do
let(:environment) { create(:environment, slug: 'staging') }
context 'without a matching name' do
let(:environment) { create(:environment, name: 'staging') }
it { is_expected.to be_empty }
end
......
......@@ -218,7 +218,7 @@ describe Clusters::Platforms::Kubernetes do
before do
allow(Clusters::KubernetesNamespaceFinder).to receive(:new)
.with(cluster, project: project, environment_slug: environment_slug)
.with(cluster, project: project, environment_name: environment_name)
.and_return(double(execute: persisted_namespace))
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment