Commit e838efc1 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'ce-to-ee-2018-04-19' into 'master'

CE upstream - 2018-04-19 13:23 UTC

Closes #4541

See merge request gitlab-org/gitlab-ee!5418
parents 900dfc27 a332cf17
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,7 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -95,9 +100,7 @@ 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
@page = build_page(wiki_params)
@error = e @error = e
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
......
...@@ -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?
......
...@@ -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
...@@ -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: "#",
......
...@@ -96,7 +96,7 @@ ...@@ -96,7 +96,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
...@@ -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
......
...@@ -91,6 +91,23 @@ describe Note do ...@@ -91,6 +91,23 @@ describe Note do
it "keeps the commit around" do it "keeps the commit around" do
expect(note.project.repository.kept_around?(commit.id)).to be_truthy expect(note.project.repository.kept_around?(commit.id)).to be_truthy
end end
it 'does not generate N+1 queries for participants', :request_store do
def retrieve_participants
commit.notes_with_associations.map(&:participants).to_a
end
# Project authorization checks are cached, establish a baseline
retrieve_participants
control_count = ActiveRecord::QueryRecorder.new do
retrieve_participants
end
create(:note_on_commit, project: note.project, note: 'another note', noteable_id: commit.id)
expect { retrieve_participants }.not_to exceed_query_limit(control_count)
end
end end
describe 'authorization' do describe 'authorization' do
......
...@@ -217,4 +217,39 @@ describe Ci::BuildPresenter do ...@@ -217,4 +217,39 @@ describe Ci::BuildPresenter do
end end
end end
end end
describe '#callout_failure_message' do
let(:build) { create(:ci_build, :failed, :script_failure) }
it 'returns a verbose failure reason' do
description = subject.callout_failure_message
expect(description).to eq('There has been a script failure. Check the job log for more information')
end
end
describe '#recoverable?' do
let(:build) { create(:ci_build, :failed, :script_failure) }
context 'when is a script or missing dependency failure' do
let(:failure_reasons) { %w(script_failure missing_dependency_failure) }
it 'should return false' do
failure_reasons.each do |failure_reason|
build.update_attribute(:failure_reason, failure_reason)
expect(presenter.recoverable?).to be_falsy
end
end
end
context 'when is any other failure type' do
let(:failure_reasons) { %w(unknown_failure api_failure stuck_or_timeout_failure runner_system_failure) }
it 'should return true' do
failure_reasons.each do |failure_reason|
build.update_attribute(:failure_reason, failure_reason)
expect(presenter.recoverable?).to be_truthy
end
end
end
end
end end
...@@ -133,22 +133,65 @@ describe JobEntity do ...@@ -133,22 +133,65 @@ describe JobEntity do
context 'when job failed' do context 'when job failed' do
let(:job) { create(:ci_build, :script_failure) } let(:job) { create(:ci_build, :script_failure) }
describe 'status' do it 'contains details' do
it 'should contain the failure reason inside label' do expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip end
expect(subject[:status][:label]).to eq('failed')
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure)') it 'states that it failed' do
end expect(subject[:status][:label]).to eq('failed')
end
it 'should indicate the failure reason on tooltip' do
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure)')
end
it 'should include a callout message with a verbose output' do
expect(subject[:callout_message]).to eq('There has been a script failure. Check the job log for more information')
end
it 'should state that it is not recoverable' do
expect(subject[:recoverable]).to be_falsy
end
end
context 'when job is allowed to fail' do
let(:job) { create(:ci_build, :allowed_to_fail, :script_failure) }
it 'contains details' do
expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
end
it 'states that it failed' do
expect(subject[:status][:label]).to eq('failed (allowed to fail)')
end
it 'should indicate the failure reason on tooltip' do
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure) (allowed to fail)')
end
it 'should include a callout message with a verbose output' do
expect(subject[:callout_message]).to eq('There has been a script failure. Check the job log for more information')
end
it 'should state that it is not recoverable' do
expect(subject[:recoverable]).to be_falsy
end
end
context 'when job failed and is recoverable' do
let(:job) { create(:ci_build, :api_failure) }
it 'should state it is recoverable' do
expect(subject[:recoverable]).to be_truthy
end end
end end
context 'when job passed' do context 'when job passed' do
let(:job) { create(:ci_build, :success) } let(:job) { create(:ci_build, :success) }
describe 'status' do it 'should not include callout message or recoverable keys' do
it 'should not contain the failure reason inside label' do expect(subject).not_to include('callout_message')
expect(subject[:status][:label]).to eq('passed') expect(subject).not_to include('recoverable')
end
end end
end end
end end
...@@ -8,6 +8,7 @@ describe Labels::TransferService do ...@@ -8,6 +8,7 @@ describe Labels::TransferService do
let(:group_3) { create(:group) } let(:group_3) { create(:group) }
let(:project_1) { create(:project, namespace: group_2) } let(:project_1) { create(:project, namespace: group_2) }
let(:project_2) { create(:project, namespace: group_3) } let(:project_2) { create(:project, namespace: group_3) }
let(:project_3) { create(:project, namespace: group_1) }
let(:group_label_1) { create(:group_label, group: group_1, name: 'Group Label 1') } let(:group_label_1) { create(:group_label, group: group_1, name: 'Group Label 1') }
let(:group_label_2) { create(:group_label, group: group_1, name: 'Group Label 2') } let(:group_label_2) { create(:group_label, group: group_1, name: 'Group Label 2') }
...@@ -23,6 +24,7 @@ describe Labels::TransferService do ...@@ -23,6 +24,7 @@ describe Labels::TransferService do
create(:labeled_issue, project: project_1, labels: [group_label_4]) create(:labeled_issue, project: project_1, labels: [group_label_4])
create(:labeled_issue, project: project_1, labels: [project_label_1]) create(:labeled_issue, project: project_1, labels: [project_label_1])
create(:labeled_issue, project: project_2, labels: [group_label_5]) create(:labeled_issue, project: project_2, labels: [group_label_5])
create(:labeled_issue, project: project_3, labels: [group_label_1])
create(:labeled_merge_request, source_project: project_1, labels: [group_label_1, group_label_2]) create(:labeled_merge_request, source_project: project_1, labels: [group_label_1, group_label_2])
create(:labeled_merge_request, source_project: project_2, labels: [group_label_5]) create(:labeled_merge_request, source_project: project_2, labels: [group_label_5])
end end
...@@ -52,5 +54,13 @@ describe Labels::TransferService do ...@@ -52,5 +54,13 @@ describe Labels::TransferService do
expect(project_1.labels.where(title: group_label_4.title)).to be_empty expect(project_1.labels.where(title: group_label_4.title)).to be_empty
end end
it 'updates only label links in the given project' do
service.execute
targets = LabelLink.where(label_id: group_label_1.id).map(&:target)
expect(targets).to eq(project_3.issues)
end
end end
end end
...@@ -14,7 +14,9 @@ describe IssueDueSchedulerWorker do ...@@ -14,7 +14,9 @@ describe IssueDueSchedulerWorker do
create(:issue, :closed, project: project_closed_issue, due_date: Date.tomorrow) create(:issue, :closed, project: project_closed_issue, due_date: Date.tomorrow)
create(:issue, :opened, project: project_issue_due_another_day, due_date: Date.today) create(:issue, :opened, project: project_issue_due_another_day, due_date: Date.today)
expect(MailScheduler::IssueDueWorker).to receive(:bulk_perform_async).with([[project1.id], [project2.id]]) expect(MailScheduler::IssueDueWorker).to receive(:bulk_perform_async) do |args|
expect(args).to match_array([[project1.id], [project2.id]])
end
described_class.new.perform described_class.new.perform
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment