Commit 3bf1d4d3 authored by GitLab Bot's avatar GitLab Bot

Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-04-19

# Conflicts:
#	app/controllers/projects/wikis_controller.rb
#	app/models/ci/job_artifact.rb
#	app/models/issue.rb
#	app/models/project.rb
#	app/models/project_statistics.rb
#	spec/models/ci/build_spec.rb

[ci skip]
parents fe4b55d9 a313aece
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6" image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-65.0-node-8.x-yarn-1.2-postgresql-9.6"
.dedicated-runner: &dedicated-runner .dedicated-runner: &dedicated-runner
retry: 1 retry: 1
......
...@@ -150,7 +150,7 @@ gem 'creole', '~> 0.5.0' ...@@ -150,7 +150,7 @@ gem 'creole', '~> 0.5.0'
gem 'wikicloth', '0.8.1' gem 'wikicloth', '0.8.1'
gem 'asciidoctor', '~> 1.5.6' gem 'asciidoctor', '~> 1.5.6'
gem 'asciidoctor-plantuml', '0.0.8' gem 'asciidoctor-plantuml', '0.0.8'
gem 'rouge', '~> 2.0' gem 'rouge', '~> 3.1'
gem 'truncato', '~> 0.7.9' gem 'truncato', '~> 0.7.9'
gem 'bootstrap_form', '~> 2.7.0' gem 'bootstrap_form', '~> 2.7.0'
gem 'nokogiri', '~> 1.8.2' gem 'nokogiri', '~> 1.8.2'
......
...@@ -327,12 +327,12 @@ GEM ...@@ -327,12 +327,12 @@ GEM
flowdock (~> 0.7) flowdock (~> 0.7)
gitlab-grit (>= 2.4.1) gitlab-grit (>= 2.4.1)
multi_json multi_json
gitlab-gollum-lib (4.2.7.1) gitlab-gollum-lib (4.2.7.2)
gemojione (~> 3.2) gemojione (~> 3.2)
github-markup (~> 1.6) github-markup (~> 1.6)
gollum-grit_adapter (~> 1.0) gollum-grit_adapter (~> 1.0)
nokogiri (>= 1.6.1, < 2.0) nokogiri (>= 1.6.1, < 2.0)
rouge (~> 2.1) rouge (~> 3.1)
sanitize (~> 2.1) sanitize (~> 2.1)
stringex (~> 2.6) stringex (~> 2.6)
gitlab-gollum-rugged_adapter (0.4.4) gitlab-gollum-rugged_adapter (0.4.4)
...@@ -776,7 +776,7 @@ GEM ...@@ -776,7 +776,7 @@ GEM
retriable (3.1.1) retriable (3.1.1)
rinku (2.0.0) rinku (2.0.0)
rotp (2.1.2) rotp (2.1.2)
rouge (2.2.1) rouge (3.1.1)
rqrcode (0.7.0) rqrcode (0.7.0)
chunky_png chunky_png
rqrcode-rails3 (0.1.7) rqrcode-rails3 (0.1.7)
...@@ -1197,7 +1197,7 @@ DEPENDENCIES ...@@ -1197,7 +1197,7 @@ DEPENDENCIES
redis-rails (~> 5.0.2) redis-rails (~> 5.0.2)
request_store (~> 1.3) request_store (~> 1.3)
responders (~> 2.0) responders (~> 2.0)
rouge (~> 2.0) rouge (~> 3.1)
rqrcode-rails3 (~> 0.1.7) rqrcode-rails3 (~> 0.1.7)
rspec-parameterized rspec-parameterized
rspec-rails (~> 3.6.0) rspec-rails (~> 3.6.0)
......
...@@ -69,7 +69,7 @@ GEM ...@@ -69,7 +69,7 @@ GEM
unf unf
ast (2.4.0) ast (2.4.0)
atomic (1.1.100) atomic (1.1.100)
attr_encrypted (3.0.3) attr_encrypted (3.1.0)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
attr_required (1.0.1) attr_required (1.0.1)
autoprefixer-rails (8.1.0.1) autoprefixer-rails (8.1.0.1)
...@@ -291,9 +291,9 @@ GEM ...@@ -291,9 +291,9 @@ GEM
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gherkin-ruby (0.3.2) gherkin-ruby (0.3.2)
gitaly-proto (0.94.0) gitaly-proto (0.97.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.0) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
charlock_holmes (~> 0.7.5) charlock_holmes (~> 0.7.5)
escape_utils (~> 1.1.0) escape_utils (~> 1.1.0)
...@@ -304,6 +304,17 @@ GEM ...@@ -304,6 +304,17 @@ GEM
flowdock (~> 0.7) flowdock (~> 0.7)
gitlab-grit (>= 2.4.1) gitlab-grit (>= 2.4.1)
multi_json multi_json
gitlab-gollum-lib (4.2.7.1)
gemojione (~> 3.2)
github-markup (~> 1.6)
gollum-grit_adapter (~> 1.0)
nokogiri (>= 1.6.1, < 2.0)
rouge (~> 2.1)
sanitize (~> 2.1)
stringex (~> 2.6)
gitlab-gollum-rugged_adapter (0.4.4)
mime-types (>= 1.15)
rugged (~> 0.25)
gitlab-grit (2.8.2) gitlab-grit (2.8.2)
charlock_holmes (~> 0.6) charlock_holmes (~> 0.6)
diff-lcs (~> 1.1) diff-lcs (~> 1.1)
...@@ -321,22 +332,8 @@ GEM ...@@ -321,22 +332,8 @@ GEM
rubyntlm (~> 0.5) rubyntlm (~> 0.5)
globalid (0.4.1) globalid (0.4.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldiloader (2.0.1)
activerecord (>= 4.2, < 5.2)
activesupport (>= 4.2, < 5.2)
gollum-grit_adapter (1.0.1) gollum-grit_adapter (1.0.1)
gitlab-grit (~> 2.7, >= 2.7.1) gitlab-grit (~> 2.7, >= 2.7.1)
gollum-lib (4.2.7)
gemojione (~> 3.2)
github-markup (~> 1.6)
gollum-grit_adapter (~> 1.0)
nokogiri (>= 1.6.1, < 2.0)
rouge (~> 2.1)
sanitize (~> 2.1)
stringex (~> 2.6)
gollum-rugged_adapter (0.4.4)
mime-types (>= 1.15)
rugged (~> 0.25)
gon (6.1.0) gon (6.1.0)
actionpack (>= 3.0) actionpack (>= 3.0)
json json
...@@ -1009,7 +1006,7 @@ DEPENDENCIES ...@@ -1009,7 +1006,7 @@ DEPENDENCIES
asciidoctor (~> 1.5.6) asciidoctor (~> 1.5.6)
asciidoctor-plantuml (= 0.0.8) asciidoctor-plantuml (= 0.0.8)
asset_sync (~> 2.2.0) asset_sync (~> 2.2.0)
attr_encrypted (~> 3.0.0) attr_encrypted (~> 3.1.0)
awesome_print (~> 1.2.0) awesome_print (~> 1.2.0)
babosa (~> 1.0.2) babosa (~> 1.0.2)
base32 (~> 0.3.0) base32 (~> 0.3.0)
...@@ -1069,15 +1066,14 @@ DEPENDENCIES ...@@ -1069,15 +1066,14 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.94.0) gitaly-proto (~> 0.97.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
gitlab-gollum-rugged_adapter (~> 0.4.4)
gitlab-markup (~> 1.6.2) gitlab-markup (~> 1.6.2)
gitlab-styles (~> 2.3) gitlab-styles (~> 2.3)
gitlab_omniauth-ldap (~> 2.0.4) gitlab_omniauth-ldap (~> 2.0.4)
goldiloader (~> 2.0)
gollum-lib (~> 4.2)
gollum-rugged_adapter (~> 0.4.4)
gon (~> 6.1.0) gon (~> 6.1.0)
google-api-client (~> 0.19.8) google-api-client (~> 0.19.8)
google-protobuf (= 3.5.1) google-protobuf (= 3.5.1)
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
export default { export default {
components: { components: {
icon, Icon,
},
directives: {
tooltip,
}, },
props: { props: {
file: { file: {
type: Object, type: Object,
required: true, required: true,
}, },
showTooltip: {
type: Boolean,
required: false,
default: false,
},
showStagedIcon: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
changedIcon() { changedIcon() {
return this.file.tempFile ? 'file-addition' : 'file-modified'; const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : '';
return this.file.tempFile ? `file-addition${suffix}` : `file-modified${suffix}`;
},
stagedIcon() {
return `${this.changedIcon}-solid`;
}, },
changedIconClass() { changedIconClass() {
return `multi-${this.changedIcon}`; return `multi-${this.changedIcon} prepend-left-5 pull-left`;
},
tooltipTitle() {
if (!this.showTooltip) return undefined;
const type = this.file.tempFile ? 'addition' : 'modification';
if (this.file.changed && !this.file.staged) {
return sprintf(__('Unstaged %{type}'), {
type,
});
} else if (!this.file.changed && this.file.staged) {
return sprintf(__('Staged %{type}'), {
type,
});
} else if (this.file.changed && this.file.staged) {
return sprintf(__('Unstaged and staged %{type}'), {
type: pluralize(type),
});
}
return undefined;
}, },
}, },
}; };
</script> </script>
<template> <template>
<icon <span
:name="changedIcon" v-tooltip
:size="12" :title="tooltipTitle"
:css-classes="`ide-file-changed-icon ${changedIconClass}`" data-container="body"
/> data-placement="right"
class="ide-file-changed-icon"
>
<icon
v-if="file.staged && showStagedIcon"
:name="stagedIcon"
:size="12"
:css-classes="changedIconClass"
/>
<icon
v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)"
:name="changedIcon"
:size="12"
:css-classes="changedIconClass"
/>
</span>
</template> </template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
noChangesStateSvgPath: {
type: String,
required: true,
},
committedStateSvgPath: {
type: String,
required: true,
},
},
computed: {
...mapState(['lastCommitMsg', 'rightPanelCollapsed']),
...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
statusSvg() {
return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
},
},
methods: {
...mapActions(['toggleRightPanelCollapsed']),
},
};
</script>
<template>
<div
class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state"
>
<header
class="multi-file-commit-panel-header"
:class="{
'is-collapsed': rightPanelCollapsed,
}"
>
<button
v-tooltip
:title="collapseButtonTooltip"
data-container="body"
data-placement="left"
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
:aria-label="__('Toggle sidebar')"
@click.stop="toggleRightPanelCollapsed"
>
<icon
:name="collapseButtonIcon"
:size="18"
/>
</button>
</header>
<div
class="ide-commit-empty-state-container"
v-if="!rightPanelCollapsed"
>
<div class="svg-content svg-80">
<img :src="statusSvg" />
</div>
<div class="append-right-default prepend-left-default">
<div
class="text-content text-center"
v-if="!lastCommitMsg"
>
<h4>
{{ __('No changes') }}
</h4>
<p>
{{ __('Edit files in the editor and commit changes here') }}
</p>
</div>
<div
class="text-content text-center"
v-else
>
<h4>
{{ __('All changes are committed') }}
</h4>
<p v-html="lastCommitMsg"></p>
</div>
</div>
</div>
</div>
</template>
<script> <script>
import { mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import icon from '~/vue_shared/components/icon.vue'; import { __, sprintf } from '~/locale';
import listItem from './list_item.vue'; import Icon from '~/vue_shared/components/icon.vue';
import listCollapsed from './list_collapsed.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
import ListCollapsed from './list_collapsed.vue';
export default { export default {
components: { components: {
icon, Icon,
listItem, ListItem,
listCollapsed, ListCollapsed,
},
directives: {
tooltip,
},
props: {
title: {
type: String,
required: true,
}, },
props: { fileList: {
title: { type: Array,
type: String, required: true,
required: true,
},
fileList: {
type: Array,
required: true,
},
}, },
computed: { showToggle: {
...mapState([ type: Boolean,
'currentProjectId', required: false,
'currentBranchId', default: true,
'rightPanelCollapsed',
]),
isCommitInfoShown() {
return this.rightPanelCollapsed || this.fileList.length;
},
}, },
methods: { iconName: {
toggleCollapsed() { type: String,
this.$emit('toggleCollapsed'); required: true,
},
}, },
}; action: {
type: String,
required: true,
},
actionBtnText: {
type: String,
required: true,
},
itemActionComponent: {
type: String,
required: true,
},
stagedList: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['rightPanelCollapsed']),
...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
titleText() {
return sprintf(__('%{title} changes'), {
title: this.title,
});
},
},
methods: {
...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']),
actionBtnClicked() {
this[this.action]();
},
},
};
</script> </script>
<template> <template>
<div <div
class="ide-commit-list-container"
:class="{ :class="{
'multi-file-commit-list': isCommitInfoShown 'is-collapsed': rightPanelCollapsed,
}" }"
> >
<header
class="multi-file-commit-panel-header"
>
<div
v-if="!rightPanelCollapsed"
class="multi-file-commit-panel-header-title"
:class="{
'append-right-10': showToggle,
}"
>
<icon
v-once
:name="iconName"
:size="18"
/>
{{ titleText }}
<button
type="button"
class="btn btn-blank btn-link ide-staged-action-btn"
@click="actionBtnClicked"
>
{{ actionBtnText }}
</button>
</div>
<button
v-if="showToggle"
v-tooltip
:title="collapseButtonTooltip"
data-container="body"
data-placement="left"
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
:aria-label="__('Toggle sidebar')"
@click.stop="toggleRightPanelCollapsed"
>
<icon
:name="collapseButtonIcon"
:size="18"
/>
</button>
</header>
<list-collapsed <list-collapsed
v-if="rightPanelCollapsed" v-if="rightPanelCollapsed"
:files="fileList"
:icon-name="iconName"
:title="title"
/> />
<template v-else> <template v-else>
<ul <ul
v-if="fileList.length" v-if="fileList.length"
class="list-unstyled append-bottom-0" class="multi-file-commit-list list-unstyled append-bottom-0"
> >
<li <li
v-for="file in fileList" v-for="file in fileList"
...@@ -58,9 +134,18 @@ ...@@ -58,9 +134,18 @@
> >
<list-item <list-item
:file="file" :file="file"
:action-component="itemActionComponent"
:key-prefix="title"
:staged-list="stagedList"
/> />
</li> </li>
</ul> </ul>
<p
v-else
class="multi-file-commit-list help-block"
>
{{ __('No changes') }}
</p>
</template> </template>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue';
import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip';
import { sprintf, n__, __ } from '~/locale';
export default { export default {
components: { components: {
icon, Icon,
},
directives: {
tooltip,
},
props: {
files: {
type: Array,
required: true,
}, },
computed: { iconName: {
...mapGetters([ type: String,
'addedFiles', required: true,
'modifiedFiles',
]),
}, },
}; title: {
type: String,
required: true,
},
},
computed: {
addedFilesLength() {
return this.files.filter(f => f.tempFile).length;
},
modifiedFilesLength() {
return this.files.filter(f => !f.tempFile).length;
},
addedFilesIconClass() {
return this.addedFilesLength ? 'multi-file-addition' : '';
},
modifiedFilesClass() {
return this.modifiedFilesLength ? 'multi-file-modified' : '';
},
additionsTooltip() {
return sprintf(n__('1 %{type} addition', '%d %{type} additions', this.addedFilesLength), {
type: this.title.toLowerCase(),
});
},
modifiedTooltip() {
return sprintf(
n__('1 %{type} modification', '%d %{type} modifications', this.modifiedFilesLength),
{ type: this.title.toLowerCase() },
);
},
titleTooltip() {
return sprintf(__('%{title} changes'), { title: this.title });
},
additionIconName() {
return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition';
},
modifiedIconName() {
return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified';
},
},
};
</script> </script>
<template> <template>
<div <div
class="multi-file-commit-list-collapsed text-center" class="multi-file-commit-list-collapsed text-center"
> >
<icon <div
name="file-addition" v-tooltip
:size="18" :title="titleTooltip"
css-classes="multi-file-addition append-bottom-10" data-container="body"
/> data-placement="left"
{{ addedFiles.length }} class="append-bottom-15"
<icon >
name="file-modified" <icon
:size="18" v-once
css-classes="multi-file-modified prepend-top-10 append-bottom-10" :name="iconName"
/> :size="18"
{{ modifiedFiles.length }} />
</div>
<div
v-tooltip
:title="additionsTooltip"
data-container="body"
data-placement="left"
class="append-bottom-10"
>
<icon
:name="additionIconName"
:size="18"
:css-classes="addedFilesIconClass"
/>
</div>
{{ addedFilesLength }}
<div
v-tooltip
:title="modifiedTooltip"
data-container="body"
data-placement="left"
class="prepend-top-10 append-bottom-10"
>
<icon
:name="modifiedIconName"
:size="18"
:css-classes="modifiedFilesClass"
/>
</div>
{{ modifiedFilesLength }}
</div> </div>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import StageButton from './stage_button.vue';
import UnstageButton from './unstage_button.vue';
export default { export default {
components: { components: {
Icon, Icon,
StageButton,
UnstageButton,
}, },
props: { props: {
file: { file: {
type: Object, type: Object,
required: true, required: true,
}, },
actionComponent: {
type: String,
required: true,
},
keyPrefix: {
type: String,
required: false,
default: '',
},
stagedList: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
iconName() { iconName() {
return this.file.tempFile ? 'file-addition' : 'file-modified'; const prefix = this.stagedList ? '-solid' : '';
return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`;
}, },
iconClass() { iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`;
}, },
}, },
methods: { methods: {
...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), ...mapActions([
openFileInEditor(file) { 'discardFileChanges',
return this.openPendingTab(file).then(changeViewer => { 'updateViewer',
'openPendingTab',
'unstageChange',
'stageChange',
]),
openFileInEditor() {
return this.openPendingTab({
file: this.file,
keyPrefix: this.keyPrefix.toLowerCase(),
}).then(changeViewer => {
if (changeViewer) { if (changeViewer) {
this.updateViewer('diff'); this.updateViewer('diff');
} }
}); });
}, },
fileAction() {
if (this.file.staged) {
this.unstageChange(this.file.path);
} else {
this.stageChange(this.file.path);
}
},
}, },
}; };
</script> </script>
...@@ -38,7 +73,9 @@ export default { ...@@ -38,7 +73,9 @@ export default {
<button <button
type="button" type="button"
class="multi-file-commit-list-path" class="multi-file-commit-list-path"
@click="openFileInEditor(file)"> @dblclick="fileAction"
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path"> <span class="multi-file-commit-list-file-path">
<icon <icon
:name="iconName" :name="iconName"
...@@ -47,12 +84,9 @@ export default { ...@@ -47,12 +84,9 @@ export default {
/>{{ file.path }} />{{ file.path }}
</span> </span>
</button> </button>
<button <component
type="button" :is="actionComponent"
class="btn btn-blank multi-file-discard-btn" :path="file.path"
@click="discardFileChanges(file.path)" />
>
Discard
</button>
</div> </div>
</template> </template>
<script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
path: {
type: String,
required: true,
},
},
methods: {
...mapActions(['stageChange', 'discardFileChanges']),
},
};
</script>
<template>
<div
v-once
class="multi-file-discard-btn"
>
<button
v-tooltip
type="button"
class="btn btn-blank append-right-5"
:aria-label="__('Stage changes')"
:title="__('Stage changes')"
data-container="body"
@click.stop="stageChange(path)"
>
<icon
name="mobile-issue-close"
:size="12"
/>
</button>
<button
v-tooltip
type="button"
class="btn btn-blank"
:aria-label="__('Discard changes')"
:title="__('Discard changes')"
data-container="body"
@click.stop="discardFileChanges(path)"
>
<icon
name="remove"
:size="12"
/>
</button>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
path: {
type: String,
required: true,
},
},
methods: {
...mapActions(['unstageChange']),
},
};
</script>
<template>
<div
v-once
class="multi-file-discard-btn"
>
<button
v-tooltip
type="button"
class="btn btn-blank"
:aria-label="__('Unstage changes')"
:title="__('Unstage changes')"
data-container="body"
@click="unstageChange(path)"
>
<icon
name="history"
:size="12"
/>
</button>
</div>
</template>
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue'; import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import repoCommitSection from './repo_commit_section.vue'; import repoCommitSection from './repo_commit_section.vue';
...@@ -22,13 +21,6 @@ export default { ...@@ -22,13 +21,6 @@ export default {
required: true, required: true,
}, },
}, },
computed: {
...mapState(['changedFiles', 'rightPanelCollapsed']),
...mapGetters(['currentIcon']),
},
methods: {
...mapActions(['setPanelCollapsedStatus']),
},
}; };
</script> </script>
...@@ -41,40 +33,6 @@ export default { ...@@ -41,40 +33,6 @@ export default {
<div <div
class="multi-file-commit-panel-section" class="multi-file-commit-panel-section"
> >
<header
class="multi-file-commit-panel-header"
:class="{
'is-collapsed': rightPanelCollapsed,
}"
>
<div
class="multi-file-commit-panel-header-title"
v-if="!rightPanelCollapsed"
>
<div
v-if="changedFiles.length"
>
<icon
name="list-bulleted"
:size="18"
/>
Staged
</div>
</div>
<button
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
@click.stop="setPanelCollapsedStatus({
side: 'right',
collapsed: !rightPanelCollapsed,
})"
>
<icon
:name="currentIcon"
:size="18"
/>
</button>
</header>
<repo-commit-section <repo-commit-section
:no-changes-state-svg-path="noChangesStateSvgPath" :no-changes-state-svg-path="noChangesStateSvgPath"
:committed-state-svg-path="committedStateSvgPath" :committed-state-svg-path="committedStateSvgPath"
......
...@@ -22,13 +22,6 @@ export default { ...@@ -22,13 +22,6 @@ export default {
<template> <template>
<div class="ide-status-bar"> <div class="ide-status-bar">
<div class="ref-name">
<icon
name="branch"
:size="12"
/>
{{ file.branchId }}
</div>
<div> <div>
<div v-if="file.lastCommit && file.lastCommit.id"> <div v-if="file.lastCommit && file.lastCommit.id">
Last commit: Last commit:
......
<script> <script>
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue';
import commitFilesList from './commit_sidebar/list.vue'; import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
import CommitMessageField from './commit_sidebar/message_field.vue'; import CommitMessageField from './commit_sidebar/message_field.vue';
import * as consts from '../stores/modules/commit/constants'; import * as consts from '../stores/modules/commit/constants';
import Actions from './commit_sidebar/actions.vue'; import Actions from './commit_sidebar/actions.vue';
...@@ -12,8 +13,9 @@ import Actions from './commit_sidebar/actions.vue'; ...@@ -12,8 +13,9 @@ import Actions from './commit_sidebar/actions.vue';
export default { export default {
components: { components: {
DeprecatedModal, DeprecatedModal,
icon, Icon,
commitFilesList, CommitFilesList,
EmptyState,
Actions, Actions,
LoadingButton, LoadingButton,
CommitMessageField, CommitMessageField,
...@@ -32,33 +34,17 @@ export default { ...@@ -32,33 +34,17 @@ export default {
}, },
}, },
computed: { computed: {
...mapState([ ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed']),
'currentProjectId',
'currentBranchId',
'rightPanelCollapsed',
'lastCommitMsg',
'changedFiles',
]),
...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled', 'branchName']), ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled', 'branchName']),
statusSvg() {
return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
},
}, },
methods: { methods: {
...mapActions(['setPanelCollapsedStatus']),
...mapActions('commit', [ ...mapActions('commit', [
'updateCommitMessage', 'updateCommitMessage',
'discardDraft', 'discardDraft',
'commitChanges', 'commitChanges',
'updateCommitAction', 'updateCommitAction',
]), ]),
toggleCollapsed() {
this.setPanelCollapsedStatus({
side: 'right',
collapsed: !this.rightPanelCollapsed,
});
},
forceCreateNewBranch() { forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
}, },
...@@ -69,9 +55,6 @@ export default { ...@@ -69,9 +55,6 @@ export default {
<template> <template>
<div <div
class="multi-file-commit-panel-section" class="multi-file-commit-panel-section"
:class="{
'multi-file-commit-empty-state-container': !changedFiles.length
}"
> >
<deprecated-modal <deprecated-modal
id="ide-create-branch-modal" id="ide-create-branch-modal"
...@@ -85,15 +68,27 @@ export default { ...@@ -85,15 +68,27 @@ export default {
Would you like to create a new branch?`) }} Would you like to create a new branch?`) }}
</template> </template>
</deprecated-modal> </deprecated-modal>
<commit-files-list
title="Staged"
:file-list="changedFiles"
:collapsed="rightPanelCollapsed"
@toggleCollapsed="toggleCollapsed"
/>
<template <template
v-if="changedFiles.length" v-if="changedFiles.length || stagedFiles.length"
> >
<commit-files-list
icon-name="unstaged"
:title="__('Unstaged')"
:file-list="changedFiles"
action="stageAllChanges"
:action-btn-text="__('Stage all')"
item-action-component="stage-button"
/>
<commit-files-list
icon-name="staged"
:title="__('Staged')"
:file-list="stagedFiles"
action="unstageAllChanges"
:action-btn-text="__('Unstage all')"
item-action-component="unstage-button"
:show-toggle="false"
:staged-list="true"
/>
<form <form
class="form-horizontal multi-file-commit-form" class="form-horizontal multi-file-commit-form"
@submit.prevent.stop="commitChanges" @submit.prevent.stop="commitChanges"
...@@ -123,38 +118,10 @@ export default { ...@@ -123,38 +118,10 @@ export default {
</div> </div>
</form> </form>
</template> </template>
<div <empty-state
v-else-if="!rightPanelCollapsed" v-else
class="row js-empty-state" :no-changes-state-svg-path="noChangesStateSvgPath"
> :committed-state-svg-path="committedStateSvgPath"
<div class="col-xs-10 col-xs-offset-1"> />
<div class="svg-content svg-80">
<img :src="statusSvg" />
</div>
</div>
<div class="col-xs-10 col-xs-offset-1">
<div
class="text-content text-center"
v-if="!lastCommitMsg"
>
<h4>
{{ __('No changes') }}
</h4>
<p>
{{ __('Edit files in the editor and commit changes here') }}
</p>
</div>
<div
class="text-content text-center"
v-else
>
<h4>
{{ __('All changes are committed') }}
</h4>
<p v-html="lastCommitMsg">
</p>
</div>
</div>
</div>
</div> </div>
</template> </template>
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
}, },
computed: { computed: {
...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']),
...mapGetters(['currentMergeRequest']), ...mapGetters(['currentMergeRequest', 'getStagedFile']),
shouldHideEditor() { shouldHideEditor() {
return this.file && this.file.binary && !this.file.content; return this.file && this.file.binary && !this.file.content;
}, },
...@@ -120,7 +120,12 @@ export default { ...@@ -120,7 +120,12 @@ export default {
setupEditor() { setupEditor() {
if (!this.file || !this.editor.instance) return; if (!this.file || !this.editor.instance) return;
this.model = this.editor.createModel(this.file); const head = this.getStagedFile(this.file.path);
this.model = this.editor.createModel(
this.file,
this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null,
);
if (this.viewer === 'mrdiff') { if (this.viewer === 'mrdiff') {
this.editor.attachMergeRequestModel(this.model); this.editor.attachMergeRequestModel(this.model);
......
...@@ -102,8 +102,11 @@ export default { ...@@ -102,8 +102,11 @@ export default {
v-if="file.mrChange" v-if="file.mrChange"
/> />
<changed-file-icon <changed-file-icon
v-if="file.changed || file.tempFile || file.staged"
:file="file" :file="file"
v-if="file.changed || file.tempFile" :show-tooltip="true"
:show-staged-icon="true"
class="prepend-top-5 pull-right"
/> />
</span> </span>
<new-dropdown <new-dropdown
......
...@@ -26,13 +26,16 @@ export default { ...@@ -26,13 +26,16 @@ export default {
}, },
computed: { computed: {
closeLabel() { closeLabel() {
if (this.tab.changed || this.tab.tempFile) { if (this.fileHasChanged) {
return `${this.tab.name} changed`; return `${this.tab.name} changed`;
} }
return `Close ${this.tab.name}`; return `Close ${this.tab.name}`;
}, },
showChangedIcon() { showChangedIcon() {
return this.tab.changed ? !this.tabMouseOver : false; return this.fileHasChanged ? !this.tabMouseOver : false;
},
fileHasChanged() {
return this.tab.changed || this.tab.tempFile || this.tab.staged;
}, },
}, },
...@@ -42,18 +45,18 @@ export default { ...@@ -42,18 +45,18 @@ export default {
this.updateDelayViewerUpdated(true); this.updateDelayViewerUpdated(true);
if (tab.pending) { if (tab.pending) {
this.openPendingTab(tab); this.openPendingTab({ file: tab, keyPrefix: tab.staged ? 'staged' : 'unstaged' });
} else { } else {
this.$router.push(`/project${tab.url}`); this.$router.push(`/project${tab.url}`);
} }
}, },
mouseOverTab() { mouseOverTab() {
if (this.tab.changed) { if (this.fileHasChanged) {
this.tabMouseOver = true; this.tabMouseOver = true;
} }
}, },
mouseOutTab() { mouseOutTab() {
if (this.tab.changed) { if (this.fileHasChanged) {
this.tabMouseOver = false; this.tabMouseOver = false;
} }
}, },
......
...@@ -3,15 +3,16 @@ import Disposable from './disposable'; ...@@ -3,15 +3,16 @@ import Disposable from './disposable';
import eventHub from '../../eventhub'; import eventHub from '../../eventhub';
export default class Model { export default class Model {
constructor(monaco, file) { constructor(monaco, file, head = null) {
this.monaco = monaco; this.monaco = monaco;
this.disposable = new Disposable(); this.disposable = new Disposable();
this.file = file; this.file = file;
this.head = head;
this.content = file.content !== '' ? file.content : file.raw; this.content = file.content !== '' ? file.content : file.raw;
this.disposable.add( this.disposable.add(
(this.originalModel = this.monaco.editor.createModel( (this.originalModel = this.monaco.editor.createModel(
this.file.raw, head ? head.content : this.file.raw,
undefined, undefined,
new this.monaco.Uri(null, null, `original/${this.file.key}`), new this.monaco.Uri(null, null, `original/${this.file.key}`),
)), )),
...@@ -31,13 +32,15 @@ export default class Model { ...@@ -31,13 +32,15 @@ export default class Model {
); );
} }
this.events = new Map(); this.events = new Set();
this.updateContent = this.updateContent.bind(this); this.updateContent = this.updateContent.bind(this);
this.updateNewContent = this.updateNewContent.bind(this);
this.dispose = this.dispose.bind(this); this.dispose = this.dispose.bind(this);
eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); eventHub.$on(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$on(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
} }
get url() { get url() {
...@@ -73,22 +76,36 @@ export default class Model { ...@@ -73,22 +76,36 @@ export default class Model {
} }
onChange(cb) { onChange(cb) {
this.events.set( this.events.add(this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))));
this.path, }
this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
); onDispose(cb) {
this.events.add(cb);
} }
updateContent(content) { updateContent({ content, changed }) {
this.getOriginalModel().setValue(content); this.getOriginalModel().setValue(content);
if (!changed) {
this.getModel().setValue(content);
}
}
updateNewContent(content) {
this.getModel().setValue(content); this.getModel().setValue(content);
} }
dispose() { dispose() {
this.disposable.dispose(); this.disposable.dispose();
this.events.forEach(cb => {
if (typeof cb === 'function') cb();
});
this.events.clear(); this.events.clear();
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
} }
} }
...@@ -17,12 +17,12 @@ export default class ModelManager { ...@@ -17,12 +17,12 @@ export default class ModelManager {
return this.models.get(key); return this.models.get(key);
} }
addModel(file) { addModel(file, head = null) {
if (this.hasCachedModel(file.key)) { if (this.hasCachedModel(file.key)) {
return this.getModel(file.key); return this.getModel(file.key);
} }
const model = new Model(this.monaco, file); const model = new Model(this.monaco, file, head);
this.models.set(model.path, model); this.models.set(model.path, model);
this.disposable.add(model); this.disposable.add(model);
......
...@@ -38,6 +38,15 @@ export default class DecorationsController { ...@@ -38,6 +38,15 @@ export default class DecorationsController {
); );
} }
hasDecorations(model) {
return this.decorations.has(model.url);
}
removeDecorations(model) {
this.decorations.delete(model.url);
this.editorDecorations.delete(model.url);
}
dispose() { dispose() {
this.decorations.clear(); this.decorations.clear();
this.editorDecorations.clear(); this.editorDecorations.clear();
......
...@@ -3,7 +3,7 @@ import { throttle } from 'underscore'; ...@@ -3,7 +3,7 @@ import { throttle } from 'underscore';
import DirtyDiffWorker from './diff_worker'; import DirtyDiffWorker from './diff_worker';
import Disposable from '../common/disposable'; import Disposable from '../common/disposable';
export const getDiffChangeType = (change) => { export const getDiffChangeType = change => {
if (change.modified) { if (change.modified) {
return 'modified'; return 'modified';
} else if (change.added) { } else if (change.added) {
...@@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => { ...@@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => {
}; };
export const getDecorator = change => ({ export const getDecorator = change => ({
range: new monaco.Range( range: new monaco.Range(change.lineNumber, 1, change.endLineNumber, 1),
change.lineNumber,
1,
change.endLineNumber,
1,
),
options: { options: {
isWholeLine: true, isWholeLine: true,
linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
...@@ -31,6 +26,7 @@ export const getDecorator = change => ({ ...@@ -31,6 +26,7 @@ export const getDecorator = change => ({
export default class DirtyDiffController { export default class DirtyDiffController {
constructor(modelManager, decorationsController) { constructor(modelManager, decorationsController) {
this.disposable = new Disposable(); this.disposable = new Disposable();
this.models = new Map();
this.editorSimpleWorker = null; this.editorSimpleWorker = null;
this.modelManager = modelManager; this.modelManager = modelManager;
this.decorationsController = decorationsController; this.decorationsController = decorationsController;
...@@ -42,7 +38,15 @@ export default class DirtyDiffController { ...@@ -42,7 +38,15 @@ export default class DirtyDiffController {
} }
attachModel(model) { attachModel(model) {
if (this.models.has(model.url)) return;
model.onChange(() => this.throttledComputeDiff(model)); model.onChange(() => this.throttledComputeDiff(model));
model.onDispose(() => {
this.decorationsController.removeDecorations(model);
this.models.delete(model.url);
});
this.models.set(model.url, model);
} }
computeDiff(model) { computeDiff(model) {
...@@ -54,7 +58,11 @@ export default class DirtyDiffController { ...@@ -54,7 +58,11 @@ export default class DirtyDiffController {
} }
reDecorate(model) { reDecorate(model) {
this.decorationsController.decorate(model); if (this.decorationsController.hasDecorations(model)) {
this.decorationsController.decorate(model);
} else {
this.computeDiff(model);
}
} }
decorate({ data }) { decorate({ data }) {
...@@ -65,6 +73,7 @@ export default class DirtyDiffController { ...@@ -65,6 +73,7 @@ export default class DirtyDiffController {
dispose() { dispose() {
this.disposable.dispose(); this.disposable.dispose();
this.models.clear();
this.dirtyDiffWorker.removeEventListener('message', this.decorate); this.dirtyDiffWorker.removeEventListener('message', this.decorate);
this.dirtyDiffWorker.terminate(); this.dirtyDiffWorker.terminate();
......
...@@ -77,8 +77,8 @@ export default class Editor { ...@@ -77,8 +77,8 @@ export default class Editor {
} }
} }
createModel(file) { createModel(file, head = null) {
return this.modelManager.addModel(file); return this.modelManager.addModel(file, head);
} }
attachModel(model) { attachModel(model) {
......
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash'; import flash from '~/flash';
...@@ -32,6 +33,22 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { ...@@ -32,6 +33,22 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
} }
}; };
export const toggleRightPanelCollapsed = (
{ dispatch, state },
e = undefined,
) => {
if (e) {
$(e.currentTarget)
.tooltip('hide')
.blur();
}
dispatch('setPanelCollapsedStatus', {
side: 'right',
collapsed: !state.rightPanelCollapsed,
});
};
export const setResizingStatus = ({ commit }, resizing) => { export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing); commit(types.SET_RESIZING_STATUS, resizing);
}; };
...@@ -104,6 +121,14 @@ export const scrollToTab = () => { ...@@ -104,6 +121,14 @@ export const scrollToTab = () => {
}); });
}; };
export const stageAllChanges = ({ state, commit }) => {
state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path));
};
export const unstageAllChanges = ({ state, commit }) => {
state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path));
};
export const updateViewer = ({ commit }, viewer) => { export const updateViewer = ({ commit }, viewer) => {
commit(types.UPDATE_VIEWER, viewer); commit(types.UPDATE_VIEWER, viewer);
}; };
......
...@@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => { ...@@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => {
if (nextFileToOpen.pending) { if (nextFileToOpen.pending) {
dispatch('updateViewer', 'diff'); dispatch('updateViewer', 'diff');
dispatch('openPendingTab', nextFileToOpen); dispatch('openPendingTab', {
file: nextFileToOpen,
keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged',
});
} else { } else {
dispatch('updateDelayViewerUpdated', true); dispatch('updateDelayViewerUpdated', true);
router.push(`/project${nextFileToOpen.url}`); router.push(`/project${nextFileToOpen.url}`);
...@@ -153,7 +156,7 @@ export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { ...@@ -153,7 +156,7 @@ export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode }); commit(types.SET_FILE_VIEWMODE, { file, viewMode });
}; };
export const discardFileChanges = ({ state, commit }, path) => { export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => {
const file = state.entries[path]; const file = state.entries[path];
commit(types.DISCARD_FILE_CHANGES, path); commit(types.DISCARD_FILE_CHANGES, path);
...@@ -161,17 +164,40 @@ export const discardFileChanges = ({ state, commit }, path) => { ...@@ -161,17 +164,40 @@ export const discardFileChanges = ({ state, commit }, path) => {
if (file.tempFile && file.opened) { if (file.tempFile && file.opened) {
commit(types.TOGGLE_FILE_OPEN, path); commit(types.TOGGLE_FILE_OPEN, path);
} else if (getters.activeFile && file.path === getters.activeFile.path) {
dispatch('updateDelayViewerUpdated', true)
.then(() => {
router.push(`/project${file.url}`);
})
.catch(e => {
throw e;
});
}
eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content);
eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content);
};
export const stageChange = ({ commit, state }, path) => {
const stagedFile = state.stagedFiles.find(f => f.path === path);
commit(types.STAGE_CHANGE, path);
if (stagedFile) {
eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content);
} }
};
eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); export const unstageChange = ({ commit }, path) => {
commit(types.UNSTAGE_CHANGE, path);
}; };
export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => {
if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') {
return false; return false;
} }
commit(types.ADD_PENDING_TAB, { file }); commit(types.ADD_PENDING_TAB, { file, keyPrefix });
dispatch('scrollToTab'); dispatch('scrollToTab');
......
import { __ } from '~/locale';
export const activeFile = state => state.openFiles.find(file => file.active) || null; export const activeFile = state => state.openFiles.find(file => file.active) || null;
export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
...@@ -29,9 +31,15 @@ export const currentMergeRequest = state => { ...@@ -29,9 +31,15 @@ export const currentMergeRequest = state => {
}; };
// eslint-disable-next-line no-confusing-arrow // eslint-disable-next-line no-confusing-arrow
export const currentIcon = state => export const collapseButtonIcon = state =>
state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
export const hasChanges = state => !!state.changedFiles.length; export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length;
// eslint-disable-next-line no-confusing-arrow
export const collapseButtonTooltip = state =>
state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar');
export const hasMergeRequest = state => !!state.currentMergeRequestId; export const hasMergeRequest = state => !!state.currentMergeRequestId;
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
...@@ -98,40 +98,25 @@ export const updateFilesAfterCommit = ( ...@@ -98,40 +98,25 @@ export const updateFilesAfterCommit = (
{ root: true }, { root: true },
); );
rootState.changedFiles.forEach(entry => { rootState.stagedFiles.forEach(file => {
commit( const changedFile = rootState.changedFiles.find(f => f.path === file.path);
rootTypes.SET_LAST_COMMIT_DATA,
{
entry,
lastCommit,
},
{ root: true },
);
eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
commit( commit(
rootTypes.SET_FILE_RAW_DATA, rootTypes.UPDATE_FILE_AFTER_COMMIT,
{ {
file: entry, file,
raw: entry.content, lastCommit,
}, },
{ root: true }, { root: true },
); );
commit( eventHub.$emit(`editor.update.model.content.${file.key}`, {
rootTypes.TOGGLE_FILE_CHANGED, content: file.content,
{ changed: !!changedFile,
file: entry, });
changed: false,
},
{ root: true },
);
}); });
commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) {
if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
router.push( router.push(
`/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`,
); );
...@@ -184,6 +169,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) = ...@@ -184,6 +169,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) =
{ root: true }, { root: true },
); );
} }
commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
}) })
.then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH));
}) })
......
import * as consts from './constants'; import * as consts from './constants';
export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; const BRANCH_SUFFIX_COUNT = 5;
export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading;
export const commitButtonDisabled = (state, getters, rootState) => export const commitButtonDisabled = (state, getters, rootState) =>
getters.discardDraftButtonDisabled || !rootState.changedFiles.length; getters.discardDraftButtonDisabled || !rootState.stagedFiles.length;
export const newBranchName = (state, _, rootState) => export const newBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`; `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
-BRANCH_SUFFIX_COUNT,
)}`;
export const branchName = (state, getters, rootState) => { export const branchName = (state, getters, rootState) => {
if ( if (
......
...@@ -51,5 +51,10 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; ...@@ -51,5 +51,10 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE';
export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_VIEWER = 'UPDATE_VIEWER';
export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
export const STAGE_CHANGE = 'STAGE_CHANGE';
export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
...@@ -49,6 +49,11 @@ export default { ...@@ -49,6 +49,11 @@ export default {
lastCommitMsg, lastCommitMsg,
}); });
}, },
[types.CLEAR_STAGED_CHANGES](state) {
Object.assign(state, {
stagedFiles: [],
});
},
[types.SET_ENTRIES](state, entries) { [types.SET_ENTRIES](state, entries) {
Object.assign(state, { Object.assign(state, {
entries, entries,
...@@ -95,6 +100,22 @@ export default { ...@@ -95,6 +100,22 @@ export default {
delayViewerUpdated, delayViewerUpdated,
}); });
}, },
[types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
const changedFile = state.changedFiles.find(f => f.path === file.path);
Object.assign(state.entries[file.path], {
raw: file.content,
changed: !!changedFile,
staged: false,
lastCommit: Object.assign(state.entries[file.path].lastCommit, {
id: lastCommit.commit.id,
url: lastCommit.commit_path,
message: lastCommit.commit.message,
author: lastCommit.commit.author_name,
updatedAt: lastCommit.commit.authored_date,
}),
});
},
...projectMutations, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
...fileMutations, ...fileMutations,
......
...@@ -57,7 +57,9 @@ export default { ...@@ -57,7 +57,9 @@ export default {
}); });
}, },
[types.UPDATE_FILE_CONTENT](state, { path, content }) { [types.UPDATE_FILE_CONTENT](state, { path, content }) {
const changed = content !== state.entries[path].raw; const stagedFile = state.stagedFiles.find(f => f.path === path);
const rawContent = stagedFile ? stagedFile.content : state.entries[path].raw;
const changed = content !== rawContent;
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content, content,
...@@ -91,8 +93,10 @@ export default { ...@@ -91,8 +93,10 @@ export default {
}); });
}, },
[types.DISCARD_FILE_CHANGES](state, path) { [types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content: state.entries[path].raw, content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false, changed: false,
}); });
}, },
...@@ -106,16 +110,67 @@ export default { ...@@ -106,16 +110,67 @@ export default {
changedFiles: state.changedFiles.filter(f => f.path !== path), changedFiles: state.changedFiles.filter(f => f.path !== path),
}); });
}, },
[types.STAGE_CHANGE](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
Object.assign(state, {
changedFiles: state.changedFiles.filter(f => f.path !== path),
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: true,
changed: false,
}),
}),
});
if (stagedFile) {
Object.assign(stagedFile, {
...state.entries[path],
});
} else {
Object.assign(state, {
stagedFiles: state.stagedFiles.concat({
...state.entries[path],
}),
});
}
},
[types.UNSTAGE_CHANGE](state, path) {
const changedFile = state.changedFiles.find(f => f.path === path);
const stagedFile = state.stagedFiles.find(f => f.path === path);
if (!changedFile && stagedFile) {
Object.assign(state.entries[path], {
...stagedFile,
key: state.entries[path].key,
active: state.entries[path].active,
opened: state.entries[path].opened,
changed: true,
});
Object.assign(state, {
changedFiles: state.changedFiles.concat(state.entries[path]),
});
}
Object.assign(state, {
stagedFiles: state.stagedFiles.filter(f => f.path !== path),
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: false,
}),
}),
});
},
[types.TOGGLE_FILE_CHANGED](state, { file, changed }) { [types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
changed, changed,
}); });
}, },
[types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) {
const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); const key = `${keyPrefix}-${file.key}`;
let openFiles = state.openFiles.map(f => const pendingTab = state.openFiles.find(f => f.key === key && f.pending);
Object.assign(f, { active: f.path === file.path, opened: false }), let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false }));
);
if (!pendingTab) { if (!pendingTab) {
const openFile = openFiles.find(f => f.path === file.path); const openFile = openFiles.find(f => f.path === file.path);
...@@ -126,10 +181,11 @@ export default { ...@@ -126,10 +181,11 @@ export default {
if (f.path === file.path) { if (f.path === file.path) {
return acc.concat({ return acc.concat({
...f, ...f,
content: file.content,
active: true, active: true,
pending: true, pending: true,
opened: true, opened: true,
key: `${keyPrefix}-${f.key}`, key,
}); });
} }
......
...@@ -3,6 +3,7 @@ export default () => ({ ...@@ -3,6 +3,7 @@ export default () => ({
currentBranchId: '', currentBranchId: '',
currentMergeRequestId: '', currentMergeRequestId: '',
changedFiles: [], changedFiles: [],
stagedFiles: [],
endpoints: {}, endpoints: {},
lastCommitMsg: '', lastCommitMsg: '',
lastCommitPath: '', lastCommitPath: '',
......
...@@ -15,6 +15,7 @@ export const dataStructure = () => ({ ...@@ -15,6 +15,7 @@ export const dataStructure = () => ({
opened: false, opened: false,
active: false, active: false,
changed: false, changed: false,
staged: false,
lastCommitPath: '', lastCommitPath: '',
lastCommit: { lastCommit: {
id: '', id: '',
...@@ -101,7 +102,7 @@ export const setPageTitle = title => { ...@@ -101,7 +102,7 @@ export const setPageTitle = title => {
export const createCommitPayload = (branch, newBranch, state, rootState) => ({ export const createCommitPayload = (branch, newBranch, state, rootState) => ({
branch, branch,
commit_message: state.commitMessage, commit_message: state.commitMessage,
actions: rootState.changedFiles.map(f => ({ actions: rootState.stagedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update', action: f.tempFile ? 'create' : 'update',
file_path: f.path, file_path: f.path,
content: f.content, content: f.content,
......
...@@ -30,10 +30,10 @@ export default class IssuableContext { ...@@ -30,10 +30,10 @@ export default class IssuableContext {
const $selectbox = $block.find('.selectbox'); const $selectbox = $block.find('.selectbox');
if ($selectbox.is(':visible')) { if ($selectbox.is(':visible')) {
$selectbox.hide(); $selectbox.hide();
$block.find('.value').show(); $block.find('.value:not(.dont-hide)').show();
} else { } else {
$selectbox.show(); $selectbox.show();
$block.find('.value').hide(); $block.find('.value:not(.dont-hide)').hide();
} }
if ($selectbox.is(':visible')) { if ($selectbox.is(':visible')) {
......
<script> <script>
import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import callout from '../../vue_shared/components/callout.vue';
export default { export default {
name: 'JobHeaderSection', name: 'JobHeaderSection',
components: { components: {
ciHeader, ciHeader,
loadingIcon, loadingIcon,
callout,
},
props: {
job: {
type: Object,
required: true,
}, },
props: { isLoading: {
job: { type: Boolean,
type: Object, required: true,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
}, },
data() { },
return { data() {
actions: this.getActions(), return {
}; actions: this.getActions(),
};
},
computed: {
status() {
return this.job && this.job.status;
}, },
computed: { shouldRenderContent() {
status() { return !this.isLoading && Object.keys(this.job).length;
return this.job && this.job.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length;
},
/**
* When job has not started the key will be `false`
* When job started the key will be a string with a date.
*/
jobStarted() {
return !this.job.started === false;
},
}, },
watch: { shouldRenderReason() {
job() { return !!(this.job.status && this.job.callout_message);
this.actions = this.getActions();
},
}, },
methods: { /**
getActions() { * When job has not started the key will be `false`
const actions = []; * When job started the key will be a string with a date.
*/
jobStarted() {
return !this.job.started === false;
},
},
watch: {
job() {
this.actions = this.getActions();
},
},
methods: {
getActions() {
const actions = [];
if (this.job.new_issue_path) { if (this.job.new_issue_path) {
actions.push({ actions.push({
label: 'New issue', label: 'New issue',
path: this.job.new_issue_path, path: this.job.new_issue_path,
cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block', cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
type: 'link', type: 'link',
}); });
} }
return actions; return actions;
},
}, },
}; },
};
</script> </script>
<template> <template>
<div class="js-build-header build-header top-area"> <header>
<ci-header <div class="js-build-header build-header top-area">
v-if="shouldRenderContent" <ci-header
:status="status" v-if="shouldRenderContent"
item-name="Job" :status="status"
:item-id="job.id" item-name="Job"
:time="job.created_at" :item-id="job.id"
:user="job.user" :time="job.created_at"
:actions="actions" :user="job.user"
:has-sidebar-button="true" :actions="actions"
:should-render-triggered-label="jobStarted" :has-sidebar-button="true"
/> :should-render-triggered-label="jobStarted"
<loading-icon />
v-if="isLoading" <loading-icon
size="2" v-if="isLoading"
class="prepend-top-default append-bottom-default" size="2"
class="prepend-top-default append-bottom-default"
/>
</div>
<callout
v-if="shouldRenderReason"
:message="job.callout_message"
/> />
</div> </header>
</template> </template>
<script> <script>
import detailRow from './sidebar_detail_row.vue'; import detailRow from './sidebar_detail_row.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago'; import timeagoMixin from '../../vue_shared/mixins/timeago';
import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
export default { export default {
name: 'SidebarDetailsBlock', name: 'SidebarDetailsBlock',
components: { components: {
detailRow, detailRow,
loadingIcon, loadingIcon,
},
mixins: [timeagoMixin],
props: {
job: {
type: Object,
required: true,
}, },
mixins: [ isLoading: {
timeagoMixin, type: Boolean,
], required: true,
props: {
job: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
runnerHelpUrl: {
type: String,
required: false,
default: '',
},
}, },
computed: { canUserRetry: {
shouldRenderContent() { type: Boolean,
return !this.isLoading && Object.keys(this.job).length > 0; required: false,
}, default: false,
coverage() { },
return `${this.job.coverage}%`; runnerHelpUrl: {
}, type: String,
duration() { required: false,
return timeIntervalInWords(this.job.duration); default: '',
}, },
queued() { },
return timeIntervalInWords(this.job.queued); computed: {
}, shouldRenderContent() {
runnerId() { return !this.isLoading && Object.keys(this.job).length > 0;
return `#${this.job.runner.id}`; },
}, coverage() {
hasTimeout() { return `${this.job.coverage}%`;
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; },
}, duration() {
timeout() { return timeIntervalInWords(this.job.duration);
if (this.job.metadata == null) { },
return ''; queued() {
} return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `#${this.job.runner.id}`;
},
retryButtonClass() {
let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block';
className +=
this.job.status && this.job.recoverable
? ' btn-primary'
: ' btn-inverted-secondary';
return className;
},
hasTimeout() {
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
},
timeout() {
if (this.job.metadata == null) {
return '';
}
let t = this.job.metadata.timeout_human_readable; let t = this.job.metadata.timeout_human_readable;
if (this.job.metadata.timeout_source !== '') { if (this.job.metadata.timeout_source !== '') {
t += ` (from ${this.job.metadata.timeout_source})`; t += ` (from ${this.job.metadata.timeout_source})`;
} }
return t; return t;
},
renderBlock() {
return this.job.merge_request ||
this.job.duration ||
this.job.finished_data ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length ||
this.job.cancel_path;
},
}, },
}; renderBlock() {
return (
this.job.merge_request ||
this.job.duration ||
this.job.finished_data ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length ||
this.job.cancel_path
);
},
},
};
</script> </script>
<template> <template>
<div> <div>
<div class="block">
<strong class="inline prepend-top-8">
{{ job.name }}
</strong>
<a
v-if="canUserRetry"
:class="retryButtonClass"
:href="job.retry_path"
data-method="post"
rel="nofollow"
>
{{ __('Retry') }}
</a>
<button
type="button"
:aria-label="__('Toggle Sidebar')"
class="btn btn-blank gutter-toggle pull-right
visible-xs-block visible-sm-block js-sidebar-build-toggle"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-angle-double-right"
></i>
</button>
</div>
<template v-if="shouldRenderContent"> <template v-if="shouldRenderContent">
<div <div
class="block retry-link" class="block retry-link"
...@@ -85,16 +124,16 @@ ...@@ -85,16 +124,16 @@
class="js-new-issue btn btn-new btn-inverted" class="js-new-issue btn btn-new btn-inverted"
:href="job.new_issue_path" :href="job.new_issue_path"
> >
New issue {{ __('New issue') }}
</a> </a>
<a <a
v-if="job.retry_path" v-if="canUserRetry"
class="js-retry-job btn btn-inverted-secondary" class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path" :href="job.retry_path"
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
> >
Retry {{ __('Retry') }}
</a> </a>
</div> </div>
<div :class="{block : renderBlock }"> <div :class="{block : renderBlock }">
...@@ -103,7 +142,7 @@ ...@@ -103,7 +142,7 @@
v-if="job.merge_request" v-if="job.merge_request"
> >
<span class="build-light-text"> <span class="build-light-text">
Merge Request: {{ __('Merge Request:') }}
</span> </span>
<a :href="job.merge_request.path"> <a :href="job.merge_request.path">
!{{ job.merge_request.iid }} !{{ job.merge_request.iid }}
...@@ -158,7 +197,7 @@ ...@@ -158,7 +197,7 @@
v-if="job.tags.length" v-if="job.tags.length"
> >
<span class="build-light-text"> <span class="build-light-text">
Tags: {{ __('Tags:') }}
</span> </span>
<span <span
v-for="(tag, i) in job.tags" v-for="(tag, i) in job.tags"
...@@ -178,7 +217,7 @@ ...@@ -178,7 +217,7 @@
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
> >
Cancel {{ __('Cancel') }}
</a> </a>
</div> </div>
</div> </div>
......
...@@ -35,9 +35,11 @@ export default () => { ...@@ -35,9 +35,11 @@ export default () => {
}); });
// Sidebar information block // Sidebar information block
const detailsBlockElement = document.getElementById('js-details-block-vue');
const detailsBlockDataset = detailsBlockElement.dataset;
// eslint-disable-next-line // eslint-disable-next-line
new Vue({ new Vue({
el: '#js-details-block-vue', el: detailsBlockElement,
components: { components: {
detailsBlock, detailsBlock,
}, },
...@@ -50,6 +52,7 @@ export default () => { ...@@ -50,6 +52,7 @@ export default () => {
return createElement('details-block', { return createElement('details-block', {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
canUserRetry: !!('canUserRetry' in detailsBlockDataset),
job: this.mediator.store.state.job, job: this.mediator.store.state.job,
runnerHelpUrl: dataset.runnerHelpUrl, runnerHelpUrl: dataset.runnerHelpUrl,
}, },
......
<script>
const calloutVariants = ['danger', 'success', 'info', 'warning'];
export default {
props: {
category: {
type: String,
required: false,
default: calloutVariants[0],
validator: value => calloutVariants.includes(value),
},
message: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
:class="`bs-callout bs-callout-${category}`"
role="alert"
aria-live="assertive"
>
{{ message }}
</div>
</template>
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
.nav-header-btn { .nav-header-btn {
padding: 10px $gl-sidebar-padding; padding: 10px $gl-sidebar-padding;
color: inherit; color: inherit;
transition-duration: .3s; transition-duration: 0.3s;
position: absolute; position: absolute;
top: 0; top: 0;
cursor: pointer; cursor: pointer;
...@@ -137,6 +137,12 @@ ...@@ -137,6 +137,12 @@
} }
} }
.issuable-sidebar .labels {
.value.dont-hide ~ .selectbox {
padding-top: $gl-padding-8;
}
}
.pikaday-container { .pikaday-container {
.pika-single { .pika-single {
margin-top: 2px; margin-top: 2px;
...@@ -151,4 +157,3 @@ ...@@ -151,4 +157,3 @@
.sidebar-collapsed-icon .sidebar-collapsed-value { .sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px; font-size: 12px;
} }
@keyframes fade-out-status { @keyframes fade-out-status {
0%, 50% { opacity: 1; } 0%,
100% { opacity: 0; } 50% {
opacity: 1;
}
100% {
opacity: 0;
}
} }
@keyframes blinking-dots { @keyframes blinking-dots {
0% { 0% {
background-color: rgba($white-light, 1); background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
25% { 25% {
background-color: rgba($white-light, 0.4); background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 2), box-shadow: 12px 0 0 0 rgba($white-light, 2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
75% { 75% {
background-color: rgba($white-light, 0.4); background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 1); 24px 0 0 0 rgba($white-light, 1);
} }
100% { 100% {
background-color: rgba($white-light, 1); background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
} }
@keyframes blinking-scroll-button { @keyframes blinking-scroll-button {
0% { opacity: 0.2; } 0% {
25% { opacity: 0.5; } opacity: 0.2;
50% { opacity: 0.7; } }
100% { opacity: 1; }
25% {
opacity: 0.5;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
} }
.build-page { .build-page {
...@@ -125,12 +142,12 @@ ...@@ -125,12 +142,12 @@
.btn-scroll.animate { .btn-scroll.animate {
.first-triangle { .first-triangle {
animation: blinking-scroll-button 1s ease infinite; animation: blinking-scroll-button 1s ease infinite;
animation-delay: .3s; animation-delay: 0.3s;
} }
.second-triangle { .second-triangle {
animation: blinking-scroll-button 1s ease infinite; animation: blinking-scroll-button 1s ease infinite;
animation-delay: .2s; animation-delay: 0.2s;
} }
.third-triangle { .third-triangle {
......
...@@ -68,6 +68,10 @@ ...@@ -68,6 +68,10 @@
.ide-file-changed-icon { .ide-file-changed-icon {
margin-left: auto; margin-left: auto;
> svg {
display: block;
}
} }
.ide-new-btn { .ide-new-btn {
...@@ -378,7 +382,11 @@ ...@@ -378,7 +382,11 @@
padding: $gl-bar-padding $gl-padding; padding: $gl-bar-padding $gl-padding;
background: $white-light; background: $white-light;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
> div + div {
padding-left: $gl-padding;
}
svg { svg {
vertical-align: middle; vertical-align: middle;
...@@ -521,9 +529,13 @@ ...@@ -521,9 +529,13 @@
overflow: auto; overflow: auto;
} }
.multi-file-commit-empty-state-container { .ide-commit-empty-state {
align-items: center; padding: 0 $gl-padding;
justify-content: center; }
.ide-commit-empty-state-container {
margin-top: auto;
margin-bottom: auto;
} }
.multi-file-commit-panel-header { .multi-file-commit-panel-header {
...@@ -532,35 +544,22 @@ ...@@ -532,35 +544,22 @@
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
padding: $gl-btn-padding 0; padding: $gl-btn-padding 0;
&.is-collapsed {
border-bottom: 1px solid $white-dark;
svg {
margin-left: auto;
margin-right: auto;
}
.multi-file-commit-panel-collapse-btn {
margin-right: auto;
margin-left: auto;
border-left: 0;
}
}
} }
.multi-file-commit-panel-header-title { .multi-file-commit-panel-header-title {
display: flex; display: flex;
flex: 1; flex: 1;
padding: 0 $gl-btn-padding; padding-left: $grid-size;
svg { svg {
margin-right: $gl-btn-padding; margin-right: $gl-btn-padding;
color: $theme-gray-700;
} }
} }
.multi-file-commit-panel-collapse-btn { .multi-file-commit-panel-collapse-btn {
border-left: 1px solid $white-dark; border-left: 1px solid $white-dark;
margin-left: auto;
} }
.multi-file-commit-list { .multi-file-commit-list {
...@@ -574,12 +573,14 @@ ...@@ -574,12 +573,14 @@
display: flex; display: flex;
padding: 0; padding: 0;
align-items: center; align-items: center;
border-radius: $border-radius-default;
.multi-file-discard-btn { .multi-file-discard-btn {
display: none; display: none;
margin-top: -2px;
margin-left: auto; margin-left: auto;
margin-right: $grid-size;
color: $gl-link-color; color: $gl-link-color;
padding: 0 2px;
&:focus, &:focus,
&:hover { &:hover {
...@@ -591,26 +592,31 @@ ...@@ -591,26 +592,31 @@
background: $white-normal; background: $white-normal;
.multi-file-discard-btn { .multi-file-discard-btn {
display: block; display: flex;
} }
} }
} }
.multi-file-addition { .multi-file-additions,
.multi-file-additions-solid {
fill: $green-500; fill: $green-500;
} }
.multi-file-modified { .multi-file-modified,
.multi-file-modified-solid {
fill: $orange-500; fill: $orange-500;
} }
.multi-file-commit-list-collapsed { .multi-file-commit-list-collapsed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: $gl-padding 0;
> svg { svg {
display: block;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
color: $theme-gray-700;
} }
.file-status-icon { .file-status-icon {
...@@ -622,7 +628,7 @@ ...@@ -622,7 +628,7 @@
.multi-file-commit-list-path { .multi-file-commit-list-path {
padding: $grid-size / 2; padding: $grid-size / 2;
padding-left: $gl-padding; padding-left: $grid-size;
background: none; background: none;
border: 0; border: 0;
text-align: left; text-align: left;
...@@ -807,6 +813,41 @@ ...@@ -807,6 +813,41 @@
} }
} }
.ide-commit-list-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 16px;
&:not(.is-collapsed) {
flex: 1;
min-height: 140px;
}
&.is-collapsed {
.multi-file-commit-panel-header {
margin-left: -$gl-padding;
margin-right: -$gl-padding;
svg {
margin-left: auto;
margin-right: auto;
}
.multi-file-commit-panel-collapse-btn {
margin-right: auto;
margin-left: auto;
border-left: 0;
}
}
}
}
.ide-staged-action-btn {
margin-left: auto;
color: $gl-link-color;
}
.ide-commit-radios { .ide-commit-radios {
label { label {
font-weight: normal; font-weight: normal;
......
...@@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController
result.merge!(trace.to_h) result.merge!(trace.to_h)
end end
result[:html] = result[:html].presence || 'No job log'
render json: result render json: result
end end
end end
......
...@@ -69,6 +69,11 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -69,6 +69,11 @@ class Projects::WikisController < Projects::ApplicationController
else else
render action: "edit" render action: "edit"
end end
rescue Gitlab::Git::Wiki::OperationError => e
@page = build_page(wiki_params)
@error = e
render 'edit'
end end
def history def history
...@@ -95,9 +100,13 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -95,9 +100,13 @@ class Projects::WikisController < Projects::ApplicationController
status: 302, status: 302,
notice: "Page was successfully deleted" notice: "Page was successfully deleted"
rescue Gitlab::Git::Wiki::OperationError => e rescue Gitlab::Git::Wiki::OperationError => e
<<<<<<< HEAD
@page = build_page(wiki_params) @page = build_page(wiki_params)
@error = e @error = e
=======
@error = e
>>>>>>> upstream/master
render 'edit' render 'edit'
end end
......
...@@ -134,10 +134,8 @@ class GroupDescendantsFinder ...@@ -134,10 +134,8 @@ class GroupDescendantsFinder
end end
def direct_child_projects def direct_child_projects
GroupProjectsFinder.new(group: parent_group, GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
current_user: current_user, .execute
options: { only_owned: true },
params: params).execute
end end
# Finds all projects nested under `parent_group` or any of its descendant # Finds all projects nested under `parent_group` or any of its descendant
......
...@@ -18,7 +18,10 @@ module Ci ...@@ -18,7 +18,10 @@ module Ci
after_save :update_file_store after_save :update_file_store
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) } scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
<<<<<<< HEAD
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
=======
>>>>>>> upstream/master
delegate :exists?, :open, to: :file delegate :exists?, :open, to: :file
......
...@@ -248,7 +248,7 @@ class Commit ...@@ -248,7 +248,7 @@ class Commit
end end
def notes_with_associations def notes_with_associations
notes.includes(:author) notes.includes(:author, :award_emoji)
end end
def merge_requests def merge_requests
......
...@@ -37,7 +37,20 @@ module GroupDescendant ...@@ -37,7 +37,20 @@ module GroupDescendant
parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id } parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
if parent.nil? && !child.parent_id.nil? if parent.nil? && !child.parent_id.nil?
raise ArgumentError.new('parent was not preloaded') parent = child.parent
exception = ArgumentError.new <<~MSG
parent: [GroupDescendant: #{parent.inspect}] was not preloaded for [#{child.inspect}]")
This error is not user facing, but causes a +1 query.
MSG
extras = {
parent: parent,
child: child,
preloaded: preloaded.map(&:full_path)
}
issue_url = 'https://gitlab.com/gitlab-org/gitlab-ce/issues/40785'
Gitlab::Sentry.track_exception(exception, issue_url: issue_url, extra: extras)
end end
if parent.nil? && hierarchy_top.present? if parent.nil? && hierarchy_top.present?
......
...@@ -39,9 +39,12 @@ class Issue < ActiveRecord::Base ...@@ -39,9 +39,12 @@ class Issue < ActiveRecord::Base
has_many :issue_assignees has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees
<<<<<<< HEAD
has_one :epic_issue has_one :epic_issue
has_one :epic, through: :epic_issue has_one :epic, through: :epic_issue
=======
>>>>>>> upstream/master
validates :project, presence: true validates :project, presence: true
......
...@@ -205,8 +205,11 @@ class Project < ActiveRecord::Base ...@@ -205,8 +205,11 @@ class Project < ActiveRecord::Base
has_one :cluster_project, class_name: 'Clusters::Project' has_one :cluster_project, class_name: 'Clusters::Project'
has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
<<<<<<< HEAD
has_many :prometheus_metrics has_many :prometheus_metrics
=======
>>>>>>> upstream/master
# Container repositories need to remove data from the container registry, # Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy # which is not managed by the DB. Hence we're still using dependent: :destroy
......
...@@ -6,10 +6,13 @@ class ProjectStatistics < ActiveRecord::Base ...@@ -6,10 +6,13 @@ class ProjectStatistics < ActiveRecord::Base
COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze
INCREMENTABLE_COLUMNS = [:build_artifacts_size].freeze INCREMENTABLE_COLUMNS = [:build_artifacts_size].freeze
<<<<<<< HEAD
def shared_runners_minutes def shared_runners_minutes
shared_runners_seconds.to_i / 60 shared_runners_seconds.to_i / 60
end end
=======
>>>>>>> upstream/master
def total_repository_size def total_repository_size
repository_size + lfs_objects_size repository_size + lfs_objects_size
......
...@@ -969,10 +969,13 @@ class User < ActiveRecord::Base ...@@ -969,10 +969,13 @@ class User < ActiveRecord::Base
end end
def manageable_groups def manageable_groups
union = Gitlab::SQL::Union.new([owned_groups.select(:id), union_sql = Gitlab::SQL::Union.new([owned_groups.select(:id), masters_groups.select(:id)]).to_sql
masters_groups.select(:id)])
arel_union = Arel::Nodes::SqlLiteral.new(union.to_sql) # Update this line to not use raw SQL when migrated to Rails 5.2.
owned_and_master_groups = Group.where(Group.arel_table[:id].in(arel_union)) # Either ActiveRecord or Arel constructions are fine.
# This was replaced with the raw SQL construction because of bugs in the arel gem.
# Bugs were fixed in arel 9.0.0 (Rails 5.2).
owned_and_master_groups = Group.where("namespaces.id IN (#{union_sql})") # rubocop:disable GitlabSecurity/SqlInjection
Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants Gitlab::GroupHierarchy.new(owned_and_master_groups).base_and_descendants
end end
......
module Ci module Ci
class BuildPresenter < Gitlab::View::Presenter::Delegated class BuildPresenter < Gitlab::View::Presenter::Delegated
CALLOUT_FAILURE_MESSAGES = {
unknown_failure: 'There is an unknown failure, please try again',
script_failure: 'There has been a script failure. Check the job log for more information',
api_failure: 'There has been an API failure, please try again',
stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again',
runner_system_failure: 'There has been a runner system failure, please try again',
missing_dependency_failure: 'There has been a missing dependency failure, check the job log for more information'
}.freeze
presents :build presents :build
def erased_by_user? def erased_by_user?
...@@ -35,6 +44,14 @@ module Ci ...@@ -35,6 +44,14 @@ module Ci
"#{subject.name} - #{detailed_status.status_tooltip}" "#{subject.name} - #{detailed_status.status_tooltip}"
end end
def callout_failure_message
CALLOUT_FAILURE_MESSAGES[failure_reason.to_sym]
end
def recoverable?
failed? && !unrecoverable?
end
private private
def tooltip_for_badge def tooltip_for_badge
...@@ -44,5 +61,9 @@ module Ci ...@@ -44,5 +61,9 @@ module Ci
def detailed_status def detailed_status
@detailed_status ||= subject.detailed_status(user) @detailed_status ||= subject.detailed_status(user)
end end
def unrecoverable?
script_failure? || missing_dependency_failure?
end
end end
end end
...@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity ...@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :detailed_status, as: :status, with: StatusEntity expose :detailed_status, as: :status, with: StatusEntity
expose :callout_message, if: -> (*) { failed? }
expose :recoverable, if: -> (*) { failed? }
private private
...@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity ...@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity
def path_to(route, build) def path_to(route, build)
send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
end end
def failed?
build.failed?
end
def callout_message
build_presenter.callout_failure_message
end
def recoverable
build_presenter.recoverable?
end
def build_presenter
@build_presenter ||= build.present
end
end end
...@@ -35,7 +35,7 @@ module Ci ...@@ -35,7 +35,7 @@ module Ci
end end
end end
builds.auto_include(false).find do |build| builds.find do |build|
next unless runner.can_pick?(build) next unless runner.can_pick?(build)
begin begin
......
...@@ -64,9 +64,14 @@ module Labels ...@@ -64,9 +64,14 @@ module Labels
end end
def update_label_links(labels, old_label_id:, new_label_id:) def update_label_links(labels, old_label_id:, new_label_id:)
LabelLink.joins(:label) # use 'labels' relation to get label_link ids only of issues/MRs
.merge(labels) # in the project being transferred.
.where(label_id: old_label_id) # IDs are fetched in a separate query because MySQL doesn't
# allow referring of 'label_links' table in UPDATE query:
# https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/62435068
link_ids = labels.pluck('label_links.id')
LabelLink.where(id: link_ids, label_id: old_label_id)
.update_all(label_id: new_label_id) .update_all(label_id: new_label_id)
end end
......
%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
.sidebar-container .sidebar-container
.blocks-container .blocks-container
.block
%strong.inline.prepend-top-8
= @build.name
- if can?(current_user, :update_build, @build) && @build.retryable?
= link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post
%a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
= icon('angle-double-right')
#js-details-block-vue #js-details-block-vue{ data: { can_user_retry: can?(current_user, :update_build, @build) && @build.retryable? } }
- if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
.block .block
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- if can_admin_issue? - if can_admin_issue?
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels .value.issuable-show-labels.dont-hide
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
None None
%a{ href: "#", %a{ href: "#",
......
...@@ -97,7 +97,7 @@ ...@@ -97,7 +97,7 @@
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable - if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any? - if selected_labels.any?
- selected_labels.each do |label| - selected_labels.each do |label|
= link_to_label(label, subject: issuable.project, type: issuable.to_ability_name) = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name)
......
---
title: Keep current labels visible when editing them in the sidebar
merge_request:
author:
type: changed
---
title: Remove branch name from the status bar of WebIDE
merge_request:
author:
type: changed
---
title: Show shared projects on group page
merge_request: 18390
author:
type: fixed
---
title: Triggering custom hooks by Wiki UI edit
merge_request: 18251
author:
type: fixed
---
title: Fix label links update on project transfer
merge_request:
author:
type: fixed
---
title: Fix N+1 queries when loading participants for a commit note
merge_request:
author:
type: performance
# This is a monkey patch which must be removed when migrating to Rails 5.1 from 5.0.
#
# In Rails 5.0 there was introduced a bug which casts types in the uniqueness validator.
# https://github.com/rails/rails/pull/23523/commits/811a4fa8eb6ceea841e61e8ac05747ffb69595ae
#
# That causes to bugs like this:
#
# 1) API::Users POST /user/:id/gpg_keys/:key_id/revoke when authenticated revokes existing key
# Failure/Error: let(:gpg_key) { create(:gpg_key, user: user) }
#
# TypeError:
# can't cast Hash
# # ./spec/requests/api/users_spec.rb:7:in `block (2 levels) in <top (required)>'
# # ./spec/requests/api/users_spec.rb:908:in `block (4 levels) in <top (required)>'
# # ------------------
# # --- Caused by: ---
# # TypeError:
# # TypeError
# # ./spec/requests/api/users_spec.rb:7:in `block (2 levels) in <top (required)>'
#
# This bug was fixed in Rails 5.1 by https://github.com/rails/rails/pull/24745/commits/aa062318c451512035c10898a1af95943b1a3803
if Gitlab.rails5?
ActiveSupport::Deprecation.warn("#{__FILE__} is a monkey patch which must be removed when upgrading to Rails 5.1")
if Rails.version.start_with?("5.1")
raise "Remove this monkey patch: #{__FILE__}"
end
# Copy-paste from https://github.com/kamipo/rails/blob/aa062318c451512035c10898a1af95943b1a3803/activerecord/lib/active_record/validations/uniqueness.rb
# including local fixes to make Rubocop happy again.
module ActiveRecord
module Validations
class UniquenessValidator < ActiveModel::EachValidator # :nodoc:
def validate_each(record, attribute, value)
finder_class = find_finder_class_for(record)
table = finder_class.arel_table
value = map_enum_attribute(finder_class, attribute, value)
relation = build_relation(finder_class, table, attribute, value)
if record.persisted?
if finder_class.primary_key
relation = relation.where.not(finder_class.primary_key => record.id_was || record.id)
else
raise UnknownPrimaryKey.new(finder_class, "Can not validate uniqueness for persisted record without primary key.")
end
end
relation = scope_relation(record, table, relation)
relation = relation.merge(options[:conditions]) if options[:conditions]
if relation.exists?
error_options = options.except(:case_sensitive, :scope, :conditions)
error_options[:value] = value
record.errors.add(attribute, :taken, error_options)
end
rescue RangeError
end
protected
def build_relation(klass, table, attribute, value) #:nodoc:
if reflection = klass._reflect_on_association(attribute)
attribute = reflection.foreign_key
value = value.attributes[reflection.klass.primary_key] unless value.nil?
end
# the attribute may be an aliased attribute
if klass.attribute_alias?(attribute)
attribute = klass.attribute_alias(attribute)
end
attribute_name = attribute.to_s
column = klass.columns_hash[attribute_name]
cast_type = klass.type_for_attribute(attribute_name)
comparison =
if !options[:case_sensitive] && !value.nil?
# will use SQL LOWER function before comparison, unless it detects a case insensitive collation
klass.connection.case_insensitive_comparison(table, attribute, column, value)
else
klass.connection.case_sensitive_comparison(table, attribute, column, value)
end
if value.nil?
klass.unscoped.where(comparison)
else
bind = Relation::QueryAttribute.new(attribute_name, value, cast_type)
klass.unscoped.where(comparison, bind)
end
end
end
end
end
end
...@@ -75,7 +75,7 @@ cancel the job, retry it, or erase the job trace. ...@@ -75,7 +75,7 @@ cancel the job, retry it, or erase the job trace.
## Seeing the failure reason for jobs ## Seeing the failure reason for jobs
> [Introduced][ce-5742] in GitLab 10.7. > [Introduced][ce-17782] in GitLab 10.7.
When a pipeline fails or is allowed to fail, there are several places where you When a pipeline fails or is allowed to fail, there are several places where you
can quickly check the reason it failed: can quickly check the reason it failed:
...@@ -88,6 +88,8 @@ In any case, if you hover over the failed job you can see the reason it failed. ...@@ -88,6 +88,8 @@ In any case, if you hover over the failed job you can see the reason it failed.
![Pipeline detail](img/job_failure_reason.png) ![Pipeline detail](img/job_failure_reason.png)
From [GitLab 10.8][ce-17814] you can also see the reason it failed on the Job detail page.
## Pipeline graphs ## Pipeline graphs
> [Introduced][ce-5742] in GitLab 8.11. > [Introduced][ce-5742] in GitLab 8.11.
...@@ -284,6 +286,7 @@ runners will not use regular runners, they must be tagged accordingly. ...@@ -284,6 +286,7 @@ runners will not use regular runners, they must be tagged accordingly.
[ce-7931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7931 [ce-7931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7931
[ce-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760 [ce-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760
[ce-17782]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17782 [ce-17782]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17782
[ce-17814]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17814
[regexp]: https://gitlab.com/gitlab-org/gitlab-ce/blob/2f3dc314f42dbd79813e6251792853bc231e69dd/app/models/commit_status.rb#L99 [regexp]: https://gitlab.com/gitlab-org/gitlab-ce/blob/2f3dc314f42dbd79813e6251792853bc231e69dd/app/models/commit_status.rb#L99
[eep]: https://about.gitlab.com/products/ "GitLab Premium" [eep]: https://about.gitlab.com/products/ "GitLab Premium"
[ee-2121]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2121 [ee-2121]: https://gitlab.com/gitlab-org/gitlab-ee/issues/2121
...@@ -524,7 +524,7 @@ export default new Vuex.Store({ ...@@ -524,7 +524,7 @@ export default new Vuex.Store({
_Note:_ If the state of the application is too complex, an individual file for the state may be better. _Note:_ If the state of the application is too complex, an individual file for the state may be better.
##### `actions.js` ##### `actions.js`
An action commits a mutatation. In this file, we will write the actions that will call the respective mutation: An action commits a mutation. In this file, we will write the actions that will commit the respective mutation:
```javascript ```javascript
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -661,7 +661,7 @@ describe('component', () => { ...@@ -661,7 +661,7 @@ describe('component', () => {
}; };
// populate the store // populate the store
store.dipatch('addUser', user); store.dispatch('addUser', user);
vm = new Component({ vm = new Component({
store, store,
......
...@@ -20,6 +20,10 @@ module Gitlab ...@@ -20,6 +20,10 @@ module Gitlab
subject subject
end end
def present(**attributes)
self
end
class_methods do class_methods do
def presenter? def presenter?
true true
......
...@@ -190,7 +190,10 @@ describe Projects::JobsController do ...@@ -190,7 +190,10 @@ describe Projects::JobsController do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq job.id expect(json_response['id']).to eq job.id
expect(json_response['status']).to eq job.status expect(json_response['status']).to eq job.status
expect(json_response['html']).to be_nil end
it 'returns no job log message' do
expect(json_response['html']).to eq('No job log')
end end
end end
......
...@@ -243,5 +243,10 @@ FactoryBot.define do ...@@ -243,5 +243,10 @@ FactoryBot.define do
failed failed
failure_reason 1 failure_reason 1
end end
trait :api_failure do
failed
failure_reason 2
end
end end
end end
...@@ -238,6 +238,22 @@ describe 'Issue Boards', :js do ...@@ -238,6 +238,22 @@ describe 'Issue Boards', :js do
end end
context 'labels' do context 'labels' do
it 'shows current labels when editing' do
click_card(card)
page.within('.labels') do
click_link 'Edit'
wait_for_requests
page.within('.value') do
expect(page).to have_selector('.label', count: 2)
expect(page).to have_content(development.title)
expect(page).to have_content(stretch.title)
end
end
end
it 'adds a single label' do it 'adds a single label' do
click_card(card) click_card(card)
...@@ -297,7 +313,9 @@ describe 'Issue Boards', :js do ...@@ -297,7 +313,9 @@ describe 'Issue Boards', :js do
wait_for_requests wait_for_requests
click_link stretch.title within('.dropdown-menu-labels') do
click_link stretch.title
end
wait_for_requests wait_for_requests
......
...@@ -5,9 +5,9 @@ feature 'Issue Sidebar' do ...@@ -5,9 +5,9 @@ feature 'Issue Sidebar' do
let(:group) { create(:group, :nested) } let(:group) { create(:group, :nested) }
let(:project) { create(:project, :public, namespace: group) } let(:project) { create(:project, :public, namespace: group) }
let(:issue) { create(:issue, project: project) }
let!(:user) { create(:user)} let!(:user) { create(:user)}
let!(:label) { create(:label, project: project, title: 'bug') } let!(:label) { create(:label, project: project, title: 'bug') }
let(:issue) { create(:labeled_issue, project: project, labels: [label]) }
let!(:xss_label) { create(:label, project: project, title: '&lt;script&gt;alert("xss");&lt;&#x2F;script&gt;') } let!(:xss_label) { create(:label, project: project, title: '&lt;script&gt;alert("xss");&lt;&#x2F;script&gt;') }
before do before do
...@@ -112,11 +112,18 @@ feature 'Issue Sidebar' do ...@@ -112,11 +112,18 @@ feature 'Issue Sidebar' do
context 'editing issue labels', :js do context 'editing issue labels', :js do
before do before do
issue.update_attributes(labels: [label])
page.within('.block.labels') do page.within('.block.labels') do
find('.edit-link').click find('.edit-link').click
end end
end end
it 'shows the current set of labels' do
page.within('.issuable-show-labels') do
expect(page).to have_content label.title
end
end
it 'shows option to create a project label' do it 'shows option to create a project label' do
page.within('.block.labels') do page.within('.block.labels') do
expect(page).to have_content 'Create project' expect(page).to have_content 'Create project'
......
...@@ -502,16 +502,18 @@ feature 'Jobs' do ...@@ -502,16 +502,18 @@ feature 'Jobs' do
end end
end end
describe "POST /:project/jobs/:id/retry" do describe "POST /:project/jobs/:id/retry", :js do
context "Job from project", :js do context "Job from project", :js do
before do before do
job.run! job.run!
job.cancel!
visit project_job_path(project, job) visit project_job_path(project, job)
find('.js-cancel-job').click() wait_for_requests
find('.js-retry-button').click find('.js-retry-button').click
end end
it 'shows the right status and buttons', :js do it 'shows the right status and buttons' do
page.within('aside.right-sidebar') do page.within('aside.right-sidebar') do
expect(page).to have_content 'Cancel' expect(page).to have_content 'Cancel'
end end
......
...@@ -44,6 +44,8 @@ feature 'Multi-file editor new directory', :js do ...@@ -44,6 +44,8 @@ feature 'Multi-file editor new directory', :js do
wait_for_requests wait_for_requests
click_button 'Stage all'
fill_in('commit-message', with: 'commit message ide') fill_in('commit-message', with: 'commit message ide')
click_button('Commit') click_button('Commit')
......
...@@ -34,6 +34,8 @@ feature 'Multi-file editor new file', :js do ...@@ -34,6 +34,8 @@ feature 'Multi-file editor new file', :js do
wait_for_requests wait_for_requests
click_button 'Stage all'
fill_in('commit-message', with: 'commit message ide') fill_in('commit-message', with: 'commit message ide')
click_button('Commit') click_button('Commit')
......
...@@ -35,15 +35,6 @@ describe GroupDescendantsFinder do ...@@ -35,15 +35,6 @@ describe GroupDescendantsFinder do
expect(finder.execute).to contain_exactly(project) expect(finder.execute).to contain_exactly(project)
end end
it 'does not include projects shared with the group' do
project = create(:project, namespace: group)
other_project = create(:project)
other_project.project_group_links.create(group: group,
group_access: ProjectGroupLink::MASTER)
expect(finder.execute).to contain_exactly(project)
end
context 'when archived is `true`' do context 'when archived is `true`' do
let(:params) { { archived: 'true' } } let(:params) { { archived: 'true' } }
......
import Vue from 'vue';
import store from '~/ide/stores';
import emptyState from '~/ide/components/commit_sidebar/empty_state.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('IDE commit panel empty state', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(emptyState);
vm = createComponentWithStore(Component, store, {
noChangesStateSvgPath: 'no-changes',
committedStateSvgPath: 'committed-state',
});
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
describe('statusSvg', () => {
it('uses noChangesStateSvgPath when commit message is empty', () => {
expect(vm.statusSvg).toBe('no-changes');
expect(vm.$el.querySelector('img').getAttribute('src')).toBe(
'no-changes',
);
});
it('uses committedStateSvgPath when commit message exists', done => {
vm.$store.state.lastCommitMsg = 'testing';
Vue.nextTick(() => {
expect(vm.statusSvg).toBe('committed-state');
expect(vm.$el.querySelector('img').getAttribute('src')).toBe(
'committed-state',
);
done();
});
});
});
it('renders no changes text when last commit message is empty', () => {
expect(vm.$el.textContent).toContain('No changes');
});
it('renders last commit message when it exists', done => {
vm.$store.state.lastCommitMsg = 'testing commit message';
Vue.nextTick(() => {
expect(vm.$el.textContent).toContain('testing commit message');
done();
});
});
describe('toggle button', () => {
it('calls store action', () => {
spyOn(vm, 'toggleRightPanelCollapsed');
vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
expect(vm.toggleRightPanelCollapsed).toHaveBeenCalled();
});
it('renders collapsed class', done => {
vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
done();
});
});
});
describe('collapsed state', () => {
beforeEach(done => {
vm.$store.state.rightPanelCollapsed = true;
Vue.nextTick(done);
});
it('does not render text & svg', () => {
expect(vm.$el.querySelector('img')).toBeNull();
expect(vm.$el.textContent).not.toContain('No changes');
});
});
});
...@@ -11,10 +11,17 @@ describe('Multi-file editor commit sidebar list collapsed', () => { ...@@ -11,10 +11,17 @@ describe('Multi-file editor commit sidebar list collapsed', () => {
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(listCollapsed); const Component = Vue.extend(listCollapsed);
vm = createComponentWithStore(Component, store); vm = createComponentWithStore(Component, store, {
files: [
vm.$store.state.changedFiles.push(file('file1'), file('file2')); {
vm.$store.state.changedFiles[0].tempFile = true; ...file('file1'),
tempFile: true,
},
file('file2'),
],
iconName: 'staged',
title: 'Staged',
});
vm.$mount(); vm.$mount();
}); });
...@@ -26,4 +33,40 @@ describe('Multi-file editor commit sidebar list collapsed', () => { ...@@ -26,4 +33,40 @@ describe('Multi-file editor commit sidebar list collapsed', () => {
it('renders added & modified files count', () => { it('renders added & modified files count', () => {
expect(removeWhitespace(vm.$el.textContent).trim()).toBe('1 1'); expect(removeWhitespace(vm.$el.textContent).trim()).toBe('1 1');
}); });
describe('addedFilesLength', () => {
it('returns an length of temp files', () => {
expect(vm.addedFilesLength).toBe(1);
});
});
describe('modifiedFilesLength', () => {
it('returns an length of modified files', () => {
expect(vm.modifiedFilesLength).toBe(1);
});
});
describe('addedFilesIconClass', () => {
it('includes multi-file-addition when addedFiles is not empty', () => {
expect(vm.addedFilesIconClass).toContain('multi-file-addition');
});
it('excludes multi-file-addition when addedFiles is empty', () => {
vm.files = [];
expect(vm.addedFilesIconClass).not.toContain('multi-file-addition');
});
});
describe('modifiedFilesClass', () => {
it('includes multi-file-modified when addedFiles is not empty', () => {
expect(vm.modifiedFilesClass).toContain('multi-file-modified');
});
it('excludes multi-file-modified when addedFiles is empty', () => {
vm.files = [];
expect(vm.modifiedFilesClass).not.toContain('multi-file-modified');
});
});
}); });
import Vue from 'vue'; import Vue from 'vue';
import store from '~/ide/stores';
import listItem from '~/ide/components/commit_sidebar/list_item.vue'; import listItem from '~/ide/components/commit_sidebar/list_item.vue';
import router from '~/ide/ide_router'; import router from '~/ide/ide_router';
import store from '~/ide/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { file, resetStore } from '../../helpers'; import { file, resetStore } from '../../helpers';
...@@ -18,6 +18,7 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -18,6 +18,7 @@ describe('Multi-file editor commit sidebar list item', () => {
vm = createComponentWithStore(Component, store, { vm = createComponentWithStore(Component, store, {
file: f, file: f,
actionComponent: 'stage-button',
}).$mount(); }).$mount();
}); });
...@@ -31,22 +32,18 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -31,22 +32,18 @@ describe('Multi-file editor commit sidebar list item', () => {
expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path);
}); });
it('calls discardFileChanges when clicking discard button', () => { it('renders actionn button', () => {
spyOn(vm, 'discardFileChanges'); expect(vm.$el.querySelector('.multi-file-discard-btn')).not.toBeNull();
vm.$el.querySelector('.multi-file-discard-btn').click();
expect(vm.discardFileChanges).toHaveBeenCalled();
}); });
it('opens a closed file in the editor when clicking the file path', done => { it('opens a closed file in the editor when clicking the file path', done => {
spyOn(vm, 'openFileInEditor').and.callThrough(); spyOn(vm, 'openPendingTab').and.callThrough();
spyOn(router, 'push'); spyOn(router, 'push');
vm.$el.querySelector('.multi-file-commit-list-path').click(); vm.$el.querySelector('.multi-file-commit-list-path').click();
setTimeout(() => { setTimeout(() => {
expect(vm.openFileInEditor).toHaveBeenCalled(); expect(vm.openPendingTab).toHaveBeenCalled();
expect(router.push).toHaveBeenCalled(); expect(router.push).toHaveBeenCalled();
done(); done();
......
...@@ -2,7 +2,7 @@ import Vue from 'vue'; ...@@ -2,7 +2,7 @@ import Vue from 'vue';
import store from '~/ide/stores'; import store from '~/ide/stores';
import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { file } from '../../helpers'; import { file, resetStore } from '../../helpers';
describe('Multi-file editor commit sidebar list', () => { describe('Multi-file editor commit sidebar list', () => {
let vm; let vm;
...@@ -13,6 +13,10 @@ describe('Multi-file editor commit sidebar list', () => { ...@@ -13,6 +13,10 @@ describe('Multi-file editor commit sidebar list', () => {
vm = createComponentWithStore(Component, store, { vm = createComponentWithStore(Component, store, {
title: 'Staged', title: 'Staged',
fileList: [], fileList: [],
iconName: 'staged',
action: 'stageAllChanges',
actionBtnText: 'stage all',
itemActionComponent: 'stage-button',
}); });
vm.$store.state.rightPanelCollapsed = false; vm.$store.state.rightPanelCollapsed = false;
...@@ -22,6 +26,8 @@ describe('Multi-file editor commit sidebar list', () => { ...@@ -22,6 +26,8 @@ describe('Multi-file editor commit sidebar list', () => {
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
resetStore(vm.$store);
}); });
describe('with a list of files', () => { describe('with a list of files', () => {
...@@ -38,6 +44,12 @@ describe('Multi-file editor commit sidebar list', () => { ...@@ -38,6 +44,12 @@ describe('Multi-file editor commit sidebar list', () => {
}); });
}); });
describe('empty files array', () => {
it('renders no changes text when empty', () => {
expect(vm.$el.textContent).toContain('No changes');
});
});
describe('collapsed', () => { describe('collapsed', () => {
beforeEach(done => { beforeEach(done => {
vm.$store.state.rightPanelCollapsed = true; vm.$store.state.rightPanelCollapsed = true;
...@@ -50,4 +62,32 @@ describe('Multi-file editor commit sidebar list', () => { ...@@ -50,4 +62,32 @@ describe('Multi-file editor commit sidebar list', () => {
expect(vm.$el.querySelector('.help-block')).toBeNull(); expect(vm.$el.querySelector('.help-block')).toBeNull();
}); });
}); });
describe('with toggle', () => {
beforeEach(done => {
spyOn(vm, 'toggleRightPanelCollapsed');
vm.showToggle = true;
Vue.nextTick(done);
});
it('calls setPanelCollapsedStatus when clickin toggle', () => {
vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
expect(vm.toggleRightPanelCollapsed).toHaveBeenCalled();
});
});
describe('action button', () => {
beforeEach(() => {
spyOn(vm, 'stageAllChanges');
});
it('calls store action when clicked', () => {
vm.$el.querySelector('.ide-staged-action-btn').click();
expect(vm.stageAllChanges).toHaveBeenCalled();
});
});
}); });
import Vue from 'vue';
import store from '~/ide/stores';
import stageButton from '~/ide/components/commit_sidebar/stage_button.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { file, resetStore } from '../../helpers';
describe('IDE stage file button', () => {
let vm;
let f;
beforeEach(() => {
const Component = Vue.extend(stageButton);
f = file();
vm = createComponentWithStore(Component, store, {
path: f.path,
});
spyOn(vm, 'stageChange');
spyOn(vm, 'discardFileChanges');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders button to discard & stage', () => {
expect(vm.$el.querySelectorAll('.btn').length).toBe(2);
});
it('calls store with stage button', () => {
vm.$el.querySelectorAll('.btn')[0].click();
expect(vm.stageChange).toHaveBeenCalledWith(f.path);
});
it('calls store with discard button', () => {
vm.$el.querySelectorAll('.btn')[1].click();
expect(vm.discardFileChanges).toHaveBeenCalledWith(f.path);
});
});
import Vue from 'vue';
import store from '~/ide/stores';
import unstageButton from '~/ide/components/commit_sidebar/unstage_button.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { file, resetStore } from '../../helpers';
describe('IDE unstage file button', () => {
let vm;
let f;
beforeEach(() => {
const Component = Vue.extend(unstageButton);
f = file();
vm = createComponentWithStore(Component, store, {
path: f.path,
});
spyOn(vm, 'unstageChange');
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders button to unstage', () => {
expect(vm.$el.querySelectorAll('.btn').length).toBe(1);
});
it('calls store with unnstage button', () => {
vm.$el.querySelector('.btn').click();
expect(vm.unstageChange).toHaveBeenCalledWith(f.path);
});
});
...@@ -28,16 +28,34 @@ describe('RepoCommitSection', () => { ...@@ -28,16 +28,34 @@ describe('RepoCommitSection', () => {
}, },
}; };
const files = [file('file1'), file('file2')].map(f =>
Object.assign(f, {
type: 'blob',
}),
);
vm.$store.state.rightPanelCollapsed = false; vm.$store.state.rightPanelCollapsed = false;
vm.$store.state.currentBranch = 'master'; vm.$store.state.currentBranch = 'master';
vm.$store.state.changedFiles = [file('file1'), file('file2')]; vm.$store.state.changedFiles = [...files];
vm.$store.state.changedFiles.forEach(f => vm.$store.state.changedFiles.forEach(f =>
Object.assign(f, {
changed: true,
content: 'changedFile testing',
}),
);
vm.$store.state.stagedFiles = [{ ...files[0] }, { ...files[1] }];
vm.$store.state.stagedFiles.forEach(f =>
Object.assign(f, { Object.assign(f, {
changed: true, changed: true,
content: 'testing', content: 'testing',
}), }),
); );
vm.$store.state.changedFiles.forEach(f => {
vm.$store.state.entries[f.path] = f;
});
return vm.$mount(); return vm.$mount();
} }
...@@ -94,20 +112,93 @@ describe('RepoCommitSection', () => { ...@@ -94,20 +112,93 @@ describe('RepoCommitSection', () => {
...vm.$el.querySelectorAll('.multi-file-commit-list li'), ...vm.$el.querySelectorAll('.multi-file-commit-list li'),
]; ];
const submitCommit = vm.$el.querySelector('form .btn'); const submitCommit = vm.$el.querySelector('form .btn');
const allFiles = vm.$store.state.changedFiles.concat(
vm.$store.state.stagedFiles,
);
expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
expect(changedFileElements.length).toEqual(2); expect(changedFileElements.length).toEqual(4);
changedFileElements.forEach((changedFile, i) => { changedFileElements.forEach((changedFile, i) => {
expect(changedFile.textContent.trim()).toContain( expect(changedFile.textContent.trim()).toContain(allFiles[i].path);
vm.$store.state.changedFiles[i].path,
);
}); });
expect(submitCommit.disabled).toBeTruthy(); expect(submitCommit.disabled).toBeTruthy();
expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
}); });
it('adds changed files into staged files', done => {
vm.$el.querySelector('.ide-staged-action-btn').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.ide-commit-list-container').textContent,
).toContain('No changes');
done();
});
});
it('stages a single file', done => {
vm.$el.querySelector('.multi-file-discard-btn .btn').click();
Vue.nextTick(() => {
expect(
vm.$el
.querySelector('.ide-commit-list-container')
.querySelectorAll('li').length,
).toBe(1);
done();
});
});
it('discards a single file', done => {
vm.$el.querySelectorAll('.multi-file-discard-btn .btn')[1].click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.ide-commit-list-container').textContent,
).not.toContain('file1');
expect(
vm.$el
.querySelector('.ide-commit-list-container')
.querySelectorAll('li').length,
).toBe(1);
done();
});
});
it('removes all staged files', done => {
vm.$el.querySelectorAll('.ide-staged-action-btn')[1].click();
Vue.nextTick(() => {
expect(
vm.$el.querySelectorAll('.ide-commit-list-container')[1].textContent,
).toContain('No changes');
done();
});
});
it('unstages a single file', done => {
vm.$el
.querySelectorAll('.multi-file-discard-btn')[2]
.querySelector('.btn')
.click();
Vue.nextTick(() => {
expect(
vm.$el
.querySelectorAll('.ide-commit-list-container')[1]
.querySelectorAll('li').length,
).toBe(1);
done();
});
});
it('updates commitMessage in store on input', done => { it('updates commitMessage in store on input', done => {
const textarea = vm.$el.querySelector('textarea'); const textarea = vm.$el.querySelector('textarea');
......
...@@ -200,7 +200,7 @@ describe('RepoEditor', () => { ...@@ -200,7 +200,7 @@ describe('RepoEditor', () => {
vm.setupEditor(); vm.setupEditor();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file); expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null);
expect(vm.model).not.toBeNull(); expect(vm.model).not.toBeNull();
}); });
...@@ -222,7 +222,7 @@ describe('RepoEditor', () => { ...@@ -222,7 +222,7 @@ describe('RepoEditor', () => {
vm.setupEditor(); vm.setupEditor();
expect(vm.editor.onPositionChange).toHaveBeenCalled(); expect(vm.editor.onPositionChange).toHaveBeenCalled();
expect(vm.model.events.size).toBe(1); expect(vm.model.events.size).toBe(2);
}); });
it('updates state when model content changed', done => { it('updates state when model content changed', done => {
...@@ -234,6 +234,20 @@ describe('RepoEditor', () => { ...@@ -234,6 +234,20 @@ describe('RepoEditor', () => {
done(); done();
}); });
}); });
it('sets head model as staged file', () => {
spyOn(vm.editor, 'createModel').and.callThrough();
Editor.editorInstance.modelManager.dispose();
vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
vm.file.staged = true;
vm.file.key = `unstaged-${vm.file.key}`;
vm.setupEditor();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
});
}); });
describe('editor updateDimensions', () => { describe('editor updateDimensions', () => {
......
...@@ -30,6 +30,19 @@ describe('Multi-file editor library model', () => { ...@@ -30,6 +30,19 @@ describe('Multi-file editor library model', () => {
expect(model.baseModel).not.toBeNull(); expect(model.baseModel).not.toBeNull();
}); });
it('creates model with head file to compare against', () => {
const f = file('path');
model.dispose();
model = new Model(monaco, f, {
...f,
content: '123 testing',
});
expect(model.head).not.toBeNull();
expect(model.getOriginalModel().getValue()).toBe('123 testing');
});
it('adds eventHub listener', () => { it('adds eventHub listener', () => {
expect(eventHub.$on).toHaveBeenCalledWith( expect(eventHub.$on).toHaveBeenCalledWith(
`editor.update.model.dispose.${model.file.key}`, `editor.update.model.dispose.${model.file.key}`,
...@@ -70,13 +83,6 @@ describe('Multi-file editor library model', () => { ...@@ -70,13 +83,6 @@ describe('Multi-file editor library model', () => {
}); });
describe('onChange', () => { describe('onChange', () => {
it('caches event by path', () => {
model.onChange(() => {});
expect(model.events.size).toBe(1);
expect(model.events.keys().next().value).toBe(model.file.key);
});
it('calls callback on change', done => { it('calls callback on change', done => {
const spy = jasmine.createSpy(); const spy = jasmine.createSpy();
model.onChange(spy); model.onChange(spy);
...@@ -119,5 +125,15 @@ describe('Multi-file editor library model', () => { ...@@ -119,5 +125,15 @@ describe('Multi-file editor library model', () => {
jasmine.anything(), jasmine.anything(),
); );
}); });
it('calls onDispose callback', () => {
const disposeSpy = jasmine.createSpy();
model.onDispose(disposeSpy);
model.dispose();
expect(disposeSpy).toHaveBeenCalled();
});
}); });
}); });
...@@ -117,4 +117,33 @@ describe('Multi-file editor library decorations controller', () => { ...@@ -117,4 +117,33 @@ describe('Multi-file editor library decorations controller', () => {
expect(controller.editorDecorations.size).toBe(0); expect(controller.editorDecorations.size).toBe(0);
}); });
}); });
describe('hasDecorations', () => {
it('returns true when decorations are cached', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.hasDecorations(model)).toBe(true);
});
it('returns false when no model decorations exist', () => {
expect(controller.hasDecorations(model)).toBe(false);
});
});
describe('removeDecorations', () => {
beforeEach(() => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.decorate(model);
});
it('removes cached decorations', () => {
expect(controller.decorations.size).not.toBe(0);
expect(controller.editorDecorations.size).not.toBe(0);
controller.removeDecorations(model);
expect(controller.decorations.size).toBe(0);
expect(controller.editorDecorations.size).toBe(0);
});
});
}); });
...@@ -3,10 +3,7 @@ import monacoLoader from '~/ide/monaco_loader'; ...@@ -3,10 +3,7 @@ import monacoLoader from '~/ide/monaco_loader';
import editor from '~/ide/lib/editor'; import editor from '~/ide/lib/editor';
import ModelManager from '~/ide/lib/common/model_manager'; import ModelManager from '~/ide/lib/common/model_manager';
import DecorationsController from '~/ide/lib/decorations/controller'; import DecorationsController from '~/ide/lib/decorations/controller';
import DirtyDiffController, { import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller';
getDiffChangeType,
getDecorator,
} from '~/ide/lib/diff/controller';
import { computeDiff } from '~/ide/lib/diff/diff'; import { computeDiff } from '~/ide/lib/diff/diff';
import { file } from '../../helpers'; import { file } from '../../helpers';
...@@ -90,6 +87,14 @@ describe('Multi-file editor library dirty diff controller', () => { ...@@ -90,6 +87,14 @@ describe('Multi-file editor library dirty diff controller', () => {
expect(model.onChange).toHaveBeenCalled(); expect(model.onChange).toHaveBeenCalled();
}); });
it('adds dispose event callback', () => {
spyOn(model, 'onDispose');
controller.attachModel(model);
expect(model.onDispose).toHaveBeenCalled();
});
it('calls throttledComputeDiff on change', () => { it('calls throttledComputeDiff on change', () => {
spyOn(controller, 'throttledComputeDiff'); spyOn(controller, 'throttledComputeDiff');
...@@ -99,6 +104,12 @@ describe('Multi-file editor library dirty diff controller', () => { ...@@ -99,6 +104,12 @@ describe('Multi-file editor library dirty diff controller', () => {
expect(controller.throttledComputeDiff).toHaveBeenCalled(); expect(controller.throttledComputeDiff).toHaveBeenCalled();
}); });
it('caches model', () => {
controller.attachModel(model);
expect(controller.models.has(model.url)).toBe(true);
});
}); });
describe('computeDiff', () => { describe('computeDiff', () => {
...@@ -116,14 +127,22 @@ describe('Multi-file editor library dirty diff controller', () => { ...@@ -116,14 +127,22 @@ describe('Multi-file editor library dirty diff controller', () => {
}); });
describe('reDecorate', () => { describe('reDecorate', () => {
it('calls decorations controller decorate', () => { it('calls computeDiff when no decorations are cached', () => {
spyOn(controller, 'computeDiff');
controller.reDecorate(model);
expect(controller.computeDiff).toHaveBeenCalledWith(model);
});
it('calls decorate when decorations are cached', () => {
spyOn(controller.decorationsController, 'decorate'); spyOn(controller.decorationsController, 'decorate');
controller.decorationsController.decorations.set(model.url, 'test');
controller.reDecorate(model); controller.reDecorate(model);
expect(controller.decorationsController.decorate).toHaveBeenCalledWith( expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
model,
);
}); });
}); });
...@@ -133,16 +152,15 @@ describe('Multi-file editor library dirty diff controller', () => { ...@@ -133,16 +152,15 @@ describe('Multi-file editor library dirty diff controller', () => {
controller.decorate({ data: { changes: [], path: model.path } }); controller.decorate({ data: { changes: [], path: model.path } });
expect( expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(
controller.decorationsController.addDecorations, model,
).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); 'dirtyDiff',
jasmine.anything(),
);
}); });
it('adds decorations into editor', () => { it('adds decorations into editor', () => {
const spy = spyOn( const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
controller.decorationsController.editor.instance,
'deltaDecorations',
);
controller.decorate({ controller.decorate({
data: { changes: computeDiff('123', '1234'), path: model.path }, data: { changes: computeDiff('123', '1234'), path: model.path },
...@@ -181,16 +199,22 @@ describe('Multi-file editor library dirty diff controller', () => { ...@@ -181,16 +199,22 @@ describe('Multi-file editor library dirty diff controller', () => {
}); });
it('removes worker event listener', () => { it('removes worker event listener', () => {
spyOn( spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough();
controller.dirtyDiffWorker,
'removeEventListener',
).and.callThrough();
controller.dispose(); controller.dispose();
expect( expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith(
controller.dirtyDiffWorker.removeEventListener, 'message',
).toHaveBeenCalledWith('message', jasmine.anything()); jasmine.anything(),
);
});
it('clears cached models', () => {
controller.attachModel(model);
model.dispose();
expect(controller.models.size).toBe(0);
}); });
}); });
}); });
...@@ -88,7 +88,7 @@ describe('Multi-file editor library', () => { ...@@ -88,7 +88,7 @@ describe('Multi-file editor library', () => {
instance.createModel('FILE'); instance.createModel('FILE');
expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE', null);
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import store from '~/ide/stores'; import store from '~/ide/stores';
import * as actions from '~/ide/stores/actions/file';
import * as types from '~/ide/stores/mutation_types';
import service from '~/ide/services'; import service from '~/ide/services';
import router from '~/ide/ide_router'; import router from '~/ide/ide_router';
import eventHub from '~/ide/eventhub'; import eventHub from '~/ide/eventhub';
import { file, resetStore } from '../../helpers'; import { file, resetStore } from '../../helpers';
import testAction from '../../../helpers/vuex_action_helper';
describe('IDE store file actions', () => { describe('IDE store file actions', () => {
beforeEach(() => { beforeEach(() => {
...@@ -402,6 +405,7 @@ describe('IDE store file actions', () => { ...@@ -402,6 +405,7 @@ describe('IDE store file actions', () => {
beforeEach(() => { beforeEach(() => {
spyOn(eventHub, '$on'); spyOn(eventHub, '$on');
spyOn(eventHub, '$emit');
tmpFile = file(); tmpFile = file();
tmpFile.content = 'testing'; tmpFile.content = 'testing';
...@@ -460,6 +464,57 @@ describe('IDE store file actions', () => { ...@@ -460,6 +464,57 @@ describe('IDE store file actions', () => {
}) })
.catch(done.fail); .catch(done.fail);
}); });
it('pushes route for active file', done => {
tmpFile.active = true;
store.state.openFiles.push(tmpFile);
store
.dispatch('discardFileChanges', tmpFile.path)
.then(() => {
expect(router.push).toHaveBeenCalledWith(`/project${tmpFile.url}`);
done();
})
.catch(done.fail);
});
it('emits eventHub event to dispose cached model', done => {
store
.dispatch('discardFileChanges', tmpFile.path)
.then(() => {
expect(eventHub.$emit).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
});
describe('stageChange', () => {
it('calls STAGE_CHANGE with file path', done => {
testAction(
actions.stageChange,
'path',
store.state,
[{ type: types.STAGE_CHANGE, payload: 'path' }],
[],
done,
);
});
});
describe('unstageChange', () => {
it('calls UNSTAGE_CHANGE with file path', done => {
testAction(
actions.unstageChange,
'path',
store.state,
[{ type: types.UNSTAGE_CHANGE, payload: 'path' }],
[],
done,
);
});
}); });
describe('openPendingTab', () => { describe('openPendingTab', () => {
...@@ -476,7 +531,7 @@ describe('IDE store file actions', () => { ...@@ -476,7 +531,7 @@ describe('IDE store file actions', () => {
it('makes file pending in openFiles', done => { it('makes file pending in openFiles', done => {
store store
.dispatch('openPendingTab', f) .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
.then(() => { .then(() => {
expect(store.state.openFiles[0].pending).toBe(true); expect(store.state.openFiles[0].pending).toBe(true);
}) })
...@@ -486,7 +541,7 @@ describe('IDE store file actions', () => { ...@@ -486,7 +541,7 @@ describe('IDE store file actions', () => {
it('returns true when opened', done => { it('returns true when opened', done => {
store store
.dispatch('openPendingTab', f) .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
.then(added => { .then(added => {
expect(added).toBe(true); expect(added).toBe(true);
}) })
...@@ -498,7 +553,7 @@ describe('IDE store file actions', () => { ...@@ -498,7 +553,7 @@ describe('IDE store file actions', () => {
store.state.currentBranchId = 'master'; store.state.currentBranchId = 'master';
store store
.dispatch('openPendingTab', f) .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
.then(() => { .then(() => {
expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/'); expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/');
}) })
...@@ -512,7 +567,7 @@ describe('IDE store file actions', () => { ...@@ -512,7 +567,7 @@ describe('IDE store file actions', () => {
store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
store store
.dispatch('openPendingTab', f) .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
.then(() => { .then(() => {
expect(scrollToTabSpy).toHaveBeenCalled(); expect(scrollToTabSpy).toHaveBeenCalled();
store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
...@@ -527,7 +582,7 @@ describe('IDE store file actions', () => { ...@@ -527,7 +582,7 @@ describe('IDE store file actions', () => {
store.state.viewer = 'diff'; store.state.viewer = 'diff';
store store
.dispatch('openPendingTab', f) .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
.then(added => { .then(added => {
expect(added).toBe(false); expect(added).toBe(false);
}) })
......
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
import store from '~/ide/stores'; import store from '~/ide/stores';
import * as actions from '~/ide/stores/actions';
import * as types from '~/ide/stores/mutation_types';
import router from '~/ide/ide_router'; import router from '~/ide/ide_router';
import { resetStore, file } from '../helpers'; import { resetStore, file } from '../helpers';
import testAction from '../../helpers/vuex_action_helper';
describe('Multi-file store actions', () => { describe('Multi-file store actions', () => {
beforeEach(() => { beforeEach(() => {
...@@ -191,9 +194,7 @@ describe('Multi-file store actions', () => { ...@@ -191,9 +194,7 @@ describe('Multi-file store actions', () => {
}) })
.then(f => { .then(f => {
expect(f.tempFile).toBeTruthy(); expect(f.tempFile).toBeTruthy();
expect(store.state.trees['abcproject/mybranch'].tree.length).toBe( expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1);
1,
);
done(); done();
}) })
...@@ -292,6 +293,42 @@ describe('Multi-file store actions', () => { ...@@ -292,6 +293,42 @@ describe('Multi-file store actions', () => {
}); });
}); });
describe('stageAllChanges', () => {
it('adds all files from changedFiles to stagedFiles', done => {
store.state.changedFiles.push(file(), file('new'));
testAction(
actions.stageAllChanges,
null,
store.state,
[
{ type: types.STAGE_CHANGE, payload: store.state.changedFiles[0].path },
{ type: types.STAGE_CHANGE, payload: store.state.changedFiles[1].path },
],
[],
done,
);
});
});
describe('unstageAllChanges', () => {
it('removes all files from stagedFiles after unstaging', done => {
store.state.stagedFiles.push(file(), file('new'));
testAction(
actions.unstageAllChanges,
null,
store.state,
[
{ type: types.UNSTAGE_CHANGE, payload: store.state.stagedFiles[0].path },
{ type: types.UNSTAGE_CHANGE, payload: store.state.stagedFiles[1].path },
],
[],
done,
);
});
});
describe('updateViewer', () => { describe('updateViewer', () => {
it('updates viewer state', done => { it('updates viewer state', done => {
store store
......
...@@ -37,19 +37,11 @@ describe('IDE store getters', () => { ...@@ -37,19 +37,11 @@ describe('IDE store getters', () => {
expect(modifiedFiles.length).toBe(1); expect(modifiedFiles.length).toBe(1);
expect(modifiedFiles[0].name).toBe('changed'); expect(modifiedFiles[0].name).toBe('changed');
}); });
});
describe('addedFiles', () => { it('returns angle left when collapsed', () => {
it('returns a list of added files', () => { localState.rightPanelCollapsed = true;
localState.openFiles.push(file());
localState.changedFiles.push(file('added'));
localState.changedFiles[0].changed = true;
localState.changedFiles[0].tempFile = true;
const modifiedFiles = getters.addedFiles(localState); expect(getters.collapseButtonIcon(localState)).toBe('angle-double-left');
expect(modifiedFiles.length).toBe(1);
expect(modifiedFiles[0].name).toBe('added');
}); });
}); });
......
...@@ -209,14 +209,14 @@ describe('IDE commit module actions', () => { ...@@ -209,14 +209,14 @@ describe('IDE commit module actions', () => {
}, },
}, },
}; };
store.state.changedFiles.push(f, { store.state.stagedFiles.push(f, {
...file('changedFile2'), ...file('changedFile2'),
changed: true, changed: true,
}); });
store.state.openFiles = store.state.changedFiles; store.state.openFiles = store.state.stagedFiles;
store.state.changedFiles.forEach(changedFile => { store.state.stagedFiles.forEach(stagedFile => {
store.state.entries[changedFile.path] = changedFile; store.state.entries[stagedFile.path] = stagedFile;
}); });
}); });
...@@ -248,19 +248,6 @@ describe('IDE commit module actions', () => { ...@@ -248,19 +248,6 @@ describe('IDE commit module actions', () => {
.catch(done.fail); .catch(done.fail);
}); });
it('removes all changed files', done => {
store
.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
})
.then(done)
.catch(done.fail);
});
it('sets files commit data', done => { it('sets files commit data', done => {
store store
.dispatch('commit/updateFilesAfterCommit', { .dispatch('commit/updateFilesAfterCommit', {
...@@ -294,10 +281,10 @@ describe('IDE commit module actions', () => { ...@@ -294,10 +281,10 @@ describe('IDE commit module actions', () => {
branch, branch,
}) })
.then(() => { .then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith( expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, {
`editor.update.model.content.${f.path}`, content: f.content,
f.content, changed: false,
); });
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -335,12 +322,22 @@ describe('IDE commit module actions', () => { ...@@ -335,12 +322,22 @@ describe('IDE commit module actions', () => {
}, },
}, },
}; };
store.state.changedFiles.push(file('changed'));
store.state.changedFiles[0].active = true; const f = {
...file('changed'),
type: 'blob',
active: true,
};
store.state.stagedFiles.push(f);
store.state.changedFiles = [
{
...f,
},
];
store.state.openFiles = store.state.changedFiles; store.state.openFiles = store.state.changedFiles;
store.state.openFiles.forEach(f => { store.state.openFiles.forEach(localF => {
store.state.entries[f.path] = f; store.state.entries[localF.path] = localF;
}); });
store.state.commit.commitAction = '2'; store.state.commit.commitAction = '2';
...@@ -420,11 +417,13 @@ describe('IDE commit module actions', () => { ...@@ -420,11 +417,13 @@ describe('IDE commit module actions', () => {
.catch(done.fail); .catch(done.fail);
}); });
it('adds commit data to changed files', done => { it('adds commit data to files', done => {
store store
.dispatch('commit/commitChanges') .dispatch('commit/commitChanges')
.then(() => { .then(() => {
expect(store.state.openFiles[0].lastCommit.message).toBe('test message'); expect(store.state.entries[store.state.openFiles[0].path].lastCommit.message).toBe(
'test message',
);
done(); done();
}) })
...@@ -443,6 +442,16 @@ describe('IDE commit module actions', () => { ...@@ -443,6 +442,16 @@ describe('IDE commit module actions', () => {
.catch(done.fail); .catch(done.fail);
}); });
it('removes all staged files', done => {
store
.dispatch('commit/commitChanges')
.then(() => {
expect(store.state.stagedFiles.length).toBe(0);
})
.then(done)
.catch(done.fail);
});
describe('merge request', () => { describe('merge request', () => {
it('redirects to new merge request page', done => { it('redirects to new merge request page', done => {
spyOn(eventHub, '$on'); spyOn(eventHub, '$on');
...@@ -471,7 +480,7 @@ describe('IDE commit module actions', () => { ...@@ -471,7 +480,7 @@ describe('IDE commit module actions', () => {
store store
.dispatch('commit/commitChanges') .dispatch('commit/commitChanges')
.then(() => { .then(() => {
expect(store.state.changedFiles.length).toBe(0); expect(store.state.stagedFiles.length).toBe(0);
done(); done();
}) })
......
...@@ -34,17 +34,17 @@ describe('IDE commit module getters', () => { ...@@ -34,17 +34,17 @@ describe('IDE commit module getters', () => {
discardDraftButtonDisabled: false, discardDraftButtonDisabled: false,
}; };
const rootState = { const rootState = {
changedFiles: ['a'], stagedFiles: ['a'],
}; };
it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { it('returns false when discardDraftButtonDisabled is false & stagedFiles is not empty', () => {
expect( expect(
getters.commitButtonDisabled(state, localGetters, rootState), getters.commitButtonDisabled(state, localGetters, rootState),
).toBeFalsy(); ).toBeFalsy();
}); });
it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { it('returns true when discardDraftButtonDisabled is false & stagedFiles is empty', () => {
rootState.changedFiles.length = 0; rootState.stagedFiles.length = 0;
expect( expect(
getters.commitButtonDisabled(state, localGetters, rootState), getters.commitButtonDisabled(state, localGetters, rootState),
...@@ -61,7 +61,7 @@ describe('IDE commit module getters', () => { ...@@ -61,7 +61,7 @@ describe('IDE commit module getters', () => {
it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
localGetters.discardDraftButtonDisabled = false; localGetters.discardDraftButtonDisabled = false;
rootState.changedFiles.length = 0; rootState.stagedFiles.length = 0;
expect( expect(
getters.commitButtonDisabled(state, localGetters, rootState), getters.commitButtonDisabled(state, localGetters, rootState),
......
...@@ -8,7 +8,10 @@ describe('IDE store file mutations', () => { ...@@ -8,7 +8,10 @@ describe('IDE store file mutations', () => {
beforeEach(() => { beforeEach(() => {
localState = state(); localState = state();
localFile = file(); localFile = {
...file(),
type: 'blob',
};
localState.entries[localFile.path] = localFile; localState.entries[localFile.path] = localFile;
}); });
...@@ -183,6 +186,49 @@ describe('IDE store file mutations', () => { ...@@ -183,6 +186,49 @@ describe('IDE store file mutations', () => {
}); });
}); });
describe('STAGE_CHANGE', () => {
it('adds file into stagedFiles array', () => {
mutations.STAGE_CHANGE(localState, localFile.path);
expect(localState.stagedFiles.length).toBe(1);
expect(localState.stagedFiles[0]).toEqual(localFile);
});
it('updates stagedFile if it is already staged', () => {
mutations.STAGE_CHANGE(localState, localFile.path);
localFile.raw = 'testing 123';
mutations.STAGE_CHANGE(localState, localFile.path);
expect(localState.stagedFiles.length).toBe(1);
expect(localState.stagedFiles[0].raw).toEqual('testing 123');
});
});
describe('UNSTAGE_CHANGE', () => {
let f;
beforeEach(() => {
f = {
...file(),
type: 'blob',
staged: true,
};
localState.stagedFiles.push(f);
localState.changedFiles.push(f);
localState.entries[f.path] = f;
});
it('removes from stagedFiles array', () => {
mutations.UNSTAGE_CHANGE(localState, f.path);
expect(localState.stagedFiles.length).toBe(0);
expect(localState.changedFiles.length).toBe(1);
});
});
describe('TOGGLE_FILE_CHANGED', () => { describe('TOGGLE_FILE_CHANGED', () => {
it('updates file changed status', () => { it('updates file changed status', () => {
mutations.TOGGLE_FILE_CHANGED(localState, { mutations.TOGGLE_FILE_CHANGED(localState, {
......
...@@ -69,6 +69,16 @@ describe('Multi-file store mutations', () => { ...@@ -69,6 +69,16 @@ describe('Multi-file store mutations', () => {
}); });
}); });
describe('CLEAR_STAGED_CHANGES', () => {
it('clears stagedFiles array', () => {
localState.stagedFiles.push('a');
mutations.CLEAR_STAGED_CHANGES(localState);
expect(localState.stagedFiles.length).toBe(0);
});
});
describe('UPDATE_VIEWER', () => { describe('UPDATE_VIEWER', () => {
it('sets viewer state', () => { it('sets viewer state', () => {
mutations.UPDATE_VIEWER(localState, 'diff'); mutations.UPDATE_VIEWER(localState, 'diff');
......
...@@ -36,14 +36,28 @@ describe('Job details header', () => { ...@@ -36,14 +36,28 @@ describe('Job details header', () => {
}, },
isLoading: false, isLoading: false,
}; };
vm = mountComponent(HeaderComponent, props);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
}); });
describe('job reason', () => {
it('should not render the reason when reason is absent', () => {
vm = mountComponent(HeaderComponent, props);
expect(vm.shouldRenderReason).toBe(false);
});
it('should render the reason when reason is present', () => {
props.job.callout_message = 'There is an unknown failure, please try again';
vm = mountComponent(HeaderComponent, props);
expect(vm.shouldRenderReason).toBe(true);
});
});
describe('triggered job', () => { describe('triggered job', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(HeaderComponent, props); vm = mountComponent(HeaderComponent, props);
...@@ -51,14 +65,17 @@ describe('Job details header', () => { ...@@ -51,14 +65,17 @@ describe('Job details header', () => {
it('should render provided job information', () => { it('should render provided job information', () => {
expect( expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), vm.$el
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('failed Job #123 triggered 3 weeks ago by Foo'); ).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
}); });
it('should render new issue link', () => { it('should render new issue link', () => {
expect( expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
vm.$el.querySelector('.js-new-issue').getAttribute('href'), props.job.new_issue_path,
).toEqual(props.job.new_issue_path); );
}); });
}); });
...@@ -68,7 +85,10 @@ describe('Job details header', () => { ...@@ -68,7 +85,10 @@ describe('Job details header', () => {
vm = mountComponent(HeaderComponent, props); vm = mountComponent(HeaderComponent, props);
expect( expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), vm.$el
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('failed Job #123 created 3 weeks ago by Foo'); ).toEqual('failed Job #123 created 3 weeks ago by Foo');
}); });
}); });
......
...@@ -31,10 +31,25 @@ describe('Sidebar details block', () => { ...@@ -31,10 +31,25 @@ describe('Sidebar details block', () => {
}); });
}); });
describe("when user can't retry", () => {
it('should not render a retry button', () => {
vm = new SidebarComponent({
propsData: {
job: {},
canUserRetry: false,
isLoading: true,
},
}).$mount();
expect(vm.$el.querySelector('.js-retry-job')).toBeNull();
});
});
beforeEach(() => { beforeEach(() => {
vm = new SidebarComponent({ vm = new SidebarComponent({
propsData: { propsData: {
job, job,
canUserRetry: true,
isLoading: false, isLoading: false,
}, },
}).$mount(); }).$mount();
...@@ -42,7 +57,9 @@ describe('Sidebar details block', () => { ...@@ -42,7 +57,9 @@ describe('Sidebar details block', () => {
describe('actions', () => { describe('actions', () => {
it('should render link to new issue', () => { it('should render link to new issue', () => {
expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(job.new_issue_path); expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
job.new_issue_path,
);
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue'); expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
}); });
...@@ -57,43 +74,35 @@ describe('Sidebar details block', () => { ...@@ -57,43 +74,35 @@ describe('Sidebar details block', () => {
describe('information', () => { describe('information', () => {
it('should render merge request link', () => { it('should render merge request link', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-mr'))).toEqual('Merge Request: !2');
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
).toEqual('Merge Request: !2');
expect( expect(vm.$el.querySelector('.js-job-mr a').getAttribute('href')).toEqual(
vm.$el.querySelector('.js-job-mr a').getAttribute('href'), job.merge_request.path,
).toEqual(job.merge_request.path); );
}); });
it('should render job duration', () => { it('should render job duration', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-duration'))).toEqual(
trimWhitespace(vm.$el.querySelector('.js-job-duration')), 'Duration: 6 seconds',
).toEqual('Duration: 6 seconds'); );
}); });
it('should render erased date', () => { it('should render erased date', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-erased'))).toEqual('Erased: 3 weeks ago');
trimWhitespace(vm.$el.querySelector('.js-job-erased')),
).toEqual('Erased: 3 weeks ago');
}); });
it('should render finished date', () => { it('should render finished date', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-finished'))).toEqual(
trimWhitespace(vm.$el.querySelector('.js-job-finished')), 'Finished: 3 weeks ago',
).toEqual('Finished: 3 weeks ago'); );
}); });
it('should render queued date', () => { it('should render queued date', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-queued'))).toEqual('Queued: 9 seconds');
trimWhitespace(vm.$el.querySelector('.js-job-queued')),
).toEqual('Queued: 9 seconds');
}); });
it('should render runner ID', () => { it('should render runner ID', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-runner'))).toEqual('Runner: #1');
trimWhitespace(vm.$el.querySelector('.js-job-runner')),
).toEqual('Runner: #1');
}); });
it('should render timeout information', () => { it('should render timeout information', () => {
...@@ -103,15 +112,11 @@ describe('Sidebar details block', () => { ...@@ -103,15 +112,11 @@ describe('Sidebar details block', () => {
}); });
it('should render coverage', () => { it('should render coverage', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-coverage'))).toEqual('Coverage: 20%');
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
).toEqual('Coverage: 20%');
}); });
it('should render tags', () => { it('should render tags', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-tags'))).toEqual('Tags: tag');
trimWhitespace(vm.$el.querySelector('.js-job-tags')),
).toEqual('Tags: tag');
}); });
}); });
}); });
import Vue from 'vue';
import callout from '~/vue_shared/components/callout.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
describe('Callout Component', () => {
let CalloutComponent;
let vm;
const exampleMessage = 'This is a callout message!';
beforeEach(() => {
CalloutComponent = Vue.extend(callout);
});
afterEach(() => {
vm.$destroy();
});
it('should render the appropriate variant of callout', () => {
vm = createComponent(CalloutComponent, {
category: 'info',
message: exampleMessage,
});
expect(vm.$el.getAttribute('class')).toEqual('bs-callout bs-callout-info');
expect(vm.$el.tagName).toEqual('DIV');
});
it('should render accessibility attributes', () => {
vm = createComponent(CalloutComponent, {
message: exampleMessage,
});
expect(vm.$el.getAttribute('role')).toEqual('alert');
expect(vm.$el.getAttribute('aria-live')).toEqual('assertive');
});
it('should render the provided message', () => {
vm = createComponent(CalloutComponent, {
message: exampleMessage,
});
expect(vm.$el.innerHTML.trim()).toEqual(exampleMessage);
});
});
...@@ -48,4 +48,11 @@ describe Gitlab::View::Presenter::Base do ...@@ -48,4 +48,11 @@ describe Gitlab::View::Presenter::Base do
end end
end end
end end
describe '#present' do
it 'returns self' do
presenter = presenter_class.new(build_stubbed(:project))
expect(presenter.present).to eq(presenter)
end
end
end end
...@@ -1414,6 +1414,7 @@ describe Ci::Build do ...@@ -1414,6 +1414,7 @@ describe Ci::Build do
expect { build.save! } expect { build.save! }
.to change { build.project.statistics.reload.build_artifacts_size } .to change { build.project.statistics.reload.build_artifacts_size }
.by(19) .by(19)
<<<<<<< HEAD
end end
context 'when the artifact size stays the same' do context 'when the artifact size stays the same' do
...@@ -1424,9 +1425,38 @@ describe Ci::Build do ...@@ -1424,9 +1425,38 @@ describe Ci::Build do
build.save! build.save!
end end
=======
>>>>>>> upstream/master
end end
end end
<<<<<<< HEAD
context 'when destroying the build' do
let!(:build) { create(:ci_build, artifacts_size: 23) }
it 'updates project statistics' do
expect(ProjectStatistics)
.to receive(:increment_statistic)
.and_call_original
expect { build.destroy! }
.to change { build.project.statistics.reload.build_artifacts_size }
.by(-23)
=======
context 'when the artifact size stays the same' do
it 'does not update project statistics' do
build.name = 'changed'
expect(build).not_to receive(:update_project_statistics_after_save)
build.save!
end
>>>>>>> upstream/master
end
end
<<<<<<< HEAD
=======
context 'when destroying the build' do context 'when destroying the build' do
let!(:build) { create(:ci_build, artifacts_size: 23) } let!(:build) { create(:ci_build, artifacts_size: 23) }
...@@ -1440,6 +1470,7 @@ describe Ci::Build do ...@@ -1440,6 +1470,7 @@ describe Ci::Build do
.by(-23) .by(-23)
end end
>>>>>>> upstream/master
context 'when the build is destroyed due to the project being destroyed' do context 'when the build is destroyed due to the project being destroyed' do
it 'does not update the project statistics' do it 'does not update the project statistics' do
expect(ProjectStatistics) expect(ProjectStatistics)
......
...@@ -79,9 +79,24 @@ describe GroupDescendant, :nested_groups do ...@@ -79,9 +79,24 @@ describe GroupDescendant, :nested_groups do
expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy) expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy)
end end
it 'tracks the exception when a parent was not preloaded' do
expect(Gitlab::Sentry).to receive(:track_exception).and_call_original
expect { GroupDescendant.build_hierarchy([subsub_group]) }.to raise_error(ArgumentError)
end
it 'recovers if a parent was not reloaded by querying for the parent' do
expected_hierarchy = { parent => { subgroup => subsub_group } }
# this does not raise in production, so stubbing it here.
allow(Gitlab::Sentry).to receive(:track_exception)
expect(GroupDescendant.build_hierarchy([subsub_group])).to eq(expected_hierarchy)
end
it 'raises an error if not all elements were preloaded' do it 'raises an error if not all elements were preloaded' do
expect { described_class.build_hierarchy([subsub_group]) } expect { described_class.build_hierarchy([subsub_group]) }
.to raise_error('parent was not preloaded') .to raise_error(/was not preloaded/)
end end
end end
end end
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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