diff options
131 files changed, 1570 insertions, 1157 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index efd32d44890..c857efddb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.0.4 (2017-10-16) + +- [SECURITY] Move project repositories between namespaces when renaming users. +- [SECURITY] Prevent an open redirect on project pages. +- [SECURITY] Prevent a persistent XSS in user-provided markup. + ## 10.0.3 (2017-10-05) - [FIXED] find_user Users helper method no longer overrides find_user API helper method. !14418 @@ -212,6 +218,14 @@ entry. - Added type to CHANGELOG entries. (Jacopo Beschi @jacopo-beschi) - [BUGIFX] Improves subgroup creation permissions. !13418 +## 9.5.9 (2017-10-16) + +- [SECURITY] Move project repositories between namespaces when renaming users. +- [SECURITY] Prevent an open redirect on project pages. +- [SECURITY] Prevent a persistent XSS in user-provided markup. +- [FIXED] Allow using newlines in pipeline email service recipients. !14250 +- Escape user name in filtered search bar. + ## 9.5.8 (2017-10-04) - [FIXED] Fixed fork button being disabled for users who can fork to a group. @@ -457,6 +471,15 @@ entry. - Use a specialized class for querying events to improve performance. - Update build badges to be pipeline badges and display passing instead of success. +## 9.4.7 (2017-10-16) + +- [SECURITY] Upgrade mail and nokogiri gems due to security issues. !13662 (Markus Koller) +- [SECURITY] Move project repositories between namespaces when renaming users. +- [SECURITY] Prevent an open redirect on project pages. +- [SECURITY] Prevent a persistent XSS in user-provided markup. +- [FIXED] Allow using newlines in pipeline email service recipients. !14250 +- Escape user name in filtered search bar. + ## 9.4.6 (2017-09-06) - [SECURITY] Upgrade mail and nokogiri gems due to security issues. !13662 (Markus Koller) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 421ab545d9a..a758a09aae5 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.47.0 +0.48.0 @@ -398,7 +398,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.42.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.45.0', require: 'gitaly' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index afb0b6b471d..89023b1cbf1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -273,7 +273,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.42.0) + gitaly-proto (0.45.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -1030,7 +1030,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.42.0) + gitaly-proto (~> 0.45.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) diff --git a/app/assets/images/auth_buttons/signin_with_google.png b/app/assets/images/auth_buttons/signin_with_google.png Binary files differindex b1327b4f7b4..f27bb243304 100644 --- a/app/assets/images/auth_buttons/signin_with_google.png +++ b/app/assets/images/auth_buttons/signin_with_google.png diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 7f3afefc9cc..c1f902a785a 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -9,6 +9,7 @@ import Flash from '../../flash'; import eventHub from '../../sidebar/event_hub'; import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; import Assignees from '../../sidebar/components/assignees/assignees'; +import DueDateSelectors from '../../due_date_select'; import './sidebar/remove_issue'; const Store = gl.issueBoards.BoardsStore; @@ -113,7 +114,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ mounted () { new IssuableContext(this.currentUser); new MilestoneSelect(); - new gl.DueDateSelectors(); + new DueDateSelectors(); new LabelsSelect(); new Sidebar(); gl.Subscription.bindAll('.subscription'); diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js index 50dbeb06362..180aa30e98c 100644 --- a/app/assets/javascripts/clusters.js +++ b/app/assets/javascripts/clusters.js @@ -3,7 +3,8 @@ import Visibility from 'visibilityjs'; import axios from 'axios'; import Poll from './lib/utils/poll'; import { s__ } from './locale'; -import './flash'; +import initSettingsPanels from './settings_panels'; +import Flash from './flash'; /** * Cluster page has 2 separate parts: @@ -24,6 +25,8 @@ class ClusterService { export default class Clusters { constructor() { + initSettingsPanels(); + const dataset = document.querySelector('.js-edit-cluster-form').dataset; this.state = { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b66652db33b..2885923aeda 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -86,6 +86,7 @@ import ShortcutsIssuable from './shortcuts_issuable'; import U2FAuthenticate from './u2f/authenticate'; import Members from './members'; import memberExpirationDate from './member_expiration_date'; +import DueDateSelectors from './due_date_select'; (function() { var Dispatcher; @@ -232,7 +233,7 @@ import memberExpirationDate from './member_expiration_date'; case 'groups:milestones:edit': case 'groups:milestones:update': new ZenMode(); - new gl.DueDateSelectors(); + new DueDateSelectors(); new GLForm($('.milestone-form'), true); break; case 'projects:compare:show': @@ -532,7 +533,7 @@ import memberExpirationDate from './member_expiration_date'; break; case 'profiles:personal_access_tokens:index': case 'admin:impersonation_tokens:index': - new gl.DueDateSelectors(); + new DueDateSelectors(); break; case 'projects:clusters:show': import(/* webpackChunkName: "clusters" */ './clusters') diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index ee71728184f..ada985913bb 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -1,8 +1,7 @@ -/* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */ /* global dateFormat */ import Pikaday from 'pikaday'; -import DateFix from './lib/utils/datefix'; +import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; class DueDateSelect { constructor({ $dropdown, $loading } = {}) { @@ -17,8 +16,8 @@ class DueDateSelect { this.$value = $block.find('.value'); this.$valueContent = $block.find('.value-content'); this.$sidebarValue = $('.js-due-date-sidebar-value', $block); - this.fieldName = $dropdown.data('field-name'), - this.abilityName = $dropdown.data('ability-name'), + this.fieldName = $dropdown.data('field-name'); + this.abilityName = $dropdown.data('ability-name'); this.issueUpdateURL = $dropdown.data('issue-update'); this.rawSelectedDate = null; @@ -39,20 +38,20 @@ class DueDateSelect { hidden: () => { this.$selectbox.hide(); this.$value.css('display', ''); - } + }, }); } initDatePicker() { const $dueDateInput = $(`input[name='${this.fieldName}']`); - const dateFix = DateFix.dashedFix($dueDateInput.val()); const calendar = new Pikaday({ field: $dueDateInput.get(0), theme: 'gitlab-theme', format: 'yyyy-mm-dd', + parse: dateString => parsePikadayDate(dateString), + toString: date => pikadayToString(date), onSelect: (dateText) => { - const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd'); - $dueDateInput.val(formattedDate); + $dueDateInput.val(calendar.toString(dateText)); if (this.$dropdown.hasClass('js-issue-boards-due-date')) { gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val(); @@ -60,10 +59,10 @@ class DueDateSelect { } else { this.saveDueDate(true); } - } + }, }); - calendar.setDate(dateFix); + calendar.setDate(parsePikadayDate($dueDateInput.val())); this.$datePicker.append(calendar.el); this.$datePicker.data('pikaday', calendar); } @@ -79,8 +78,8 @@ class DueDateSelect { gl.issueBoards.BoardsStore.detail.issue.dueDate = ''; this.updateIssueBoardIssue(); } else { - $("input[name='" + this.fieldName + "']").val(''); - return this.saveDueDate(false); + $(`input[name='${this.fieldName}']`).val(''); + this.saveDueDate(false); } }); } @@ -111,7 +110,7 @@ class DueDateSelect { this.datePayload = datePayload; } - updateIssueBoardIssue () { + updateIssueBoardIssue() { this.$loading.fadeIn(); this.$dropdown.trigger('loading.gl.dropdown'); this.$selectbox.hide(); @@ -149,8 +148,8 @@ class DueDateSelect { return selectedDateValue.length ? $('.js-remove-due-date-holder').removeClass('hidden') : $('.js-remove-due-date-holder').addClass('hidden'); - } - }).done((data) => { + }, + }).done(() => { if (isDropdown) { this.$dropdown.trigger('loaded.gl.dropdown'); this.$dropdown.dropdown('toggle'); @@ -160,27 +159,28 @@ class DueDateSelect { } } -class DueDateSelectors { +export default class DueDateSelectors { constructor() { this.initMilestoneDatePicker(); this.initIssuableSelect(); } - + // eslint-disable-next-line class-methods-use-this initMilestoneDatePicker() { - $('.datepicker').each(function() { + $('.datepicker').each(function initPikadayMilestone() { const $datePicker = $(this); - const dateFix = DateFix.dashedFix($datePicker.val()); const calendar = new Pikaday({ field: $datePicker.get(0), theme: 'gitlab-theme animate-picker', format: 'yyyy-mm-dd', container: $datePicker.parent().get(0), + parse: dateString => parsePikadayDate(dateString), + toString: date => pikadayToString(date), onSelect(dateText) { - $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); - } + $datePicker.val(calendar.toString(dateText)); + }, }); - calendar.setDate(dateFix); + calendar.setDate(parsePikadayDate($datePicker.val())); $datePicker.data('pikaday', calendar); }); @@ -191,19 +191,17 @@ class DueDateSelectors { calendar.setDate(null); }); } - + // eslint-disable-next-line class-methods-use-this initIssuableSelect() { const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); $('.js-due-date-select').each((i, dropdown) => { const $dropdown = $(dropdown); + // eslint-disable-next-line no-new new DueDateSelect({ $dropdown, - $loading + $loading, }); }); } } - -window.gl = window.gl || {}; -window.gl.DueDateSelectors = DueDateSelectors; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index dd24fc44d2a..d2f92929b8a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -123,8 +123,8 @@ class FilteredSearchVisualTokens { /* eslint-disable no-param-reassign */ tokenValueContainer.dataset.originalValue = tokenValue; tokenValueElement.innerHTML = ` - <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar"> - ${user.name} + <img class="avatar s20" src="${user.avatar_url}" alt=""> + ${_.escape(user.name)} `; /* eslint-enable no-param-reassign */ }) diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index 29e3d2ea94e..32a1a269f9a 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -4,6 +4,8 @@ /* global IssuableContext */ /* global Sidebar */ +import DueDateSelectors from './due_date_select'; + export default () => { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); @@ -13,6 +15,6 @@ export default () => { new LabelsSelect(); new IssuableContext(sidebarOptions.currentUser); gl.Subscription.bindAll('.subscription'); - new gl.DueDateSelectors(); + new DueDateSelectors(); window.sidebar = new Sidebar(); }; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 470c39c6f76..cd2562bc6a9 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,12 +1,12 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ /* global GitLab */ /* global Autosave */ -/* global dateFormat */ import Pikaday from 'pikaday'; import UsersSelect from './users_select'; import GfmAutoComplete from './gfm_auto_complete'; import ZenMode from './zen_mode'; +import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; (function() { this.IssuableForm = (function() { @@ -38,11 +38,13 @@ import ZenMode from './zen_mode'; theme: 'gitlab-theme animate-picker', format: 'yyyy-mm-dd', container: $issuableDueDate.parent().get(0), + parse: dateString => parsePikadayDate(dateString), + toString: date => pikadayToString(date), onSelect: function(dateText) { - $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + $issuableDueDate.val(calendar.toString(dateText)); } }); - calendar.setDate(new Date($issuableDueDate.val())); + calendar.setDate(parsePikadayDate($issuableDueDate.val())); } } diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index eecb56cb185..d1aa83ea57f 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -24,6 +24,11 @@ export default { required: true, type: Boolean, }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, + }, issuableRef: { type: String, required: true, @@ -222,20 +227,25 @@ export default { <div v-else> <title-component :issuable-ref="issuableRef" + :can-update="canUpdate" :title-html="state.titleHtml" - :title-text="state.titleText" /> + :title-text="state.titleText" + :show-inline-edit-button="showInlineEditButton" + /> <description-component v-if="state.descriptionHtml" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" :updated-at="state.updatedAt" - :task-status="state.taskStatus" /> + :task-status="state.taskStatus" + /> <edited-component v-if="hasUpdated" :updated-at="state.updatedAt" :updated-by-name="state.updatedByName" - :updated-by-path="state.updatedByPath" /> + :updated-by-path="state.updatedByPath" + /> </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index a9dabd4cff1..00002709ac6 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -1,5 +1,8 @@ <script> import animateMixin from '../mixins/animate'; + import eventHub from '../event_hub'; + import tooltip from '../../vue_shared/directives/tooltip'; + import { spriteIcon } from '../../lib/utils/common_utils'; export default { mixins: [animateMixin], @@ -15,6 +18,11 @@ type: String, required: true, }, + canUpdate: { + required: false, + type: Boolean, + default: false, + }, titleHtml: { type: String, required: true, @@ -23,6 +31,14 @@ type: String, required: true, }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, + }, + }, + directives: { + tooltip, }, watch: { titleHtml() { @@ -30,24 +46,46 @@ this.animateChange(); }, }, + computed: { + pencilIcon() { + return spriteIcon('pencil', 'link-highlight'); + }, + }, methods: { setPageTitle() { const currentPageTitleScope = this.titleEl.innerText.split('·'); currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; this.titleEl.textContent = currentPageTitleScope.join('·'); }, + edit() { + eventHub.$emit('open.form'); + }, }, }; </script> <template> - <h2 - class="title" - :class="{ - 'issue-realtime-pre-pulse': preAnimation, - 'issue-realtime-trigger-pulse': pulseAnimation - }" - v-html="titleHtml" - > - </h2> + <div class="title-container"> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> + <button + v-tooltip + v-if="showInlineEditButton && canUpdate" + type="button" + class="btn-blank btn-edit note-action-button" + v-html="pencilIcon" + title="Edit title and description" + data-placement="bottom" + data-container="body" + @click="edit" + > + </button> + </div> </template> diff --git a/app/assets/javascripts/lib/utils/datefix.js b/app/assets/javascripts/lib/utils/datefix.js index 990dc3f6d1a..e98c9068367 100644 --- a/app/assets/javascripts/lib/utils/datefix.js +++ b/app/assets/javascripts/lib/utils/datefix.js @@ -1,8 +1,29 @@ -const DateFix = { - dashedFix(val) { - const [y, m, d] = val.split('-'); - return new Date(y, m - 1, d); - }, + +export const pad = (val, len = 2) => (`0${val}`).slice(-len); + +/** + * Formats dates in Pickaday + * @param {String} dateString Date in yyyy-mm-dd format + * @return {Date} UTC format + */ +export const parsePikadayDate = (dateString) => { + const parts = dateString.split('-'); + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1] - 1, 10); + const day = parseInt(parts[2], 10); + + return new Date(year, month, day); }; -export default DateFix; +/** + * Used `onSelect` method in pickaday + * @param {Date} date UTC format + * @return {String} Date formated in yyyy-mm-dd + */ +export const pikadayToString = (date) => { + const day = pad(date.getDate()); + const month = pad(date.getMonth() + 1); + const year = date.getFullYear(); + + return `${year}-${month}-${day}`; +}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 2fc47d5963b..4cf07e99161 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -51,8 +51,6 @@ import './confirm_danger_modal'; import './copy_as_gfm'; import './copy_to_clipboard'; import './diff'; -import './dropzone_input'; -import './due_date_select'; import './files_comment_button'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 26b24fdafda..84e70e35bad 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -1,6 +1,5 @@ -/* global dateFormat */ - import Pikaday from 'pikaday'; +import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; // Add datepickers to all `js-access-expiration-date` elements. If those elements are // children of an element with the `clearable-input` class, and have a sibling @@ -22,8 +21,10 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d format: 'yyyy-mm-dd', minDate: new Date(), container: $input.parent().get(0), + parse: dateString => parsePikadayDate(dateString), + toString: date => pikadayToString(date), onSelect(dateText) { - $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + $input.val(calendar.toString(dateText)); $input.trigger('change'); @@ -31,7 +32,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d }, }); - calendar.setDate(new Date($input.val())); + calendar.setDate(parsePikadayDate($input.val())); $input.data('pikaday', calendar); }); diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index e40382e7afc..208c3c39866 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -38,7 +38,7 @@ export default { tag: element.name, revision: element.revision, shortRevision: element.short_revision, - size: element.size, + size: element.total_size, layers: element.layers, location: element.location, createdAt: element.created_at, diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index cc60aa5939c..0a89a9f16cb 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper'; import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; export default { - data: () => Store, + data() { + return Store; + }, mixins: [RepoMixin], components: { RepoSidebar, diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index c0dc4c8cd8b..185cd90ac06 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility'; export default { mixins: [RepoMixin], - data: () => Store, + data() { + return Store; + }, components: { PopupDialog, diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue index 353142edeb7..e6e8b2e5205 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/repo/components/repo_edit_button.vue @@ -3,7 +3,9 @@ import Store from '../stores/repo_store'; import RepoMixin from '../mixins/repo_mixin'; export default { - data: () => Store, + data() { + return Store; + }, mixins: [RepoMixin], computed: { buttonLabel() { diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index 2c597a3cd65..4639bee6d66 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -5,7 +5,9 @@ import Service from '../services/repo_service'; import Helper from '../helpers/repo_helper'; const RepoEditor = { - data: () => Store, + data() { + return Store; + }, destroyed() { if (Helper.monacoInstance) { @@ -93,7 +95,7 @@ const RepoEditor = { }, blobRaw() { - if (Helper.monacoInstance && !this.isTree) { + if (Helper.monacoInstance) { this.setupEditor(); } }, diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 8b9cbd23456..c7e69340f17 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -1,107 +1,78 @@ <script> -import TimeAgoMixin from '../../vue_shared/mixins/timeago'; + import timeAgoMixin from '../../vue_shared/mixins/timeago'; + import eventHub from '../event_hub'; + import repoMixin from '../mixins/repo_mixin'; -const RepoFile = { - mixins: [TimeAgoMixin], - props: { - file: { - type: Object, - required: true, + export default { + mixins: [ + repoMixin, + timeAgoMixin, + ], + props: { + file: { + type: Object, + required: true, + }, }, - isMini: { - type: Boolean, - required: false, - default: false, + computed: { + fileIcon() { + const classObj = { + 'fa-spinner fa-spin': this.file.loading, + [this.file.icon]: !this.file.loading, + 'fa-folder-open': !this.file.loading && this.file.opened, + }; + return classObj; + }, + levelIndentation() { + return { + marginLeft: `${this.file.level * 16}px`, + }; + }, }, - loading: { - type: Object, - required: false, - default() { return { tree: false }; }, + methods: { + linkClicked(file) { + eventHub.$emit('fileNameClicked', file); + }, }, - hasFiles: { - type: Boolean, - required: false, - default: false, - }, - activeFile: { - type: Object, - required: true, - }, - }, - - computed: { - canShowFile() { - return !this.loading.tree || this.hasFiles; - }, - - fileIcon() { - const classObj = { - 'fa-spinner fa-spin': this.file.loading, - [this.file.icon]: !this.file.loading, - }; - return classObj; - }, - - fileIndentation() { - return { - 'margin-left': `${this.file.level * 10}px`, - }; - }, - - activeFileClass() { - return { - active: this.activeFile.url === this.file.url, - }; - }, - }, - - methods: { - linkClicked(file) { - this.$emit('linkclicked', file); - }, - }, -}; - -export default RepoFile; + }; </script> <template> -<tr - v-if="canShowFile" - class="file" - :class="activeFileClass" - @click.prevent="linkClicked(file)"> - <td> - <i - class="fa fa-fw file-icon" - :class="fileIcon" - :style="fileIndentation" - aria-label="file icon"> - </i> - <a - :href="file.url" - class="repo-file-name" - :title="file.url"> - {{file.name}} - </a> - </td> + <tr + class="file" + @click.prevent="linkClicked(file)"> + <td> + <i + class="fa fa-fw file-icon" + :class="fileIcon" + :style="levelIndentation" + aria-hidden="true" + > + </i> + <a + :href="file.url" + class="repo-file-name" + > + {{ file.name }} + </a> + </td> - <template v-if="!isMini"> - <td class="hidden-sm hidden-xs"> - <div class="commit-message"> - <a @click.stop :href="file.lastCommitUrl"> - {{file.lastCommitMessage}} + <template v-if="!isMini"> + <td class="hidden-sm hidden-xs"> + <a + @click.stop + :href="file.lastCommit.url" + class="commit-message" + > + {{ file.lastCommit.message }} </a> - </div> - </td> + </td> - <td class="hidden-xs text-right"> - <span - class="commit-update" - :title="tooltipTitle(file.lastCommitUpdate)"> - {{timeFormated(file.lastCommitUpdate)}} - </span> - </td> - </template> -</tr> + <td class="commit-update hidden-xs text-right"> + <span :title="tooltipTitle(file.lastCommit.updatedAt)"> + {{ timeFormated(file.lastCommit.updatedAt) }} + </span> + </td> + </template> + </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index e43ef366f47..03cd219e718 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper'; import RepoMixin from '../mixins/repo_mixin'; const RepoFileButtons = { - data: () => Store, + data() { + return Store; + }, mixins: [RepoMixin], diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue deleted file mode 100644 index 6a15755f029..00000000000 --- a/app/assets/javascripts/repo/components/repo_file_options.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -const RepoFileOptions = { - props: { - isMini: { - type: Boolean, - required: false, - default: false, - }, - projectName: { - type: String, - required: true, - }, - }, -}; - -export default RepoFileOptions; -</script> - -<template> - <tr v-if="isMini" class="repo-file-options"> - <td> - <span class="title">{{projectName}}</span> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue index bc8c64c8362..832b45b2b29 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -1,43 +1,23 @@ <script> -const RepoLoadingFile = { - props: { - loading: { - type: Object, - required: false, - default: {}, - }, - hasFiles: { - type: Boolean, - required: false, - default: false, - }, - isMini: { - type: Boolean, - required: false, - default: false, - }, - }, - - computed: { - showGhostLines() { - return this.loading.tree && !this.hasFiles; - }, - }, + import repoMixin from '../mixins/repo_mixin'; - methods: { - lineOfCode(n) { - return `skeleton-line-${n}`; + export default { + mixins: [ + repoMixin, + ], + methods: { + lineOfCode(n) { + return `skeleton-line-${n}`; + }, }, - }, -}; - -export default RepoLoadingFile; + }; </script> <template> <tr - v-if="showGhostLines" - class="loading-file"> + class="loading-file" + aria-label="Loading files" + > <td> <div class="animation-container animation-container-small"> @@ -48,29 +28,28 @@ export default RepoLoadingFile; </div> </div> </td> - - <td - v-if="!isMini" - class="hidden-sm hidden-xs"> - <div class="animation-container"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> + <template v-if="!isMini"> + <td + class="hidden-sm hidden-xs"> + <div class="animation-container"> + <div + v-for="n in 6" + :key="n" + :class="lineOfCode(n)"> + </div> </div> - </div> - </td> + </td> - <td - v-if="!isMini" - class="hidden-xs"> - <div class="animation-container animation-container-small"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> + <td + class="hidden-xs"> + <div class="animation-container animation-container-small animation-container-right"> + <div + v-for="n in 6" + :key="n" + :class="lineOfCode(n)"> + </div> </div> - </div> - </td> + </td> + </template> </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue index bbdbdc61e38..c4bf6dcdec2 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue @@ -1,38 +1,38 @@ <script> -import RepoMixin from '../mixins/repo_mixin'; + import eventHub from '../event_hub'; + import repoMixin from '../mixins/repo_mixin'; -const RepoPreviousDirectory = { - props: { - prevUrl: { - type: String, - required: true, + export default { + mixins: [ + repoMixin, + ], + props: { + prevUrl: { + type: String, + required: true, + }, }, - }, - - mixins: [RepoMixin], - - computed: { - colSpanCondition() { - return this.isMini ? undefined : 3; + computed: { + colSpanCondition() { + return this.isMini ? undefined : 3; + }, }, - }, - - methods: { - linkClicked(file) { - this.$emit('linkclicked', file); + methods: { + linkClicked(file) { + eventHub.$emit('goToPreviousDirectoryClicked', file); + }, }, - }, -}; - -export default RepoPreviousDirectory; + }; </script> <template> -<tr class="prev-directory"> - <td - :colspan="colSpanCondition" - @click.prevent="linkClicked(prevUrl)"> - <a :href="prevUrl">..</a> - </td> -</tr> + <tr class="file prev-directory"> + <td + :colspan="colSpanCondition" + class="table-cell" + @click.prevent="linkClicked(prevUrl)" + > + <a :href="prevUrl">...</a> + </td> + </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index a87bef6084a..b5be771d539 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -4,7 +4,9 @@ import Store from '../stores/repo_store'; export default { - data: () => Store, + data() { + return Store; + }, computed: { html() { return this.activeFile.html; diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index e0f3c33003a..5832e603907 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -1,9 +1,10 @@ <script> +import _ from 'underscore'; import Service from '../services/repo_service'; import Helper from '../helpers/repo_helper'; import Store from '../stores/repo_store'; +import eventHub from '../event_hub'; import RepoPreviousDirectory from './repo_prev_directory.vue'; -import RepoFileOptions from './repo_file_options.vue'; import RepoFile from './repo_file.vue'; import RepoLoadingFile from './repo_loading_file.vue'; import RepoMixin from '../mixins/repo_mixin'; @@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin'; export default { mixins: [RepoMixin], components: { - 'repo-file-options': RepoFileOptions, 'repo-previous-directory': RepoPreviousDirectory, 'repo-file': RepoFile, 'repo-loading-file': RepoLoadingFile, }, - created() { window.addEventListener('popstate', this.checkHistory); }, destroyed() { + eventHub.$off('fileNameClicked', this.fileClicked); + eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); window.removeEventListener('popstate', this.checkHistory); }, + mounted() { + eventHub.$on('fileNameClicked', this.fileClicked); + eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); + }, + data() { + return Store; + }, + computed: { + flattendFiles() { + const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)])); - data: () => Store, - + return _.chain(this.files) + .map(arr => [arr, mapFiles(arr)]) + .flatten() + .value(); + }, + }, methods: { checkHistory() { let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); @@ -52,21 +67,21 @@ export default { }, fileClicked(clickedFile, lineNumber) { - let file = clickedFile; + const file = clickedFile; + if (file.loading) return; - file.loading = true; if (file.type === 'tree' && file.opened) { - file = Store.removeChildFilesOfTree(file); - file.loading = false; + Helper.setDirectoryToClosed(file); Store.setActiveLine(lineNumber); } else { const openFile = Helper.getFileFromPath(file.url); + if (openFile) { - file.loading = false; Store.setActiveFiles(openFile); Store.setActiveLine(lineNumber); } else { + file.loading = true; Service.url = file.url; Helper.getContent(file) .then(() => { @@ -81,7 +96,7 @@ export default { goToPreviousDirectoryClicked(prevURL) { Service.url = prevURL; - Helper.getContent(null) + Helper.getContent(null, true) .then(() => Helper.scrollTabsRight()) .catch(Helper.loadingError); }, @@ -92,38 +107,43 @@ export default { <template> <div id="sidebar" :class="{'sidebar-mini' : isMini}"> <table class="table"> - <thead v-if="!isMini"> + <thead> <tr> - <th class="name">Name</th> - <th class="hidden-sm hidden-xs last-commit">Last commit</th> - <th class="hidden-xs last-update text-right">Last update</th> + <th + v-if="isMini" + class="repo-file-options title" + > + <strong class="clgray"> + {{ projectName }} + </strong> + </th> + <template v-else> + <th class="name"> + Name + </th> + <th class="hidden-sm hidden-xs last-commit"> + Last commit + </th> + <th class="hidden-xs last-update text-right"> + Last update + </th> + </template> </tr> </thead> <tbody> - <repo-file-options - :is-mini="isMini" - :project-name="projectName" - /> <repo-previous-directory - v-if="isRoot" + v-if="!isRoot && !loading.tree" :prev-url="prevURL" - @linkclicked="goToPreviousDirectoryClicked(prevURL)"/> + /> <repo-loading-file + v-if="!flattendFiles.length && loading.tree" v-for="n in 5" :key="n" - :loading="loading" - :has-files="!!files.length" - :is-mini="isMini" /> <repo-file - v-for="file in files" + v-for="file in flattendFiles" :key="file.id" :file="file" - :is-mini="isMini" - @linkclicked="fileClicked(file)" - :is-tree="isTree" - :has-files="!!files.length" - :active-file="activeFile" /> </tbody> </table> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index 0d0c34ec741..098715915b0 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -26,11 +26,13 @@ const RepoTab = { }, methods: { - tabClicked: Store.setActiveFiles, - + tabClicked(file) { + Store.setActiveFiles(file); + }, closeTab(file) { if (file.changed) return; - this.$emit('tabclosed', file); + + Store.removeFromOpenedFiles(file); }, }, }; @@ -39,25 +41,28 @@ export default RepoTab; </script> <template> -<li @click="tabClicked(tab)"> - <a - href="#0" - class="close" - @click.stop.prevent="closeTab(tab)" - :aria-label="closeLabel"> - <i - class="fa" - :class="changedClass" - aria-hidden="true"> - </i> - </a> + <li + :class="{ active : tab.active }" + @click="tabClicked(tab)" + > + <button + type="button" + class="close-btn" + @click.stop.prevent="closeTab(tab)" + :aria-label="closeLabel"> + <i + class="fa" + :class="changedClass" + aria-hidden="true"> + </i> + </button> - <a - href="#" - class="repo-tab" - :title="tab.url" - @click.prevent="tabClicked(tab)"> - {{tab.name}} - </a> -</li> + <a + href="#" + class="repo-tab" + :title="tab.url" + @click.prevent="tabClicked(tab)"> + {{tab.name}} + </a> + </li> </template> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index 9c5bfc5d0cf..b57cd0960de 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -1,36 +1,29 @@ <script> -import Store from '../stores/repo_store'; -import RepoTab from './repo_tab.vue'; -import RepoMixin from '../mixins/repo_mixin'; + import Store from '../stores/repo_store'; + import RepoTab from './repo_tab.vue'; + import RepoMixin from '../mixins/repo_mixin'; -const RepoTabs = { - mixins: [RepoMixin], - - components: { - 'repo-tab': RepoTab, - }, - - data: () => Store, - - methods: { - tabClosed(file) { - Store.removeFromOpenedFiles(file); + export default { + mixins: [RepoMixin], + components: { + 'repo-tab': RepoTab, }, - }, -}; - -export default RepoTabs; + data() { + return Store; + }, + }; </script> <template> -<ul id="tabs"> - <repo-tab - v-for="tab in openedFiles" - :key="tab.id" - :tab="tab" - :class="{'active' : tab.active}" - @tabclosed="tabClosed" - /> - <li class="tabs-divider" /> -</ul> + <ul + id="tabs" + class="list-unstyled" + > + <repo-tab + v-for="tab in openedFiles" + :key="tab.id" + :tab="tab" + /> + <li class="tabs-divider" /> + </ul> </template> diff --git a/app/assets/javascripts/repo/event_hub.js b/app/assets/javascripts/repo/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/repo/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js index 46204598e1d..dfaf9caaee7 100644 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ b/app/assets/javascripts/repo/helpers/repo_helper.js @@ -1,3 +1,4 @@ +import { convertPermissionToBoolean } from '../../lib/utils/common_utils'; import Service from '../services/repo_service'; import Store from '../stores/repo_store'; import Flash from '../../flash'; @@ -25,10 +26,6 @@ const RepoHelper = { key: '', - isTree(data) { - return Object.hasOwnProperty.call(data, 'blobs'); - }, - Time: window.performance && window.performance.now ? window.performance @@ -58,13 +55,20 @@ const RepoHelper = { }, setDirectoryOpen(tree, title) { - const file = tree; - if (!file) return undefined; + if (!tree) return; + + Object.assign(tree, { + opened: true, + }); + + RepoHelper.updateHistoryEntry(tree.url, title); + }, - file.opened = true; - file.icon = 'fa-folder-open'; - RepoHelper.updateHistoryEntry(file.url, title); - return file; + setDirectoryToClosed(entry) { + Object.assign(entry, { + opened: false, + files: [], + }); }, isRenderable() { @@ -81,63 +85,23 @@ const RepoHelper = { .catch(RepoHelper.loadingError); }, - // when you open a directory you need to put the directory files under - // the directory... This will merge the list of the current directory and the new list. - getNewMergedList(inDirectory, currentList, newList) { - const newListSorted = newList.sort(this.compareFilesCaseInsensitive); - if (!inDirectory) return newListSorted; - const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url); - if (!indexOfFile) return newListSorted; - return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile); - }, - - // within the get new merged list this does the merging of the current list of files - // and the new list of files. The files are never "in" another directory they just - // appear like they are because of the margin. - mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) { - newList.reverse().forEach((newFile) => { - const fileIndex = indexOfFile + 1; - const file = newFile; - file.level = inDirectory.level + 1; - oldList.splice(fileIndex, 0, file); - }); - - return oldList; - }, - - compareFilesCaseInsensitive(a, b) { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (a.level > 0) return 0; - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }, + getContent(treeOrFile, emptyFiles = false) { + let file = treeOrFile; - isRoot(url) { - // the url we are requesting -> split by the project URL. Grab the right side. - const isRoot = !!url.split(Store.projectUrl)[1] - // remove the first "/" - .slice(1) - // split this by "/" - .split('/') - // remove the first two items of the array... usually /tree/master. - .slice(2) - // we want to know the length of the array. - // If greater than 0 not root. - .length; - return isRoot; - }, + if (!Store.files.length) { + Store.loading.tree = true; + } - getContent(treeOrFile) { - let file = treeOrFile; return Service.getContent() .then((response) => { const data = response.data; if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title']; + if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) { + Store.isRoot = convertPermissionToBoolean(response.headers['is-root']); + Store.isInitialRoot = Store.isRoot; + } - Store.isTree = RepoHelper.isTree(data); - if (!Store.isTree) { + if (file && file.type === 'blob') { if (!file) file = data; Store.binary = data.binary; @@ -145,38 +109,40 @@ const RepoHelper = { // file might be undefined RepoHelper.setBinaryDataAsBase64(data); Store.setViewToPreview(); - } else if (!Store.isPreviewView()) { - if (!data.render_error) { - Service.getRaw(data.raw_path) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - data.plain = rawResponse.data; - RepoHelper.setFile(data, file); - }).catch(RepoHelper.loadingError); - } + } else if (!Store.isPreviewView() && !data.render_error) { + Service.getRaw(data.raw_path) + .then((rawResponse) => { + Store.blobRaw = rawResponse.data; + data.plain = rawResponse.data; + RepoHelper.setFile(data, file); + }).catch(RepoHelper.loadingError); } if (Store.isPreviewView()) { RepoHelper.setFile(data, file); } + } else { + Store.loading.tree = false; + RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name); - // if the file tree is empty - if (Store.files.length === 0) { - const parentURL = Service.blobURLtoParentTree(Service.url); - Service.url = parentURL; - RepoHelper.getContent(); + if (emptyFiles) { + Store.files = []; } - } else { - // it's a tree - if (!file) Store.isRoot = RepoHelper.isRoot(Service.url); - file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name); - const newDirectory = RepoHelper.dataToListOfFiles(data); - Store.addFilesToDirectory(file, Store.files, newDirectory); + + this.addToDirectory(file, data); + Store.prevURL = Service.blobURLtoParentTree(Service.url); } }).catch(RepoHelper.loadingError); }, + addToDirectory(file, data) { + const tree = file || Store; + const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0)); + + tree.files = files; + }, + setFile(data, file) { const newFile = data; newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh. @@ -190,57 +156,39 @@ const RepoHelper = { Store.setActiveFiles(newFile); }, - serializeBlob(blob) { - const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob); - simpleBlob.lastCommitMessage = blob.last_commit.message; - simpleBlob.lastCommitUpdate = blob.last_commit.committed_date; - simpleBlob.loading = false; - - return simpleBlob; - }, - - serializeTree(tree) { - return RepoHelper.serializeRepoEntity('tree', tree); - }, - - serializeSubmodule(submodule) { - return RepoHelper.serializeRepoEntity('submodule', submodule); - }, - - serializeRepoEntity(type, entity) { + serializeRepoEntity(type, entity, level = 0) { const { url, name, icon, last_commit } = entity; - const returnObj = { + + return { type, name, url, + level, icon: `fa-${icon}`, - level: 0, + files: [], loading: false, + opened: false, + // eslint-disable-next-line camelcase + lastCommit: last_commit ? { + url: `${Store.projectUrl}/commit/${last_commit.id}`, + message: last_commit.message, + updatedAt: last_commit.committed_date, + } : {}, }; - - if (entity.last_commit) { - returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`; - } else { - returnObj.lastCommitUrl = ''; - } - return returnObj; }, scrollTabsRight() { - // wait for the transition. 0.1 seconds. - setTimeout(() => { - const tabs = document.getElementById('tabs'); - if (!tabs) return; - tabs.scrollLeft = tabs.scrollWidth; - }, 200); + const tabs = document.getElementById('tabs'); + if (!tabs) return; + tabs.scrollLeft = tabs.scrollWidth; }, - dataToListOfFiles(data) { + dataToListOfFiles(data, level) { const { blobs, trees, submodules } = data; return [ - ...blobs.map(blob => RepoHelper.serializeBlob(blob)), - ...trees.map(tree => RepoHelper.serializeTree(tree)), - ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)), + ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)), + ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)), + ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)), ]; }, diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js index 1a09f411b22..65dee7d5fd1 100644 --- a/app/assets/javascripts/repo/index.js +++ b/app/assets/javascripts/repo/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; import Service from './services/repo_service'; import Store from './stores/repo_store'; import Repo from './components/repo.vue'; @@ -33,6 +34,8 @@ function setInitialStore(data) { Store.onTopOfBranch = data.onTopOfBranch; Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl); Store.customBranchURL = decodeURIComponent(data.blobUrl); + Store.isRoot = convertPermissionToBoolean(data.root); + Store.isInitialRoot = convertPermissionToBoolean(data.root); Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); Store.checkIsCommitable(); Store.setBranchHash(); diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js index f8d29af7ffe..49d7317a17e 100644 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ b/app/assets/javascripts/repo/stores/repo_store.js @@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper'; import Service from '../services/repo_service'; const RepoStore = { - monaco: {}, monacoLoading: false, service: '', canCommit: false, onTopOfBranch: false, editMode: false, - isTree: false, - isRoot: false, + isRoot: null, + isInitialRoot: null, prevURL: '', projectId: '', projectName: '', @@ -39,23 +38,11 @@ const RepoStore = { newMrTemplateUrl: '', branchChanged: false, commitMessage: '', - binaryTypes: { - png: false, - md: false, - svg: false, - unknown: false, - }, loading: { tree: false, blob: false, }, - resetBinaryTypes() { - Object.keys(RepoStore.binaryTypes).forEach((key) => { - RepoStore.binaryTypes[key] = false; - }); - }, - setBranchHash() { return Service.getBranch() .then((data) => { @@ -72,10 +59,6 @@ const RepoStore = { RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit; }, - addFilesToDirectory(inDirectory, currentList, newList) { - RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList); - }, - toggleRawPreview() { RepoStore.activeFile.raw = !RepoStore.activeFile.raw; RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; @@ -129,30 +112,6 @@ const RepoStore = { RepoStore.activeFileLabel = 'Display source'; }, - removeChildFilesOfTree(tree) { - let foundTree = false; - const treeToClose = tree; - let canStopSearching = false; - RepoStore.files = RepoStore.files.filter((file) => { - const isItTheTreeWeWant = file.url === treeToClose.url; - // if it's the next tree - if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) { - canStopSearching = true; - return true; - } - if (canStopSearching) return true; - - if (isItTheTreeWeWant) foundTree = true; - - if (foundTree) return file.level <= treeToClose.level; - return true; - }); - - treeToClose.opened = false; - treeToClose.icon = 'fa-folder'; - return treeToClose; - }, - removeFromOpenedFiles(file) { if (file.type === 'tree') return; let foundIndex; @@ -186,6 +145,7 @@ const RepoStore = { if (openedFilesAlreadyExists) return; openFile.changed = false; + openFile.active = true; RepoStore.openedFiles.push(openFile); }, diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index dd9a2ebb184..1ac61a3c39b 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -7,6 +7,7 @@ Sample configuration: <user-avatar-image + :lazy="true" :img-src="userAvatarSrc" :img-alt="tooltipText" :tooltip-text="tooltipText" @@ -16,11 +17,17 @@ */ import defaultAvatarUrl from 'images/no_avatar.png'; +import { placeholderImage } from '../../../lazy_loader'; import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarImage', props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, imgSrc: { type: String, required: false, @@ -56,18 +63,21 @@ export default { tooltip, }, computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; + }, tooltipContainer() { return this.tooltipText ? 'body' : null; }, avatarSizeClass() { return `s${this.size}`; }, - // API response sends null when gravatar is disabled and - // we provide an empty string when we use it inside user avatar link. - // In both cases we should render the defaultAvatarUrl - imageSource() { - return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - }, }, }; </script> @@ -76,11 +86,16 @@ export default { <img v-tooltip class="avatar" - :class="[avatarSizeClass, cssClasses]" - :src="imageSource" + :class="{ + lazy, + [avatarSizeClass]: true, + [cssClasses]: true + }" + :src="resultantSrcAttribute" :width="size" :height="size" :alt="imgAlt" + :data-src="sanitizedSource" :data-container="tooltipContainer" :data-placement="tooltipPlacement" :title="tooltipText" diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index f0e6b23757f..374988bb590 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -198,6 +198,13 @@ a { height: 12px; } + &.animation-container-right { + .skeleton-line-2 { + left: 0; + right: 150px; + } + } + &::before { animation-duration: 1s; animation-fill-mode: forwards; diff --git a/app/assets/stylesheets/framework/new-sidebar.scss b/app/assets/stylesheets/framework/new-sidebar.scss index 78972717932..17fa31c450d 100644 --- a/app/assets/stylesheets/framework/new-sidebar.scss +++ b/app/assets/stylesheets/framework/new-sidebar.scss @@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px; @media (max-width: $screen-xs-max) { + .breadcrumbs-links { - padding-left: 17px; + padding-left: $gl-padding; border-left: 1px solid $gl-text-color-quaternary; } } diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 5538e46a6c4..8d6f30e3b84 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -4,6 +4,6 @@ } .alert-block { - margin-bottom: 20px; + margin-bottom: 10px; } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index c93c4e93af5..48532503263 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -72,12 +72,22 @@ } } + .title-container { + display: flex; + } + .title { padding: 0; margin-bottom: 16px; border-bottom: none; } + .btn-edit { + margin-left: auto; + // Set height to match title height + height: 2em; + } + // Border around images in issue and MR descriptions. .description img:not(.emoji) { border: 1px solid $white-normal; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 96b7db3b85d..ebad429c2ba 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -531,14 +531,13 @@ ul.notes { padding: 0; min-width: 16px; color: $gray-darkest; + fill: $gray-darkest; .fa { position: relative; font-size: 16px; } - - svg { height: 16px; width: 16px; @@ -566,6 +565,7 @@ ul.notes { .link-highlight { color: $gl-link-color; + fill: $gl-link-color; svg { fill: $gl-link-color; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index c36fe25f74d..ea37ccf5e3d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -153,28 +153,13 @@ overflow-x: auto; li { - animation: swipeRightAppear ease-in 0.1s; - animation-iteration-count: 1; - transform-origin: 0% 50%; - list-style-type: none; + position: relative; background: $gray-normal; - display: inline-block; padding: #{$gl-padding / 2} $gl-padding; border-right: 1px solid $white-dark; border-bottom: 1px solid $white-dark; - white-space: nowrap; cursor: pointer; - &.remove { - animation: swipeRightDissapear ease-in 0.1s; - animation-iteration-count: 1; - transform-origin: 0% 50%; - - a { - width: 0; - } - } - &.active { background: $white-light; border-bottom: none; @@ -182,17 +167,21 @@ a { @include str-truncated(100px); - color: $black; + color: $gl-text-color; vertical-align: middle; text-decoration: none; margin-right: 12px; + } - &.close { - width: auto; - font-size: 15px; - opacity: 1; - margin-right: -6px; - } + .close-btn { + position: absolute; + right: 8px; + top: 50%; + padding: 0; + background: none; + border: 0; + font-size: $gl-font-size; + transform: translateY(-50%); } .close-icon:hover { @@ -201,9 +190,6 @@ .close-icon, .unsaved-icon { - float: right; - margin-top: 3px; - margin-left: 15px; color: $gray-darkest; } @@ -222,9 +208,7 @@ #repo-file-buttons { background-color: $white-light; - border-bottom: 1px solid $white-normal; padding: 5px 10px; - position: relative; border-top: 1px solid $white-normal; } @@ -287,37 +271,23 @@ overflow: auto; } - table { + .table { margin-bottom: 0; } tr { - animation: fadein 0.5s; - cursor: pointer; - - &.repo-file-options td { - padding: 0; - border-top: none; - background: $gray-light; + .repo-file-options { + padding: 2px 16px; width: 100%; - display: inline-block; - - &:first-child { - border-top-left-radius: 2px; - } + } - .title { - display: inline-block; - font-size: 10px; - text-transform: uppercase; - font-weight: $gl-font-weight-bold; - color: $gray-darkest; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: middle; - padding: 2px 16px; - } + .title { + font-size: 10px; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; } .file-icon { @@ -329,11 +299,13 @@ } } + .file { + cursor: pointer; + } + a { @include str-truncated(250px); color: $almost-black; - display: inline-block; - vertical-align: middle; } } } diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index d7dd8ddcb7d..9e79852e378 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -2,7 +2,6 @@ class Projects::ApplicationController < ApplicationController include RoutableActions skip_before_action :authenticate_user! - before_action :redirect_git_extension before_action :project before_action :repository layout 'project' @@ -11,15 +10,6 @@ class Projects::ApplicationController < ApplicationController private - def redirect_git_extension - # Redirect from - # localhost/group/project.git - # to - # localhost/group/project - # - redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git' - end - def project return @project if @project return nil unless params[:project_id] || params[:id] diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index f3719059f88..756f7e5df8c 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController format.json do page_title @path.presence || _("Files"), @ref, @project.name_with_namespace + response.header['is-root'] = @path.empty? # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261 Gitlab::GitalyClient.allow_n_plus_1_calls do diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index e90b75672ae..db543d688a0 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -4,6 +4,7 @@ class ProjectsController < Projects::ApplicationController include PreviewMarkdown before_action :authenticate_user!, except: [:index, :show, :activity, :refs] + before_action :redirect_git_extension, only: [:show] before_action :project, except: [:index, :new, :create] before_action :repository, except: [:index, :new, :create] before_action :assign_ref_vars, only: [:show], if: :repo_exists? @@ -125,7 +126,7 @@ class ProjectsController < Projects::ApplicationController return access_denied! unless can?(current_user, :remove_project, @project) ::Projects::DestroyService.new(@project, current_user, {}).async_execute - flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace } + flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.name_with_namespace } redirect_to dashboard_projects_path, status: 302 rescue Projects::DestroyService::DestroyError => ex @@ -389,4 +390,13 @@ class ProjectsController < Projects::ApplicationController def project_export_enabled render_404 unless current_application_settings.project_export_enabled? end + + def redirect_git_extension + # Redirect from + # localhost/group/project.git + # to + # localhost/group/project + # + redirect_to request.original_url.sub(/\.git\/?\Z/, '') if params[:format] == 'git' + end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 0d7347ed30d..8e822ed0ea2 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -36,7 +36,8 @@ module PreferencesHelper def project_view_choices [ ['Files and Readme (default)', :files], - ['Activity', :activity] + ['Activity', :activity], + ['Readme', :readme] ] end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index a93b777a9bc..d3b8debb0fd 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -33,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base attr_accessor :domain_whitelist_raw, :domain_blacklist_raw + default_value_for :id, 1 + validates :uuid, presence: true validates :session_expire_delay, diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 5ab5c80a2f5..b3020484738 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -7,6 +7,8 @@ module Storage raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry') end + expires_full_path_cache + # Move the namespace directory in all storage paths used by member projects repository_storage_paths.each do |repository_storage_path| # Ensure old directory exists before moving it diff --git a/app/models/note.rb b/app/models/note.rb index ceded9f2aef..8939e590ef1 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -169,7 +169,7 @@ class Note < ActiveRecord::Base end def cross_reference? - system? && SystemNoteService.cross_reference?(note) + system? && matches_cross_reference_regex? end def diff_note? diff --git a/app/models/project.rb b/app/models/project.rb index 6e1cf9e31ee..4689b588906 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1272,7 +1272,7 @@ class Project < ActiveRecord::Base # self.forked_from_project will be nil before the project is saved, so # we need to go through the relation - original_project = forked_project_link.forked_from_project + original_project = forked_project_link&.forked_from_project return true unless original_project level <= original_project.visibility_level diff --git a/app/models/user.rb b/app/models/user.rb index 533a776bc65..9459b6d4fa4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -182,13 +182,8 @@ class User < ActiveRecord::Base enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos] # User's Project preference - # - # Note: When adding an option, it MUST go on the end of the hash with a - # number higher than the current max. We cannot move options and/or change - # their numbers. - # - # We skip 0 because this was used by an option that has since been removed. - enum project_view: { activity: 1, files: 2 } + # Note: When adding an option, it MUST go on the end of the array. + enum project_view: [:readme, :activity, :files] alias_attribute :private_token, :authentication_token diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb index ec1fc349586..26a68c43807 100644 --- a/app/serializers/container_tag_entity.rb +++ b/app/serializers/container_tag_entity.rb @@ -1,7 +1,7 @@ class ContainerTagEntity < Grape::Entity include RequestAwareEntity - expose :name, :location, :revision, :total_size, :created_at + expose :name, :location, :revision, :short_revision, :total_size, :created_at expose :destroy_path, if: -> (*) { can_destroy? } do |tag| project_registry_repository_tag_path(project, tag.repository, tag.name, format: :json) diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index abe414d0c05..2b82e5732e4 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -15,8 +15,8 @@ module Projects refresh_forks_count(@project.forked_from_project) - @project.forked_project_link.destroy @project.fork_network_member.destroy + @project.forked_project_link.destroy end def refresh_forks_count(project) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 0bce20ae5b7..69bd19c1977 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -162,7 +162,6 @@ module SystemNoteService # "changed time estimate to 3d 5h" # # Returns the created Note object - def change_time_estimate(noteable, project, author) parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) body = if noteable.time_estimate == 0 @@ -188,7 +187,6 @@ module SystemNoteService # "added 2h 30m of time spent" # # Returns the created Note object - def change_time_spent(noteable, project, author) time_spent = noteable.time_spent @@ -453,10 +451,6 @@ module SystemNoteService end end - def cross_reference?(note_text) - note_text =~ /\A#{cross_reference_note_prefix}/i - end - # Check if a cross-reference is disallowed # # This method prevents adding a "mentioned in !1" note on every single commit @@ -486,7 +480,6 @@ module SystemNoteService # mentioner - Mentionable object # # Returns Boolean - def cross_reference_exists?(noteable, mentioner) # Initial scope should be system notes of this noteable type notes = Note.system.where(noteable_type: noteable.class) diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index 253cd336882..079d9083dff 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -4,7 +4,7 @@ %td.notes_line.old %td.notes_content.parallel.old .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) } - = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old' + = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old', locals: { disable_collapse_class: true } - else %td.notes_line.old= ("") %td.notes_content.parallel.old @@ -14,7 +14,7 @@ %td.notes_line.new %td.notes_content.parallel.new .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) } - = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new' + = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new', locals: { disable_collapse_class: true } - else %td.notes_line.new= ("") %td.notes_content.parallel.new diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml new file mode 100644 index 00000000000..44aa9eb3826 --- /dev/null +++ b/app/views/projects/_readme.html.haml @@ -0,0 +1,23 @@ +- if (readme = @repository.readme) && readme.rich_viewer + %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) } + .js-file-title.file-title + = blob_icon readme.mode, readme.name + = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do + %strong + = readme.name + = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json) + +- else + .row-content-block.second-block.center + %h3.page-title + This project does not have a README yet + - if can?(current_user, :push_code, @project) + %p + A + %code README + file contains information about other files in a repository and is commonly + distributed with computer software, forming part of its documentation. + %p + We recommend you to + = link_to "add a README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link' + file to the repository and GitLab will render it here instead of this message. diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml new file mode 100644 index 00000000000..6c162481dd8 --- /dev/null +++ b/app/views/projects/clusters/_advanced_settings.html.haml @@ -0,0 +1,14 @@ +- if can?(current_user, :admin_cluster, @cluster) + .append-bottom-20 + %label.append-bottom-10 + = s_('ClusterIntegration|Google Container Engine') + %p + - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') + = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } + + .well.form-group + %label.text-danger + = s_('ClusterIntegration|Remove cluster integration') + %p + = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.') + = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"}) diff --git a/app/views/projects/clusters/login.html.haml b/app/views/projects/clusters/login.html.haml index ae132672b7e..fde030b500b 100644 --- a/app/views/projects/clusters/login.html.haml +++ b/app/views/projects/clusters/login.html.haml @@ -10,7 +10,7 @@ .col-sm-8.col-sm-offset-4.signin-with-google - if @authorize_url = link_to @authorize_url do - = image_tag('auth_buttons/signin_with_google.png') + = image_tag('auth_buttons/signin_with_google.png', width: '191px') - else - link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer') = s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link } diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index aee6f904a62..ff76abc3553 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -1,24 +1,37 @@ +- @content_class = "limit-container-width" unless fluid_layout - breadcrumb_title "Cluster" - page_title _("Cluster") +- expanded = Rails.env.test? + - status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation? -.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, +.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, toggle_status: @cluster.enabled? ? 'true': 'false', cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason } } - .col-sm-4 - = render 'sidebar' - .col-sm-8 - %label.append-bottom-10{ for: 'enable-cluster-integration' } - = s_('ClusterIntegration|Enable cluster integration') - %p - - if @cluster.enabled? - - if can?(current_user, :update_cluster, @cluster) - = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.') + + %section.settings + %h4= s_('ClusterIntegration|Enable cluster integration') + .settings-content.expanded + + .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' } + = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine') + %p.js-error-reason + + .hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' } + = s_('ClusterIntegration|Cluster is being created on Google Container Engine...') + + .hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' } + = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine') + + %p + - if @cluster.enabled? + - if can?(current_user, :update_cluster, @cluster) + = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.') + - else + = s_('ClusterIntegration|Cluster integration is enabled for this project.') - else - = s_('ClusterIntegration|Cluster integration is enabled for this project.') - - else - = s_('ClusterIntegration|Cluster integration is disabled for this project.') + = s_('ClusterIntegration|Cluster integration is disabled for this project.') = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field| = form_errors(@cluster) @@ -36,35 +49,28 @@ .form-group = field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success' - - if can?(current_user, :admin_cluster, @cluster) - %label.append-bottom-10{ for: 'google-container-engine' } - = s_('ClusterIntegration|Google Container Engine') - %p - - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') - = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } + %section.settings#js-cluster-details + .settings-header + %h4= s_('ClusterIntegration|Cluster details') + %button.btn.js-settings-toggle + = expanded ? 'Collapse' : 'Expand' + %p= s_('ClusterIntegration|See and edit the details for your cluster') - .hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' } - = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine') - %p.js-error-reason - - .hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' } - = s_('ClusterIntegration|Cluster is being created on Google Container Engine...') - - .hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' } - = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine') + .settings-content.no-animate{ class: ('expanded' if expanded) } - .form_group.append-bottom-20 - %label.append-bottom-10{ for: 'cluter-name' } - = s_('ClusterIntegration|Cluster name') - .input-group - %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true } - %span.input-group-addon.clipboard-addon - = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name')) + .form_group.append-bottom-20 + %label.append-bottom-10{ for: 'cluter-name' } + = s_('ClusterIntegration|Cluster name') + .input-group + %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true } + %span.input-group-addon.clipboard-addon + = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name')) - - if can?(current_user, :admin_cluster, @cluster) - .well.form_group - %label.text-danger - = s_('ClusterIntegration|Remove cluster integration') - %p - = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.') - = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"}) + %section.settings#js-cluster-advanced-settings + .settings-header + %h4= s_('ClusterIntegration|Advanced settings') + %button.btn.js-settings-toggle + = expanded ? 'Collapse' : 'Expand' + %p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project') + .settings-content.no-animate{ class: ('expanded' if expanded) } + = render 'advanced_settings' diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml index 919f19f2c23..7185f5bcc5b 100644 --- a/app/views/shared/repo/_repo.html.haml +++ b/app/views/shared/repo/_repo.html.haml @@ -1,4 +1,5 @@ -#repo{ data: { url: content_url, +#repo{ data: { root: @path.empty?.to_s, + url: content_url, project_name: project.name, refs_url: refs_project_path(project, format: :json), project_url: project_path(project), diff --git a/changelogs/unreleased/30140-restore-readme-only-preference.yml b/changelogs/unreleased/30140-restore-readme-only-preference.yml new file mode 100644 index 00000000000..4b4ee4d5be9 --- /dev/null +++ b/changelogs/unreleased/30140-restore-readme-only-preference.yml @@ -0,0 +1,5 @@ +--- +title: Add readme only option as project view +merge_request: 14900 +author: +type: changed diff --git a/changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml b/changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml new file mode 100644 index 00000000000..22651967a40 --- /dev/null +++ b/changelogs/unreleased/37032-get-project-branch-invalid-name-message.yml @@ -0,0 +1,5 @@ +--- +title: Get Project Branch API shows an helpful error message on invalid refname +merge_request: 14884 +author: Jacopo Beschi @jacopo-beschi +type: added diff --git a/changelogs/unreleased/38986-due-date.yml b/changelogs/unreleased/38986-due-date.yml new file mode 100644 index 00000000000..7799b8d297e --- /dev/null +++ b/changelogs/unreleased/38986-due-date.yml @@ -0,0 +1,5 @@ +--- +title: Fix timezone bug in Pikaday and upgrade Pikaday version +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml b/changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml new file mode 100644 index 00000000000..eef78cd58f9 --- /dev/null +++ b/changelogs/unreleased/add-lazy-option-to-user-avatar-image-component.yml @@ -0,0 +1,5 @@ +--- +title: Add lazy option to UserAvatarImage +merge_request: 14895 +author: +type: changed diff --git a/changelogs/unreleased/bvl-do-not-use-redis-keys.yml b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml new file mode 100644 index 00000000000..f703aad2065 --- /dev/null +++ b/changelogs/unreleased/bvl-do-not-use-redis-keys.yml @@ -0,0 +1,5 @@ +--- +title: Forbid the usage of `Redis#keys` +merge_request: 14889 +author: +type: fixed diff --git a/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml b/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml new file mode 100644 index 00000000000..95f56facc4b --- /dev/null +++ b/changelogs/unreleased/bvl-fix-deleting-forked-projects.yml @@ -0,0 +1,5 @@ +--- +title: Fix error when updating a forked project with deleted `ForkedProjectLink` +merge_request: 14916 +author: +type: fixed diff --git a/changelogs/unreleased/fix-resolved-side-by-side.yml b/changelogs/unreleased/fix-resolved-side-by-side.yml new file mode 100644 index 00000000000..424130c3eb0 --- /dev/null +++ b/changelogs/unreleased/fix-resolved-side-by-side.yml @@ -0,0 +1,5 @@ +--- +title: Fix resolved discussions not expanding on side by side view +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml b/changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml new file mode 100644 index 00000000000..acbb24d16fc --- /dev/null +++ b/changelogs/unreleased/kt-bug-fix-revision-and-size-for-container-registry.yml @@ -0,0 +1,5 @@ +--- +title: Fix revision and total size missing for Container Registry +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/prevent-creating-multiple-application-settings.yml b/changelogs/unreleased/prevent-creating-multiple-application-settings.yml new file mode 100644 index 00000000000..fd49028b9e9 --- /dev/null +++ b/changelogs/unreleased/prevent-creating-multiple-application-settings.yml @@ -0,0 +1,5 @@ +--- +title: Prevent creating multiple ApplicationSetting instances +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/tc-saml-fix-false-empty.yml b/changelogs/unreleased/tc-saml-fix-false-empty.yml new file mode 100644 index 00000000000..987f596475b --- /dev/null +++ b/changelogs/unreleased/tc-saml-fix-false-empty.yml @@ -0,0 +1,5 @@ +--- +title: Fix SAML error 500 when no groups are defined for user +merge_request: 14913 +author: +type: fixed diff --git a/config/database.yml.mysql b/config/database.yml.mysql index eb71d3f5fe1..98c2abe9f5e 100644 --- a/config/database.yml.mysql +++ b/config/database.yml.mysql @@ -10,7 +10,7 @@ production: pool: 10 username: git password: "secure password" - # host: localhost + host: localhost # socket: /tmp/mysql.sock # @@ -25,7 +25,22 @@ development: pool: 5 username: root password: "secure password" - # host: localhost + host: localhost + # socket: /tmp/mysql.sock + +# +# Staging specific +# +staging: + adapter: mysql2 + encoding: utf8 + collation: utf8_general_ci + reconnect: false + database: gitlabhq_staging + pool: 10 + username: git + password: "secure password" + host: localhost # socket: /tmp/mysql.sock # Warning: The database defined as "test" will be erased and @@ -40,6 +55,6 @@ test: &test pool: 5 username: root password: - # host: localhost + host: localhost # socket: /tmp/mysql.sock prepared_statements: false diff --git a/config/database.yml.postgresql b/config/database.yml.postgresql index 4b30982fe82..baded682e46 100644 --- a/config/database.yml.postgresql +++ b/config/database.yml.postgresql @@ -6,10 +6,9 @@ production: encoding: unicode database: gitlabhq_production pool: 10 - # username: git - # password: - # host: localhost - # port: 5432 + username: git + password: "secure password" + host: localhost # # Development specific @@ -20,8 +19,8 @@ development: database: gitlabhq_development pool: 5 username: postgres - password: - # host: localhost + password: "secure password" + host: localhost # # Staging specific @@ -30,10 +29,10 @@ staging: adapter: postgresql encoding: unicode database: gitlabhq_staging - pool: 5 - username: postgres - password: - # host: localhost + pool: 10 + username: git + password: "secure password" + host: localhost # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". @@ -45,5 +44,5 @@ test: &test pool: 5 username: postgres password: - # host: localhost + host: localhost prepared_statements: false diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index db31b01a7d2..3af7f7bd5c0 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -464,3 +464,10 @@ :why: Our own library - https://gitlab.com/gitlab-org/gitlab-svgs :versions: [] :when: 2017-09-19 14:36:32.795496000 Z +- - :license + - pikaday + - MIT + - :who: + :why: + :versions: [] + :when: 2017-10-17 17:46:12.367554000 Z diff --git a/doc/user/project/issues/automatic_issue_closing.md b/doc/user/project/issues/automatic_issue_closing.md index 10dede255ec..402a2a3c727 100644 --- a/doc/user/project/issues/automatic_issue_closing.md +++ b/doc/user/project/issues/automatic_issue_closing.md @@ -1,8 +1,10 @@ # Automatic issue closing ->**Note:** -This is the user docs. In order to change the default issue closing pattern, -follow the steps in the [administration docs]. +>**Notes:** +> - This is the user docs. In order to change the default issue closing pattern, +> follow the steps in the [administration docs]. +> - For performance reasons, automatic issue closing is disabled for the very +> first push from an existing repository. When a commit or merge request resolves one or more issues, it is possible to automatically have these issues closed when the commit or merge request lands diff --git a/lib/api/branches.rb b/lib/api/branches.rb index 61a2d688282..19152c9f395 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -8,6 +8,16 @@ module API before { authorize! :download_code, user_project } + helpers do + def find_branch!(branch_name) + begin + user_project.repository.find_branch(branch_name) || not_found!('Branch') + rescue Gitlab::Git::CommandError + render_api_error!('The branch refname is invalid', 400) + end + end + end + params do requires :id, type: String, desc: 'The ID of a project' end @@ -38,8 +48,7 @@ module API user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404) end get do - branch = user_project.repository.find_branch(params[:branch]) - not_found!('Branch') unless branch + branch = find_branch!(params[:branch]) present branch, with: Entities::Branch, project: user_project end @@ -60,8 +69,7 @@ module API put ':id/repository/branches/:branch/protect', requirements: BRANCH_ENDPOINT_REQUIREMENTS do authorize_admin_project - branch = user_project.repository.find_branch(params[:branch]) - not_found!('Branch') unless branch + branch = find_branch!(params[:branch]) protected_branch = user_project.protected_branches.find_by(name: branch.name) @@ -96,8 +104,7 @@ module API put ':id/repository/branches/:branch/unprotect', requirements: BRANCH_ENDPOINT_REQUIREMENTS do authorize_admin_project - branch = user_project.repository.find_branch(params[:branch]) - not_found!("Branch") unless branch + branch = find_branch!(params[:branch]) protected_branch = user_project.protected_branches.find_by(name: branch.name) protected_branch&.destroy @@ -133,8 +140,7 @@ module API delete ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do authorize_push_project - branch = user_project.repository.find_branch(params[:branch]) - not_found!('Branch') unless branch + branch = find_branch!(params[:branch]) commit = user_project.repository.commit(branch.dereferenced_target) diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index d8c8deea628..6786b9d07b6 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -75,9 +75,19 @@ module Banzai begin node['href'] = node['href'].strip uri = Addressable::URI.parse(node['href']) - uri.scheme = uri.scheme.downcase if uri.scheme - node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme) + return unless uri.scheme + + # Remove all invalid scheme characters before checking against the + # list of unsafe protocols. + # + # See https://tools.ietf.org/html/rfc3986#section-3.1 + scheme = uri.scheme + .strip + .downcase + .gsub(/[^A-Za-z0-9\+\.\-]+/, '') + + node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(scheme) rescue Addressable::URI::InvalidURIError node.remove_attribute('href') end diff --git a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb index 8e5c95f2287..380802258f5 100644 --- a/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb +++ b/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits.rb @@ -81,6 +81,7 @@ module Gitlab def single_diff_rows(merge_request_diff) sha_attribute = Gitlab::Database::ShaAttribute.new commits = YAML.load(merge_request_diff.st_commits) rescue [] + commits ||= [] commit_rows = commits.map.with_index do |commit, index| commit_hash = commit.to_hash.with_indifferent_access.except(:parent_ids) diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb index 4e2d261f461..0456ad9a1f3 100644 --- a/lib/gitlab/git/storage/circuit_breaker.rb +++ b/lib/gitlab/git/storage/circuit_breaker.rb @@ -16,7 +16,7 @@ module Gitlab pattern = "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}*" Gitlab::Git::Storage.redis.with do |redis| - all_storage_keys = redis.keys(pattern) + all_storage_keys = redis.scan_each(match: pattern).to_a redis.del(*all_storage_keys) unless all_storage_keys.empty? end diff --git a/lib/gitlab/git/storage/health.rb b/lib/gitlab/git/storage/health.rb index 1564e94b7f7..7049772fe3b 100644 --- a/lib/gitlab/git/storage/health.rb +++ b/lib/gitlab/git/storage/health.rb @@ -23,26 +23,36 @@ module Gitlab end end - def self.all_keys_for_storages(storage_names, redis) + private_class_method def self.all_keys_for_storages(storage_names, redis) keys_per_storage = {} redis.pipelined do storage_names.each do |storage_name| pattern = pattern_for_storage(storage_name) + matched_keys = redis.scan_each(match: pattern) - keys_per_storage[storage_name] = redis.keys(pattern) + keys_per_storage[storage_name] = matched_keys end end - keys_per_storage + # We need to make sure each lazy-loaded `Enumerator` for matched keys + # is loaded into an array. + # + # Otherwise it would be loaded in the second `Redis#pipelined` block + # within `.load_for_keys`. In this pipelined call, the active + # Redis-client changes again, so the values would not be available + # until the end of that pipelined-block. + keys_per_storage.each do |storage_name, key_future| + keys_per_storage[storage_name] = key_future.to_a + end end - def self.load_for_keys(keys_per_storage, redis) + private_class_method def self.load_for_keys(keys_per_storage, redis) info_for_keys = {} redis.pipelined do keys_per_storage.each do |storage_name, keys_future| - info_for_storage = keys_future.value.map do |key| + info_for_storage = keys_future.map do |key| { name: key, failure_count: redis.hget(key, :failure_count) } end diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index d651c931a38..e7b2f52a552 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -23,14 +23,14 @@ module Gitlab end def write_page(name, format, content, commit_details) - assert_type!(format, Symbol) - assert_type!(commit_details, CommitDetails) - - gollum_wiki.write_page(name, format, content, commit_details.to_h) - - nil - rescue Gollum::DuplicatePageError => e - raise Gitlab::Git::Wiki::DuplicatePageError, e.message + @repository.gitaly_migrate(:wiki_write_page) do |is_enabled| + if is_enabled + gitaly_write_page(name, format, content, commit_details) + gollum_wiki.clear_cache + else + gollum_write_page(name, format, content, commit_details) + end + end end def delete_page(page_path, commit_details) @@ -110,6 +110,25 @@ module Gitlab raise ArgumentError, "expected a #{klass}, got #{object.inspect}" end end + + def gitaly_wiki_client + @gitaly_wiki_client ||= Gitlab::GitalyClient::WikiService.new(@repository) + end + + def gollum_write_page(name, format, content, commit_details) + assert_type!(format, Symbol) + assert_type!(commit_details, CommitDetails) + + gollum_wiki.write_page(name, format, content, commit_details.to_h) + + nil + rescue Gollum::DuplicatePageError => e + raise Gitlab::Git::Wiki::DuplicatePageError, e.message + end + + def gitaly_write_page(name, format, content, commit_details) + gitaly_wiki_client.write_page(name, format, content, commit_details) + end end end end diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb new file mode 100644 index 00000000000..03afcce81f0 --- /dev/null +++ b/lib/gitlab/gitaly_client/wiki_service.rb @@ -0,0 +1,45 @@ +require 'stringio' + +module Gitlab + module GitalyClient + class WikiService + MAX_MSG_SIZE = 128.kilobytes.freeze + + def initialize(repository) + @gitaly_repo = repository.gitaly_repository + @repository = repository + end + + def write_page(name, format, content, commit_details) + request = Gitaly::WikiWritePageRequest.new( + repository: @gitaly_repo, + name: GitalyClient.encode(name), + format: format.to_s, + commit_details: Gitaly::WikiCommitDetails.new( + name: GitalyClient.encode(commit_details.name), + email: GitalyClient.encode(commit_details.email), + message: GitalyClient.encode(commit_details.message) + ) + ) + + strio = StringIO.new(content) + + enum = Enumerator.new do |y| + until strio.eof? + chunk = strio.read(MAX_MSG_SIZE) + request.content = GitalyClient.encode(chunk) + + y.yield request + + request = Gitaly::WikiWritePageRequest.new + end + end + + response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_write_page, enum) + if error = response.duplicate_error.presence + raise Gitlab::Git::Wiki::DuplicatePageError, error + end + end + end + end +end diff --git a/lib/gitlab/saml/auth_hash.rb b/lib/gitlab/saml/auth_hash.rb index 67a5f368bdb..33d19373098 100644 --- a/lib/gitlab/saml/auth_hash.rb +++ b/lib/gitlab/saml/auth_hash.rb @@ -2,7 +2,7 @@ module Gitlab module Saml class AuthHash < Gitlab::OAuth::AuthHash def groups - get_raw(Gitlab::Saml::Config.groups) + Array.wrap(get_raw(Gitlab::Saml::Config.groups)) end private diff --git a/package.json b/package.json index 620b39766f4..057cd8f7bc7 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "monaco-editor": "0.10.0", "mousetrap": "^1.4.6", "name-all-modules-plugin": "^1.0.1", - "pikaday": "^1.5.1", + "pikaday": "^1.6.1", "prismjs": "^1.6.0", "raphael": "^2.2.7", "raven-js": "^3.14.0", diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index 39806901274..7abadef5e89 100644 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -27,11 +27,9 @@ fi cp config/database.yml.$GITLAB_DATABASE config/database.yml if [ "$GITLAB_DATABASE" = 'postgresql' ]; then - sed -i 's/# host:.*/host: postgres/g' config/database.yml + sed -i 's/localhost/postgres/g' config/database.yml else # Assume it's mysql - sed -i 's/username:.*/username: root/g' config/database.yml - sed -i 's/password:.*/password:/g' config/database.yml - sed -i 's/# host:.*/host: mysql/g' config/database.yml + sed -i 's/localhost/mysql/g' config/database.yml fi cp config/resque.yml.example config/resque.yml diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index ce5040ff02b..d380978b86e 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -1,9 +1,10 @@ require('spec_helper') -describe ProfilesController do - describe "PUT update" do - it "allows an email update from a user without an external email address" do - user = create(:user) +describe ProfilesController, :request_store do + let(:user) { create(:user) } + + describe 'PUT update' do + it 'allows an email update from a user without an external email address' do sign_in(user) put :update, @@ -29,7 +30,7 @@ describe ProfilesController do expect(user.unconfirmed_email).to eq nil end - it "ignores an email update from a user with an external email address" do + it 'ignores an email update from a user with an external email address' do stub_omniauth_setting(sync_profile_from_provider: ['ldap']) stub_omniauth_setting(sync_profile_attributes: true) @@ -46,7 +47,7 @@ describe ProfilesController do expect(ldap_user.unconfirmed_email).not_to eq('john@gmail.com') end - it "ignores an email and name update but allows a location update from a user with external email and name, but not external location" do + it 'ignores an email and name update but allows a location update from a user with external email and name, but not external location' do stub_omniauth_setting(sync_profile_from_provider: ['ldap']) stub_omniauth_setting(sync_profile_attributes: true) @@ -65,4 +66,35 @@ describe ProfilesController do expect(ldap_user.location).to eq('City, Country') end end + + describe 'PUT update_username' do + let(:namespace) { user.namespace } + let(:project) { create(:project_empty_repo, namespace: namespace) } + let(:gitlab_shell) { Gitlab::Shell.new } + let(:new_username) { 'renamedtosomethingelse' } + + it 'allows username change' do + sign_in(user) + + put :update_username, + user: { username: new_username } + + user.reload + + expect(response.status).to eq(302) + expect(user.username).to eq(new_username) + end + + it 'moves dependent projects to new namespace' do + sign_in(user) + + put :update_username, + user: { username: new_username } + + user.reload + + expect(response.status).to eq(302) + expect(gitlab_shell.exists?(project.repository_storage_path, "#{new_username}/#{project.path}.git")).to be_truthy + end + end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 053bd73fee3..ed8088a46f0 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -850,47 +850,48 @@ describe Projects::IssuesController do describe 'GET #discussions' do let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } - - before do - project.add_developer(user) - sign_in(user) - end - - it 'returns discussion json' do - get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - - expect(JSON.parse(response.body).first.keys).to match_array(%w[id reply_id expanded notes individual_note]) - end - - context 'with cross-reference system note', :request_store do - let(:new_issue) { create(:issue) } - let(:cross_reference) { "mentioned in #{new_issue.to_reference(issue.project)}" } - + context 'when authenticated' do before do - create(:discussion_note_on_issue, :system, noteable: issue, project: issue.project, note: cross_reference) + project.add_developer(user) + sign_in(user) end - it 'filters notes that the user should not see' do + it 'returns discussion json' do get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - expect(JSON.parse(response.body).count).to eq(1) + expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note]) end - it 'does not result in N+1 queries' do - # Instantiate the controller variables to ensure QueryRecorder has an accurate base count - get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid + context 'with cross-reference system note', :request_store do + let(:new_issue) { create(:issue) } + let(:cross_reference) { "mentioned in #{new_issue.to_reference(issue.project)}" } - RequestStore.clear! + before do + create(:discussion_note_on_issue, :system, noteable: issue, project: issue.project, note: cross_reference) + end - control_count = ActiveRecord::QueryRecorder.new do + it 'filters notes that the user should not see' do get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - end.count - RequestStore.clear! + expect(JSON.parse(response.body).count).to eq(1) + end - create_list(:discussion_note_on_issue, 2, :system, noteable: issue, project: issue.project, note: cross_reference) + it 'does not result in N+1 queries' do + # Instantiate the controller variables to ensure QueryRecorder has an accurate base count + get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - expect { get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid }.not_to exceed_query_limit(control_count) + RequestStore.clear! + + control_count = ActiveRecord::QueryRecorder.new do + get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid + end.count + + RequestStore.clear! + + create_list(:discussion_note_on_issue, 2, :system, noteable: issue, project: issue.project, note: cross_reference) + + expect { get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid }.not_to exceed_query_limit(control_count) + end end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 0544afe31ed..7569052c3aa 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -222,6 +222,14 @@ describe ProjectsController do get :show, namespace_id: public_project.namespace, id: public_project expect(response).to render_template('_files') end + + it "renders the readme view" do + allow(controller).to receive(:current_user).and_return(user) + allow(user).to receive(:project_view).and_return('readme') + + get :show, namespace_id: public_project.namespace, id: public_project + expect(response).to render_template('_readme') + end end context "when the url contains .atom" do diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 475c8586f45..3db0729cafb 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -97,14 +97,33 @@ feature 'Diff notes resolve', :js do visit_merge_request end - it 'hides when resolve discussion is clicked' do - expect(page).to have_selector('.discussion-body', visible: false) + describe 'timeline view' do + it 'hides when resolve discussion is clicked' do + expect(page).to have_selector('.discussion-body', visible: false) + end + + it 'shows resolved discussion when toggled' do + find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click + + expect(page.find(".timeline-content #note_#{note.noteable_id}")).to be_visible + end end - it 'shows resolved discussion when toggled' do - find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click + describe 'side-by-side view' do + before do + page.within('.merge-request-tabs') { click_link 'Changes' } + page.find('#parallel-diff-btn').click + end - expect(page.find(".timeline-content #note_#{note.noteable_id}")).to be_visible + it 'hides when resolve discussion is clicked' do + expect(page).to have_selector('.diffs .diff-file .notes_holder', visible: false) + end + + it 'shows resolved discussion when toggled' do + find('.diff-comment-avatar-holders').click + + expect(find('.diffs .diff-file .notes_holder')).to be_visible + end end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 3bc7ec3123f..3b01ed442bf 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -139,7 +139,7 @@ feature 'Project' do it 'removes a project' do expect { remove_with_confirm('Remove project', project.path) }.to change {Project.count}.by(-1) - expect(page).to have_content "Project 'test / project1' will be deleted." + expect(page).to have_content "Project 'test / project1' is in the process of being deleted." expect(Project.all.count).to be_zero expect(project.issues).to be_empty expect(project.merge_requests).to be_empty diff --git a/spec/fixtures/api/schemas/registry/tag.json b/spec/fixtures/api/schemas/registry/tag.json index 5bc307e0e64..3a2c88791e1 100644 --- a/spec/fixtures/api/schemas/registry/tag.json +++ b/spec/fixtures/api/schemas/registry/tag.json @@ -14,6 +14,11 @@ "revision": { "type": "string" }, + "short_revision": { + "type": "string", + "minLength": 9, + "maxLength": 9 + }, "total_size": { "type": "integer" }, diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index 67166802c70..2ecb64d84b5 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -791,6 +791,29 @@ describe('Filtered Search Visual Tokens', () => { expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); const avatar = tokenValueElement.querySelector('img.avatar'); expect(avatar.src).toBe(dummyUser.avatar_url); + expect(avatar.alt).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + + it('escapes user name when creating token', (done) => { + const dummyUser = { + name: '<script>', + avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`, + }; + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + tokenValueElement.querySelector('.avatar').remove(); + expect(tokenValueElement.innerHTML.trim()).toBe(_.escape(dummyUser.name)); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 583a3a74d77..2ea290108a4 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -332,4 +332,15 @@ describe('Issuable output', () => { .catch(done.fail); }); }); + + describe('show inline edit button', () => { + it('should not render by default', () => { + expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined(); + }); + + it('should render if showInlineEditButton', () => { + vm.showInlineEditButton = true; + expect(vm.$el.querySelector('.title-container .note-action-button')).toBeDefined(); + }); + }); }); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js index a2d90a9b9f5..c1edc785d0f 100644 --- a/spec/javascripts/issue_show/components/title_spec.js +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Store from '~/issue_show/stores'; import titleComponent from '~/issue_show/components/title.vue'; +import eventHub from '~/issue_show/event_hub'; describe('Title component', () => { let vm; @@ -25,7 +26,7 @@ describe('Title component', () => { it('renders title HTML', () => { expect( - vm.$el.innerHTML.trim(), + vm.$el.querySelector('.title').innerHTML.trim(), ).toBe('Testing <img>'); }); @@ -47,12 +48,12 @@ describe('Title component', () => { Vue.nextTick(() => { expect( - vm.$el.classList.contains('issue-realtime-pre-pulse'), + vm.$el.querySelector('.title').classList.contains('issue-realtime-pre-pulse'), ).toBeTruthy(); setTimeout(() => { expect( - vm.$el.classList.contains('issue-realtime-trigger-pulse'), + vm.$el.querySelector('.title').classList.contains('issue-realtime-trigger-pulse'), ).toBeTruthy(); done(); @@ -72,4 +73,36 @@ describe('Title component', () => { done(); }); }); + + describe('inline edit button', () => { + beforeEach(() => { + spyOn(eventHub, '$emit'); + }); + + it('should not show by default', () => { + expect(vm.$el.querySelector('.note-action-button')).toBeNull(); + }); + + it('should not show if canUpdate is false', () => { + vm.showInlineEditButton = true; + vm.canUpdate = false; + expect(vm.$el.querySelector('.note-action-button')).toBeNull(); + }); + + it('should show if showInlineEditButton and canUpdate', () => { + vm.showInlineEditButton = true; + vm.canUpdate = true; + expect(vm.$el.querySelector('.note-action-button')).toBeDefined(); + }); + + it('should trigger open.form event when clicked', () => { + vm.showInlineEditButton = true; + vm.canUpdate = true; + + Vue.nextTick(() => { + vm.$el.querySelector('.note-action-button').click(); + expect(eventHub.$emit).toHaveBeenCalledWith('open.form'); + }); + }); + }); }); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index 6613b7dee6b..a5298be5669 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -467,19 +467,27 @@ describe('common_utils', () => { commonUtils.ajaxPost(requestURL, data); expect(ajaxSpy.calls.allArgs()[0][0].type).toEqual('POST'); }); + }); - describe('gl.utils.spriteIcon', () => { - beforeEach(() => { - window.gon.sprite_icons = 'icons.svg'; - }); + describe('spriteIcon', () => { + let beforeGon; - it('should return the svg for a linked icon', () => { - expect(gl.utils.spriteIcon('test')).toEqual('<svg ><use xlink:href="icons.svg#test" /></svg>'); - }); + beforeEach(() => { + window.gon = window.gon || {}; + beforeGon = Object.assign({}, window.gon); + window.gon.sprite_icons = 'icons.svg'; + }); - it('should set svg className when passed', () => { - expect(gl.utils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>'); - }); + afterEach(() => { + window.gon = beforeGon; + }); + + it('should return the svg for a linked icon', () => { + expect(commonUtils.spriteIcon('test')).toEqual('<svg ><use xlink:href="icons.svg#test" /></svg>'); + }); + + it('should set svg className when passed', () => { + expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual('<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>'); }); }); }); diff --git a/spec/javascripts/lib/utils/datefix_spec.js b/spec/javascripts/lib/utils/datefix_spec.js new file mode 100644 index 00000000000..0b9fde2be67 --- /dev/null +++ b/spec/javascripts/lib/utils/datefix_spec.js @@ -0,0 +1,29 @@ +import { pad, parsePikadayDate, pikadayToString } from '~/lib/utils/datefix'; + +describe('datefix', () => { + describe('pad', () => { + it('should add a 0 when length is smaller than 2', () => { + expect(pad(2)).toEqual('02'); + }); + + it('should not add a zero when lenght matches the default', () => { + expect(pad(12)).toEqual('12'); + }); + + it('should add a 0 when lenght is smaller than the provided', () => { + expect(pad(12, 3)).toEqual('012'); + }); + }); + + describe('parsePikadayDate', () => { + it('should return a UTC date', () => { + expect(parsePikadayDate('2020-01-29')).toEqual(new Date('2020-01-29')); + }); + }); + + describe('pikadayToString', () => { + it('should format a UTC date into yyyy-mm-dd format', () => { + expect(pikadayToString(new Date('2020-01-29'))).toEqual('2020-01-29'); + }); + }); +}); diff --git a/spec/javascripts/registry/mock_data.js b/spec/javascripts/registry/mock_data.js index 18600d00bff..6bffb47be55 100644 --- a/spec/javascripts/registry/mock_data.js +++ b/spec/javascripts/registry/mock_data.js @@ -26,7 +26,7 @@ export const registryServerResponse = [ name: 'centos7', short_revision: 'b118ab5b0', revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - size: 679, + total_size: 679, layers: 19, location: 'location', created_at: 1505828744434, @@ -36,7 +36,7 @@ export const registryServerResponse = [ name: 'centos6', short_revision: 'b118ab5b0', revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', - size: 679, + total_size: 679, layers: 19, location: 'location', created_at: 1505828744434, @@ -70,7 +70,7 @@ export const parsedRegistryServerResponse = [ tag: registryServerResponse[0].name, revision: registryServerResponse[0].revision, shortRevision: registryServerResponse[0].short_revision, - size: registryServerResponse[0].size, + size: registryServerResponse[0].total_size, layers: registryServerResponse[0].layers, location: registryServerResponse[0].location, createdAt: registryServerResponse[0].created_at, @@ -81,7 +81,7 @@ export const parsedRegistryServerResponse = [ tag: registryServerResponse[1].name, revision: registryServerResponse[1].revision, shortRevision: registryServerResponse[1].short_revision, - size: registryServerResponse[1].size, + size: registryServerResponse[1].total_size, layers: registryServerResponse[1].layers, location: registryServerResponse[1].location, createdAt: registryServerResponse[1].created_at, diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js index 0635de4b30b..e09d593f04c 100644 --- a/spec/javascripts/repo/components/repo_commit_section_spec.js +++ b/spec/javascripts/repo/components/repo_commit_section_spec.js @@ -134,6 +134,7 @@ describe('RepoCommitSection', () => { afterEach(() => { vm.$destroy(); el.remove(); + RepoStore.openedFiles = []; }); it('shows commit message', () => { diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js index 411514009dc..dff2fac191d 100644 --- a/spec/javascripts/repo/components/repo_edit_button_spec.js +++ b/spec/javascripts/repo/components/repo_edit_button_spec.js @@ -9,6 +9,10 @@ describe('RepoEditButton', () => { return new RepoEditButton().$mount(); } + afterEach(() => { + RepoStore.openedFiles = []; + }); + it('renders an edit button that toggles the view state', (done) => { RepoStore.isCommitable = true; RepoStore.changedFiles = []; @@ -38,12 +42,4 @@ describe('RepoEditButton', () => { expect(vm.$el.innerHTML).toBeUndefined(); }); - - describe('methods', () => { - describe('editCancelClicked', () => { - it('sets dialog to open when there are changedFiles'); - - it('toggles editMode and calls toggleBlobView'); - }); - }); }); diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js index 85d55d171f9..a25a600b3be 100644 --- a/spec/javascripts/repo/components/repo_editor_spec.js +++ b/spec/javascripts/repo/components/repo_editor_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import RepoStore from '~/repo/stores/repo_store'; import repoEditor from '~/repo/components/repo_editor.vue'; describe('RepoEditor', () => { @@ -8,6 +9,10 @@ describe('RepoEditor', () => { this.vm = new RepoEditor().$mount(); }); + afterEach(() => { + RepoStore.openedFiles = []; + }); + it('renders an ide container', (done) => { this.vm.openedFiles = ['idiidid']; this.vm.binary = false; diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js index dfab51710c3..701c260224f 100644 --- a/spec/javascripts/repo/components/repo_file_buttons_spec.js +++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js @@ -9,6 +9,10 @@ describe('RepoFileButtons', () => { return new RepoFileButtons().$mount(); } + afterEach(() => { + RepoStore.openedFiles = []; + }); + it('renders Raw, Blame, History, Permalink and Preview toggle', () => { const activeFile = { extension: 'md', diff --git a/spec/javascripts/repo/components/repo_file_options_spec.js b/spec/javascripts/repo/components/repo_file_options_spec.js deleted file mode 100644 index 9759b4bf12d..00000000000 --- a/spec/javascripts/repo/components/repo_file_options_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import Vue from 'vue'; -import repoFileOptions from '~/repo/components/repo_file_options.vue'; - -describe('RepoFileOptions', () => { - const projectName = 'projectName'; - - function createComponent(propsData) { - const RepoFileOptions = Vue.extend(repoFileOptions); - - return new RepoFileOptions({ - propsData, - }).$mount(); - } - - it('renders the title and new file/folder buttons if isMini is true', () => { - const vm = createComponent({ - isMini: true, - projectName, - }); - - expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy(); - expect(vm.$el.querySelector('.title').textContent).toEqual(projectName); - }); - - it('does not render if isMini is false', () => { - const vm = createComponent({ - isMini: false, - projectName, - }); - - expect(vm.$el.innerHTML).toBeFalsy(); - }); -}); diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js index 620b604f404..334bf0997ca 100644 --- a/spec/javascripts/repo/components/repo_file_spec.js +++ b/spec/javascripts/repo/components/repo_file_spec.js @@ -1,21 +1,11 @@ import Vue from 'vue'; import repoFile from '~/repo/components/repo_file.vue'; import RepoStore from '~/repo/stores/repo_store'; +import eventHub from '~/repo/event_hub'; +import { file } from '../mock_data'; describe('RepoFile', () => { const updated = 'updated'; - const file = { - icon: 'icon', - url: 'url', - name: 'name', - lastCommitMessage: 'message', - lastCommitUpdate: Date.now(), - level: 10, - }; - const activeFile = { - pageTitle: 'pageTitle', - url: 'url', - }; const otherFile = { html: '<p class="file-content">html</p>', pageTitle: 'otherpageTitle', @@ -29,12 +19,15 @@ describe('RepoFile', () => { }).$mount(); } + beforeEach(() => { + RepoStore.openedFiles = []; + }); + it('renders link, icon, name and last commit details', () => { const RepoFile = Vue.extend(repoFile); const vm = new RepoFile({ propsData: { - file, - activeFile, + file: file(), }, }); spyOn(vm, 'timeFormated').and.returnValue(updated); @@ -43,28 +36,20 @@ describe('RepoFile', () => { const name = vm.$el.querySelector('.repo-file-name'); const fileIcon = vm.$el.querySelector('.file-icon'); - expect(vm.$el.classList.contains('active')).toBeTruthy(); - expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px'); - expect(name.title).toEqual(file.url); - expect(name.href).toMatch(`/${file.url}`); - expect(name.textContent.trim()).toEqual(file.name); - expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(file.lastCommitMessage); + expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px'); + expect(name.href).toMatch(`/${vm.file.url}`); + expect(name.textContent.trim()).toEqual(vm.file.name); + expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(vm.file.lastCommit.message); expect(vm.$el.querySelector('.commit-update').textContent.trim()).toBe(updated); - expect(fileIcon.classList.contains(file.icon)).toBeTruthy(); - expect(fileIcon.style.marginLeft).toEqual(`${file.level * 10}px`); + expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy(); + expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`); }); it('does render if hasFiles is true and is loading tree', () => { const vm = createComponent({ - file, - activeFile, - loading: { - tree: true, - }, - hasFiles: true, + file: file(), }); - expect(vm.$el.innerHTML).toBeTruthy(); expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy(); }); @@ -75,75 +60,51 @@ describe('RepoFile', () => { }); it('renders a spinner if the file is loading', () => { - file.loading = true; - const vm = createComponent({ - file, - activeFile, - loading: { - tree: true, - }, - hasFiles: true, - }); - - expect(vm.$el.innerHTML).toBeTruthy(); - expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${file.level * 10}px`); - }); - - it('does not render if loading tree', () => { + const f = file(); + f.loading = true; const vm = createComponent({ - file, - activeFile, - loading: { - tree: true, - }, + file: f, }); - expect(vm.$el.innerHTML).toBeFalsy(); + expect(vm.$el.querySelector('.fa-spin.fa-spinner')).not.toBeNull(); + expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`); }); it('does not render commit message and datetime if mini', () => { + RepoStore.openedFiles.push(file()); + const vm = createComponent({ - file, - activeFile, - isMini: true, + file: file(), }); expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); }); - it('does not set active class if file is active file', () => { - const vm = createComponent({ - file, - activeFile: {}, - }); - - expect(vm.$el.classList.contains('active')).toBeFalsy(); - }); - it('fires linkClicked when the link is clicked', () => { const vm = createComponent({ - file, - activeFile, + file: file(), }); spyOn(vm, 'linkClicked'); - vm.$el.querySelector('.repo-file-name').click(); + vm.$el.click(); - expect(vm.linkClicked).toHaveBeenCalledWith(file); + expect(vm.linkClicked).toHaveBeenCalledWith(vm.file); }); describe('methods', () => { describe('linkClicked', () => { - const vm = jasmine.createSpyObj('vm', ['$emit']); + it('$emits fileNameClicked with file obj', () => { + spyOn(eventHub, '$emit'); - it('$emits linkclicked with file obj', () => { - const theFile = {}; + const vm = createComponent({ + file: file(), + }); - repoFile.methods.linkClicked.call(vm, theFile); + vm.linkClicked(vm.file); - expect(vm.$emit).toHaveBeenCalledWith('linkclicked', theFile); + expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file); }); }); }); diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js index a030314d749..e9f95a02028 100644 --- a/spec/javascripts/repo/components/repo_loading_file_spec.js +++ b/spec/javascripts/repo/components/repo_loading_file_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import RepoStore from '~/repo/stores/repo_store'; import repoLoadingFile from '~/repo/components/repo_loading_file.vue'; describe('RepoLoadingFile', () => { @@ -28,6 +29,10 @@ describe('RepoLoadingFile', () => { }); } + afterEach(() => { + RepoStore.openedFiles = []; + }); + it('renders 3 columns of animated LoC', () => { const vm = createComponent({ loading: { @@ -42,38 +47,16 @@ describe('RepoLoadingFile', () => { }); it('renders 1 column of animated LoC if isMini', () => { + RepoStore.openedFiles = new Array(1); const vm = createComponent({ loading: { tree: true, }, hasFiles: false, - isMini: true, }); const columns = [...vm.$el.querySelectorAll('td')]; expect(columns.length).toEqual(1); assertColumns(columns); }); - - it('does not render if tree is not loading', () => { - const vm = createComponent({ - loading: { - tree: false, - }, - hasFiles: false, - }); - - expect(vm.$el.innerHTML).toBeFalsy(); - }); - - it('does not render if hasFiles is true', () => { - const vm = createComponent({ - loading: { - tree: true, - }, - hasFiles: true, - }); - - expect(vm.$el.innerHTML).toBeFalsy(); - }); }); diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js index 34dde545e6a..4c064f21084 100644 --- a/spec/javascripts/repo/components/repo_prev_directory_spec.js +++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue'; +import eventHub from '~/repo/event_hub'; describe('RepoPrevDirectory', () => { function createComponent(propsData) { @@ -20,7 +21,7 @@ describe('RepoPrevDirectory', () => { spyOn(vm, 'linkClicked'); expect(link.href).toMatch(`/${prevUrl}`); - expect(link.textContent).toEqual('..'); + expect(link.textContent).toEqual('...'); link.click(); @@ -29,14 +30,17 @@ describe('RepoPrevDirectory', () => { describe('methods', () => { describe('linkClicked', () => { - const vm = jasmine.createSpyObj('vm', ['$emit']); + it('$emits linkclicked with prevUrl', () => { + const prevUrl = 'prevUrl'; + const vm = createComponent({ + prevUrl, + }); - it('$emits linkclicked with file obj', () => { - const file = {}; + spyOn(eventHub, '$emit'); - repoPrevDirectory.methods.linkClicked.call(vm, file); + vm.linkClicked(prevUrl); - expect(vm.$emit).toHaveBeenCalledWith('linkclicked', file); + expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl); }); }); }); diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js index 35d2b37ac2a..61283da8257 100644 --- a/spec/javascripts/repo/components/repo_sidebar_spec.js +++ b/spec/javascripts/repo/components/repo_sidebar_spec.js @@ -3,6 +3,7 @@ import Helper from '~/repo/helpers/repo_helper'; import RepoService from '~/repo/services/repo_service'; import RepoStore from '~/repo/stores/repo_store'; import repoSidebar from '~/repo/components/repo_sidebar.vue'; +import { file } from '../mock_data'; describe('RepoSidebar', () => { let vm; @@ -15,14 +16,15 @@ describe('RepoSidebar', () => { afterEach(() => { vm.$destroy(); + + RepoStore.files = []; + RepoStore.openedFiles = []; }); it('renders a sidebar', () => { - RepoStore.files = [{ - id: 0, - }]; + RepoStore.files = [file()]; RepoStore.openedFiles = []; - RepoStore.isRoot = false; + RepoStore.isRoot = true; vm = createComponent(); const thead = vm.$el.querySelector('thead'); @@ -30,9 +32,9 @@ describe('RepoSidebar', () => { expect(vm.$el.id).toEqual('sidebar'); expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); - expect(thead.querySelector('.name').textContent).toEqual('Name'); - expect(thead.querySelector('.last-commit').textContent).toEqual('Last commit'); - expect(thead.querySelector('.last-update').textContent).toEqual('Last update'); + expect(thead.querySelector('.name').textContent.trim()).toEqual('Name'); + expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit'); + expect(thead.querySelector('.last-update').textContent.trim()).toEqual('Last update'); expect(tbody.querySelector('.repo-file-options')).toBeFalsy(); expect(tbody.querySelector('.prev-directory')).toBeFalsy(); expect(tbody.querySelector('.loading-file')).toBeFalsy(); @@ -46,76 +48,74 @@ describe('RepoSidebar', () => { vm = createComponent(); expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy(); - expect(vm.$el.querySelector('thead')).toBeFalsy(); - expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy(); + expect(vm.$el.querySelector('thead')).toBeTruthy(); + expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy(); }); it('renders 5 loading files if tree is loading and not hasFiles', () => { - RepoStore.loading = { - tree: true, - }; + RepoStore.loading.tree = true; RepoStore.files = []; vm = createComponent(); expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); }); - it('renders a prev directory if isRoot', () => { - RepoStore.files = [{ - id: 0, - }]; - RepoStore.isRoot = true; + it('renders a prev directory if is not root', () => { + RepoStore.files = [file()]; + RepoStore.isRoot = false; + RepoStore.loading.tree = false; vm = createComponent(); expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); }); + describe('flattendFiles', () => { + it('returns a flattend array of files', () => { + const f = file(); + f.files.push(file('testing 123')); + const files = [f, file()]; + vm = createComponent(); + vm.files = files; + + expect(vm.flattendFiles.length).toBe(3); + expect(vm.flattendFiles[1].name).toBe('testing 123'); + }); + }); + describe('methods', () => { describe('fileClicked', () => { it('should fetch data for new file', () => { spyOn(Helper, 'getContent').and.callThrough(); - const file1 = { - id: 0, - url: '', - }; - RepoStore.files = [file1]; + RepoStore.files = [file()]; RepoStore.isRoot = true; vm = createComponent(); - vm.fileClicked(file1); + vm.fileClicked(RepoStore.files[0]); - expect(Helper.getContent).toHaveBeenCalledWith(file1); + expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]); }); it('should not fetch data for already opened files', () => { - const file = { - id: 42, - url: 'foo', - }; - - spyOn(Helper, 'getFileFromPath').and.returnValue(file); + const f = file(); + spyOn(Helper, 'getFileFromPath').and.returnValue(f); spyOn(RepoStore, 'setActiveFiles'); vm = createComponent(); - vm.fileClicked(file); + vm.fileClicked(f); - expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(file); + expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f); }); it('should hide files in directory if already open', () => { - spyOn(RepoStore, 'removeChildFilesOfTree').and.callThrough(); - const file1 = { - id: 0, - type: 'tree', - url: '', - opened: true, - }; - RepoStore.files = [file1]; - RepoStore.isRoot = true; + spyOn(Helper, 'setDirectoryToClosed').and.callThrough(); + const f = file(); + f.opened = true; + f.type = 'tree'; + RepoStore.files = [f]; vm = createComponent(); - vm.fileClicked(file1); + vm.fileClicked(RepoStore.files[0]); - expect(RepoStore.removeChildFilesOfTree).toHaveBeenCalledWith(file1); + expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]); }); }); @@ -131,36 +131,31 @@ describe('RepoSidebar', () => { }); describe('back button', () => { - const file1 = { - id: 1, - url: 'file1', - }; - const file2 = { - id: 2, - url: 'file2', - }; - RepoStore.files = [file1, file2]; - RepoStore.openedFiles = [file1, file2]; - RepoStore.isRoot = true; + beforeEach(() => { + const f = file(); + const file2 = Object.assign({}, file()); + file2.url = 'test'; + RepoStore.files = [f, file2]; + RepoStore.openedFiles = []; + RepoStore.isRoot = true; - vm = createComponent(); - vm.fileClicked(file1); + vm = createComponent(); + }); it('render previous file when using back button', () => { spyOn(Helper, 'getContent').and.callThrough(); - vm.fileClicked(file2); - expect(Helper.getContent).toHaveBeenCalledWith(file2); - Helper.getContent.calls.reset(); + vm.fileClicked(RepoStore.files[1]); + expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]); history.pushState({ key: Math.random(), - }, '', file1.url); + }, '', RepoStore.files[1].url); const popEvent = document.createEvent('Event'); popEvent.initEvent('popstate', true, true); window.dispatchEvent(popEvent); - expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(file1.url); + expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url); window.history.pushState({}, null, '/'); }); diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js index d2a790ad73a..37e297437f0 100644 --- a/spec/javascripts/repo/components/repo_tab_spec.js +++ b/spec/javascripts/repo/components/repo_tab_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import repoTab from '~/repo/components/repo_tab.vue'; +import RepoStore from '~/repo/stores/repo_store'; describe('RepoTab', () => { function createComponent(propsData) { @@ -18,7 +19,7 @@ describe('RepoTab', () => { const vm = createComponent({ tab, }); - const close = vm.$el.querySelector('.close'); + const close = vm.$el.querySelector('.close-btn'); const name = vm.$el.querySelector(`a[title="${tab.url}"]`); spyOn(vm, 'closeTab'); @@ -44,26 +45,43 @@ describe('RepoTab', () => { tab, }); - expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy(); + expect(vm.$el.querySelector('.close-btn .fa-circle')).toBeTruthy(); }); describe('methods', () => { describe('closeTab', () => { - const vm = jasmine.createSpyObj('vm', ['$emit']); - it('returns undefined and does not $emit if file is changed', () => { - const file = { changed: true }; - const returnVal = repoTab.methods.closeTab.call(vm, file); + const tab = { + url: 'url', + name: 'name', + changed: true, + }; + const vm = createComponent({ + tab, + }); + + spyOn(RepoStore, 'removeFromOpenedFiles'); + + vm.$el.querySelector('.close-btn').click(); - expect(returnVal).toBeUndefined(); - expect(vm.$emit).not.toHaveBeenCalled(); + expect(RepoStore.removeFromOpenedFiles).not.toHaveBeenCalled(); }); it('$emits tabclosed event with file obj', () => { - const file = { changed: false }; - repoTab.methods.closeTab.call(vm, file); + const tab = { + url: 'url', + name: 'name', + changed: false, + }; + const vm = createComponent({ + tab, + }); + + spyOn(RepoStore, 'removeFromOpenedFiles'); + + vm.$el.querySelector('.close-btn').click(); - expect(vm.$emit).toHaveBeenCalledWith('tabclosed', file); + expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(tab); }); }); }); diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js index a02b54efafc..431129bc866 100644 --- a/spec/javascripts/repo/components/repo_tabs_spec.js +++ b/spec/javascripts/repo/components/repo_tabs_spec.js @@ -16,6 +16,10 @@ describe('RepoTabs', () => { return new RepoTabs().$mount(); } + afterEach(() => { + RepoStore.openedFiles = []; + }); + it('renders a list of tabs', () => { RepoStore.openedFiles = openedFiles; @@ -28,18 +32,4 @@ describe('RepoTabs', () => { expect(tabs[1].classList.contains('active')).toBeFalsy(); expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy(); }); - - describe('methods', () => { - describe('tabClosed', () => { - it('calls removeFromOpenedFiles with file obj', () => { - const file = {}; - - spyOn(RepoStore, 'removeFromOpenedFiles'); - - repoTabs.methods.tabClosed(file); - - expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(file); - }); - }); - }); }); diff --git a/spec/javascripts/repo/mock_data.js b/spec/javascripts/repo/mock_data.js new file mode 100644 index 00000000000..836b867205e --- /dev/null +++ b/spec/javascripts/repo/mock_data.js @@ -0,0 +1,13 @@ +import RepoHelper from '~/repo/helpers/repo_helper'; + +// eslint-disable-next-line import/prefer-default-export +export const file = (name = 'name') => RepoHelper.serializeRepoEntity('blob', { + icon: 'icon', + url: 'url', + name, + last_commit: { + id: '123', + message: 'test', + committed_date: '', + }, +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js new file mode 100644 index 00000000000..aa93134f2dd --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import { placeholderImage } from '~/lazy_loader'; +import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +const DEFAULT_PROPS = { + size: 99, + imgSrc: 'myavatarurl.com', + imgAlt: 'mydisplayname', + cssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', +}; + +describe('User Avatar Image Component', function () { + let vm; + let UserAvatarImage; + + beforeEach(() => { + UserAvatarImage = Vue.extend(userAvatarImage); + }); + + describe('Initialization', function () { + beforeEach(function () { + vm = mountComponent(UserAvatarImage, { + ...DEFAULT_PROPS, + }).$mount(); + }); + + it('should return a defined Vue component', function () { + expect(vm).toBeDefined(); + }); + + it('should have <img> as a child element', function () { + expect(vm.$el.tagName).toBe('IMG'); + expect(vm.$el.getAttribute('src')).toBe(DEFAULT_PROPS.imgSrc); + expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc); + expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt); + }); + + it('should properly compute tooltipContainer', function () { + expect(vm.tooltipContainer).toBe('body'); + }); + + it('should properly render tooltipContainer', function () { + expect(vm.$el.getAttribute('data-container')).toBe('body'); + }); + + it('should properly compute avatarSizeClass', function () { + expect(vm.avatarSizeClass).toBe('s99'); + }); + + it('should properly render img css', function () { + const classList = vm.$el.classList; + const containsAvatar = classList.contains('avatar'); + const containsSizeClass = classList.contains('s99'); + const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses); + const lazyClass = classList.contains('lazy'); + + expect(containsAvatar).toBe(true); + expect(containsSizeClass).toBe(true); + expect(containsCustomClass).toBe(true); + expect(lazyClass).toBe(false); + }); + }); + + describe('Initialization when lazy', function () { + beforeEach(function () { + vm = mountComponent(UserAvatarImage, { + ...DEFAULT_PROPS, + lazy: true, + }).$mount(); + }); + + it('should add lazy attributes', function () { + const classList = vm.$el.classList; + const lazyClass = classList.contains('lazy'); + + expect(lazyClass).toBe(true); + expect(vm.$el.getAttribute('src')).toBe(placeholderImage); + expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js index 52e450e9ba5..52e450e9ba5 100644 --- a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js index b8d639ffbec..b8d639ffbec 100644 --- a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_svg_spec.js diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js deleted file mode 100644 index 8daa7610274..00000000000 --- a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import Vue from 'vue'; -import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; - -const UserAvatarImageComponent = Vue.extend(UserAvatarImage); - -describe('User Avatar Image Component', function () { - describe('Initialization', function () { - beforeEach(function () { - this.propsData = { - size: 99, - imgSrc: 'myavatarurl.com', - imgAlt: 'mydisplayname', - cssClasses: 'myextraavatarclass', - tooltipText: 'tooltip text', - tooltipPlacement: 'bottom', - }; - - this.userAvatarImage = new UserAvatarImageComponent({ - propsData: this.propsData, - }).$mount(); - }); - - it('should return a defined Vue component', function () { - expect(this.userAvatarImage).toBeDefined(); - }); - - it('should have <img> as a child element', function () { - expect(this.userAvatarImage.$el.tagName).toBe('IMG'); - }); - - it('should properly compute tooltipContainer', function () { - expect(this.userAvatarImage.tooltipContainer).toBe('body'); - }); - - it('should properly render tooltipContainer', function () { - expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body'); - }); - - it('should properly compute avatarSizeClass', function () { - expect(this.userAvatarImage.avatarSizeClass).toBe('s99'); - }); - - it('should properly render img css', function () { - const classList = this.userAvatarImage.$el.classList; - const containsAvatar = classList.contains('avatar'); - const containsSizeClass = classList.contains('s99'); - const containsCustomClass = classList.contains('myextraavatarclass'); - - expect(containsAvatar).toBe(true); - expect(containsSizeClass).toBe(true); - expect(containsCustomClass).toBe(true); - }); - }); -}); diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index 5f41e28fece..17a620ef603 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -217,6 +217,11 @@ describe Banzai::Filter::SanitizationFilter do output: '<img>' }, + 'protocol-based JS injection: Unicode' => { + input: %Q(<a href="\u0001java\u0003script:alert('XSS')">foo</a>), + output: '<a>foo</a>' + }, + 'protocol-based JS injection: spaces and entities' => { input: '<a href="  javascript:alert(\'XSS\');">foo</a>', output: '<a href="">foo</a>' diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb index d2e7243ee05..4d3fdbd9554 100644 --- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb +++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb @@ -31,8 +31,8 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t end it 'creates correct entries in the merge_request_diff_commits table' do - expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(commits.count) - expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(commits) + expect(updated_merge_request_diff.merge_request_diff_commits.count).to eq(expected_commits.count) + expect(updated_merge_request_diff.commits.map(&:to_hash)).to eq(expected_commits) end it 'creates correct entries in the merge_request_diff_files table' do @@ -199,6 +199,16 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diff has valid commits and diffs' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } + let(:expected_commits) { commits } + let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) } + let(:expected_diffs) { diffs } + + include_examples 'updated MR diff' + end + + context 'when the merge request diff has diffs but no commits' do + let(:commits) { nil } + let(:expected_commits) { [] } let(:diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) } let(:expected_diffs) { diffs } @@ -207,6 +217,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diffs do not have too_large set' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } + let(:expected_commits) { commits } let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) } let(:diffs) do @@ -218,6 +229,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diffs do not have a_mode and b_mode set' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } + let(:expected_commits) { commits } let(:expected_diffs) { diffs_to_hashes(merge_request_diff.merge_request_diff_files) } let(:diffs) do @@ -229,6 +241,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diffs have binary content' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } + let(:expected_commits) { commits } let(:expected_diffs) { diffs } # The start of a PDF created by Illustrator @@ -257,6 +270,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diff has commits, but no diffs' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } + let(:expected_commits) { commits } let(:diffs) { [] } let(:expected_diffs) { diffs } @@ -265,6 +279,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diffs have invalid content' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } + let(:expected_commits) { commits } let(:diffs) { ['--broken-diff'] } let(:expected_diffs) { [] } @@ -274,6 +289,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diffs are Rugged::Patch instances' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) } + let(:expected_commits) { commits } let(:diffs) { first_commit.rugged_diff_from_parent.patches } let(:expected_diffs) { [] } @@ -283,6 +299,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits, :t context 'when the merge request diffs are Rugged::Diff::Delta instances' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) } + let(:expected_commits) { commits } let(:diffs) { first_commit.rugged_diff_from_parent.deltas } let(:expected_diffs) { [] } diff --git a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb index 68eb93eb9f9..c8d532df059 100644 --- a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb +++ b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb @@ -41,6 +41,10 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: expect(key_exists).to be_falsey end + + it 'does not break when there are no keys in redis' do + expect { described_class.reset_all! }.not_to raise_error + end end describe '.for_storage' do diff --git a/spec/lib/gitlab/git/storage/health_spec.rb b/spec/lib/gitlab/git/storage/health_spec.rb index 2d3af387971..4a14a5201d1 100644 --- a/spec/lib/gitlab/git/storage/health_spec.rb +++ b/spec/lib/gitlab/git/storage/health_spec.rb @@ -20,36 +20,6 @@ describe Gitlab::Git::Storage::Health, clean_gitlab_redis_shared_state: true, br end end - describe '.load_for_keys' do - let(:subject) do - results = Gitlab::Git::Storage.redis.with do |redis| - fake_future = double - allow(fake_future).to receive(:value).and_return([host1_key]) - described_class.load_for_keys({ 'broken' => fake_future }, redis) - end - - # Make sure the `Redis#future is loaded - results.inject({}) do |result, (name, info)| - info.each { |i| i[:failure_count] = i[:failure_count].value.to_i } - - result[name] = info - - result - end - end - - it 'loads when there is no info in redis' do - expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 0 }]) - end - - it 'reads the correct values for a storage from redis' do - set_in_redis(host1_key, 5) - set_in_redis(host2_key, 7) - - expect(subject).to eq('broken' => [{ name: host1_key, failure_count: 5 }]) - end - end - describe '.for_all_storages' do it 'loads health status for all configured storages' do healths = described_class.for_all_storages diff --git a/spec/lib/gitlab/saml/auth_hash_spec.rb b/spec/lib/gitlab/saml/auth_hash_spec.rb new file mode 100644 index 00000000000..a555935aea3 --- /dev/null +++ b/spec/lib/gitlab/saml/auth_hash_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Gitlab::Saml::AuthHash do + include LoginHelpers + + let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers) } } + subject(:saml_auth_hash) { described_class.new(omniauth_auth_hash) } + + let(:info_hash) do + { + name: 'John', + email: 'john@mail.com' + } + end + + let(:omniauth_auth_hash) do + OmniAuth::AuthHash.new(uid: 'my-uid', + provider: 'saml', + info: info_hash, + extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) } ) + end + + before do + stub_saml_group_config(%w(Developers Freelancers Designers)) + end + + describe '#groups' do + it 'returns array of groups' do + expect(saml_auth_hash.groups).to eq(%w(Developers Freelancers)) + end + + context 'raw info hash attributes empty' do + let(:raw_info_attr) { {} } + + it 'returns an empty array' do + expect(saml_auth_hash.groups).to be_a(Array) + end + end + end +end diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb index 59923bfb14d..1c23fb5f285 100644 --- a/spec/lib/gitlab/saml/user_spec.rb +++ b/spec/lib/gitlab/saml/user_spec.rb @@ -2,13 +2,15 @@ require 'spec_helper' describe Gitlab::Saml::User do include LdapHelpers + include LoginHelpers let(:saml_user) { described_class.new(auth_hash) } let(:gl_user) { saml_user.gl_user } let(:uid) { 'my-uid' } let(:dn) { 'uid=user1,ou=People,dc=example' } let(:provider) { 'saml' } - let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new({ 'groups' => %w(Developers Freelancers Designers) }) }) } + let(:raw_info_attr) { { 'groups' => %w(Developers Freelancers Designers) } } + let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash, extra: { raw_info: OneLogin::RubySaml::Attributes.new(raw_info_attr) }) } let(:info_hash) do { name: 'John', @@ -18,22 +20,6 @@ describe Gitlab::Saml::User do let(:ldap_user) { Gitlab::LDAP::Person.new(Net::LDAP::Entry.new, 'ldapmain') } describe '#save' do - def stub_omniauth_config(messages) - allow(Gitlab.config.omniauth).to receive_messages(messages) - end - - def stub_ldap_config(messages) - allow(Gitlab::LDAP::Config).to receive_messages(messages) - end - - def stub_basic_saml_config - allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', args: {} } }) - end - - def stub_saml_group_config(groups) - allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } }) - end - before do stub_basic_saml_config end @@ -402,4 +388,16 @@ describe Gitlab::Saml::User do end end end + + describe '#find_user' do + context 'raw info hash attributes empty' do + let(:raw_info_attr) { {} } + + it 'does not mark user as external' do + stub_saml_group_config(%w(Freelancers)) + + expect(saml_user.find_user.external).to be_falsy + end + end + end end diff --git a/spec/migrations/migrate_user_project_view_spec.rb b/spec/migrations/migrate_user_project_view_spec.rb index afaa5d836a7..5e16769d63a 100644 --- a/spec/migrations/migrate_user_project_view_spec.rb +++ b/spec/migrations/migrate_user_project_view_spec.rb @@ -5,12 +5,7 @@ require Rails.root.join('db', 'post_migrate', '20170406142253_migrate_user_proje describe MigrateUserProjectView, :truncate do let(:migration) { described_class.new } - let!(:user) { create(:user) } - - before do - # 0 is the numeric value for the old 'readme' option - user.update_column(:project_view, 0) - end + let!(:user) { create(:user, project_view: 'readme') } describe '#up' do it 'updates project view setting with new value' do diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index cf192691507..6945c90cb9b 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -222,6 +222,16 @@ describe ApplicationSetting do end end + context 'restrict creating duplicates' do + before do + described_class.create_from_defaults + end + + it 'raises an record creation violation if already created' do + expect { described_class.create_from_defaults }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + context 'restricted signup domains' do it 'sets single domain' do setting.domain_whitelist_raw = 'example.com' diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 1bd8e8a5415..90b768f595e 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -4,6 +4,7 @@ describe Namespace do include ProjectForksHelper let!(:namespace) { create(:namespace) } + let(:gitlab_shell) { Gitlab::Shell.new } describe 'associations' do it { is_expected.to have_many :projects } @@ -167,25 +168,18 @@ describe Namespace do end end - describe '#move_dir' do + describe '#move_dir', :request_store do let(:namespace) { create(:namespace) } let!(:project) { create(:project_empty_repo, namespace: namespace) } - before do - allow(namespace).to receive(:path_changed?).and_return(true) - end - it "raises error when directory exists" do expect { namespace.move_dir }.to raise_error("namespace directory cannot be moved") end it "moves dir if path changed" do - new_path = namespace.full_path + "_new" + namespace.update_attributes(path: namespace.full_path + '_new') - allow(namespace).to receive(:full_path_was).and_return(namespace.full_path) - allow(namespace).to receive(:full_path).and_return(new_path) - expect(namespace).to receive(:remove_exports!) - expect(namespace.move_dir).to be_truthy + expect(gitlab_shell.exists?(project.repository_storage_path, "#{namespace.path}/#{project.path}.git")).to be_truthy end context "when any project has container images" do diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 78fb2df884a..f10d9383ae2 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -180,37 +180,47 @@ describe ProjectWiki do end describe "#create_page" do - after do - destroy_page(subject.pages.first.page) - end + shared_examples 'creating a wiki page' do + after do + destroy_page(subject.pages.first.page) + end - it "creates a new wiki page" do - expect(subject.create_page("test page", "this is content")).not_to eq(false) - expect(subject.pages.count).to eq(1) - end + it "creates a new wiki page" do + expect(subject.create_page("test page", "this is content")).not_to eq(false) + expect(subject.pages.count).to eq(1) + end - it "returns false when a duplicate page exists" do - subject.create_page("test page", "content") - expect(subject.create_page("test page", "content")).to eq(false) - end + it "returns false when a duplicate page exists" do + subject.create_page("test page", "content") + expect(subject.create_page("test page", "content")).to eq(false) + end - it "stores an error message when a duplicate page exists" do - 2.times { subject.create_page("test page", "content") } - expect(subject.error_message).to match(/Duplicate page:/) - end + it "stores an error message when a duplicate page exists" do + 2.times { subject.create_page("test page", "content") } + expect(subject.error_message).to match(/Duplicate page:/) + end - it "sets the correct commit message" do - subject.create_page("test page", "some content", :markdown, "commit message") - expect(subject.pages.first.page.version.message).to eq("commit message") - end + it "sets the correct commit message" do + subject.create_page("test page", "some content", :markdown, "commit message") + expect(subject.pages.first.page.version.message).to eq("commit message") + end - it 'updates project activity' do - subject.create_page('Test Page', 'This is content') + it 'updates project activity' do + subject.create_page('Test Page', 'This is content') - project.reload + project.reload - expect(project.last_activity_at).to be_within(1.minute).of(Time.now) - expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + expect(project.last_activity_at).to be_within(1.minute).of(Time.now) + expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) + end + end + + context 'when Gitaly wiki_write_page is enabled' do + it_behaves_like 'creating a wiki page' + end + + context 'when Gitaly wiki_write_page is disabled', :skip_gitaly_mock do + it_behaves_like 'creating a wiki page' end end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 16b12446ed4..e433597f58b 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -110,6 +110,15 @@ describe API::Branches do end end + context 'when the branch refname is invalid' do + let(:branch_name) { 'branch*' } + let(:message) { 'The branch refname is invalid' } + + it_behaves_like '400 response' do + let(:request) { get api(route, current_user) } + end + end + context 'when repository is disabled' do include_context 'disabled repository' @@ -234,6 +243,15 @@ describe API::Branches do end end + context 'when the branch refname is invalid' do + let(:branch_name) { 'branch*' } + let(:message) { 'The branch refname is invalid' } + + it_behaves_like '400 response' do + let(:request) { put api(route, current_user) } + end + end + context 'when repository is disabled' do include_context 'disabled repository' @@ -359,6 +377,15 @@ describe API::Branches do end end + context 'when the branch refname is invalid' do + let(:branch_name) { 'branch*' } + let(:message) { 'The branch refname is invalid' } + + it_behaves_like '400 response' do + let(:request) { put api(route, current_user) } + end + end + context 'when repository is disabled' do include_context 'disabled repository' @@ -520,6 +547,15 @@ describe API::Branches do expect(response).to have_gitlab_http_status(404) end + context 'when the branch refname is invalid' do + let(:branch_name) { 'branch*' } + let(:message) { 'The branch refname is invalid' } + + it_behaves_like '400 response' do + let(:request) { delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) } + end + end + it_behaves_like '412 response' do let(:request) { api("/projects/#{project.id}/repository/branches/#{branch_name}", user) } end diff --git a/spec/serializers/container_tag_entity_spec.rb b/spec/serializers/container_tag_entity_spec.rb index 6dcc5204516..4beb50c70f8 100644 --- a/spec/serializers/container_tag_entity_spec.rb +++ b/spec/serializers/container_tag_entity_spec.rb @@ -22,7 +22,7 @@ describe ContainerTagEntity do end it 'exposes required informations' do - expect(subject).to include(:name, :location, :revision, :total_size, :created_at) + expect(subject).to include(:name, :location, :revision, :short_revision, :total_size, :created_at) end context 'when user can manage repositories' do diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index c90bad46295..0bec2054f50 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::DestroyService do + include ProjectForksHelper + let!(:user) { create(:user) } let!(:project) { create(:project, :repository, namespace: user.namespace) } let!(:path) { project.repository.path_to_repo } @@ -212,6 +214,21 @@ describe Projects::DestroyService do end end + context 'for a forked project with LFS objects' do + let(:forked_project) { fork_project(project, user) } + + before do + project.lfs_objects << create(:lfs_object) + forked_project.forked_project_link.destroy + forked_project.reload + end + + it 'destroys the fork' do + expect { destroy_project(forked_project, user) } + .not_to raise_error + end + end + context 'as the root of a fork network' do let!(:fork_network) { create(:fork_network, root_project: project) } diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index cd473c1f388..0a6ab455abe 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -502,20 +502,6 @@ describe SystemNoteService do end end - describe '.cross_reference?' do - it 'is truthy when text begins with expected text' do - expect(described_class.cross_reference?('mentioned in something')).to be_truthy - end - - it 'is truthy when text begins with legacy capitalized expected text' do - expect(described_class.cross_reference?('mentioned in something')).to be_truthy - end - - it 'is falsey when text does not begin with expected text' do - expect(described_class.cross_reference?('this is a note')).to be_falsey - end - end - describe '.cross_reference_disallowed?' do context 'when mentioner is not a MergeRequest' do it 'is falsey' do diff --git a/spec/support/ldap_helpers.rb b/spec/support/ldap_helpers.rb index 079f244475c..28d39a32f02 100644 --- a/spec/support/ldap_helpers.rb +++ b/spec/support/ldap_helpers.rb @@ -15,10 +15,7 @@ module LdapHelpers # admin_group: 'my-admin-group' # ) def stub_ldap_config(messages) - messages.each do |config, value| - allow_any_instance_of(::Gitlab::LDAP::Config) - .to receive(config.to_sym).and_return(value) - end + allow_any_instance_of(::Gitlab::LDAP::Config).to receive_messages(messages) end # Stub an LDAP person search and provide the return entry. Specify `nil` for diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb index 3e117530151..4aed40bf22d 100644 --- a/spec/support/login_helpers.rb +++ b/spec/support/login_helpers.rb @@ -120,4 +120,16 @@ module LoginHelpers allow_any_instance_of(Object).to receive(:user_saml_omniauth_authorize_path).and_return('/users/auth/saml') allow_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml') end + + def stub_omniauth_config(messages) + allow(Gitlab.config.omniauth).to receive_messages(messages) + end + + def stub_basic_saml_config + allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', args: {} } }) + end + + def stub_saml_group_config(groups) + allow(Gitlab::Saml::Config).to receive_messages({ options: { name: 'saml', groups_attribute: 'groups', external_groups: groups, args: {} } }) + end end diff --git a/spec/support/redis_without_keys.rb b/spec/support/redis_without_keys.rb new file mode 100644 index 00000000000..6220167dee6 --- /dev/null +++ b/spec/support/redis_without_keys.rb @@ -0,0 +1,8 @@ +class Redis + ForbiddenCommand = Class.new(StandardError) + + def keys(*args) + raise ForbiddenCommand.new("Don't use `Redis#keys` as it iterates over all "\ + "keys in redis. Use `Redis#scan_each` instead.") + end +end diff --git a/spec/support/shared_examples/requests/api/status_shared_examples.rb b/spec/support/shared_examples/requests/api/status_shared_examples.rb index 7d7f66adeab..0ed917e448a 100644 --- a/spec/support/shared_examples/requests/api/status_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/status_shared_examples.rb @@ -3,6 +3,8 @@ # Requires an API request: # let(:request) { get api("/projects/#{project.id}/repository/branches", user) } shared_examples_for '400 response' do + let(:message) { nil } + before do # Fires the request request @@ -10,6 +12,10 @@ shared_examples_for '400 response' do it 'returns 400' do expect(response).to have_gitlab_http_status(400) + + if message.present? + expect(json_response['message']).to eq(message) + end end end @@ -26,6 +32,7 @@ end shared_examples_for '404 response' do let(:message) { nil } + before do # Fires the request request diff --git a/yarn.lock b/yarn.lock index b830278eab0..91ffbe5d4b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4677,9 +4677,9 @@ pify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" -pikaday@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.5.1.tgz#0a48549bc1a14ea1d08c44074d761bc2f2bfcfd3" +pikaday@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/pikaday/-/pikaday-1.6.1.tgz#b91bcb9b8539cedd8d6d08e4e7465e12095671b0" optionalDependencies: moment "2.x" |