diff options
80 files changed, 1666 insertions, 1056 deletions
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index 2b7c5ae0184..17b2ccd9bf9 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.4.2 +0.4.3 diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index baa20d0c34a..aa0871eb771 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -3,7 +3,7 @@ /* global ActiveTabMemoizer */ /* global ShortcutsNavigation */ /* global Build */ -/* global Issuable */ +/* global IssuableIndex */ /* global ShortcutsIssuable */ /* global ZenMode */ /* global Milestone */ @@ -127,10 +127,9 @@ import ShortcutsBlob from './shortcuts_blob'; const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); filteredSearchManager.setup(); } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', - }); + const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_'; + IssuableIndex.init(pagePrefix); + shortcut_handler = new ShortcutsNavigation(); new UsersSelect(); break; diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index a2448520a5f..41d5453f1b2 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -70,7 +70,7 @@ export default { </span> </button> - <ul class="dropdown-menu dropdown-menu-align-right"> + <ul class="dropdown-menu"> <li v-for="action in actions"> <button type="button" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 012ff1f975b..03eb51ba1b2 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -421,14 +421,19 @@ export default { }; </script> <template> - <tr :class="{ 'js-child-row': model.isChildren }"> - <td> + <div + :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }"> + <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header"> + Environment + </div> <a v-if="!model.isFolder" - class="environment-name" - :class="{ 'prepend-left-default': model.isChildren }" + class="environment-name flex-truncate-parent table-mobile-content" :href="environmentPath"> - {{model.name}} + <span class="flex-truncate-child">{{model.name}}</span> </a> <span v-else @@ -461,9 +466,9 @@ export default { {{model.size}} </span> </span> - </td> + </div> - <td class="deployment-column"> + <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell"> <span v-if="shouldRenderDeploymentID"> {{deploymentInternalId}} </span> @@ -478,21 +483,26 @@ export default { :tooltip-text="deploymentUser.username" /> </span> - </td> + </div> - <td class="environments-build-cell"> + <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell"> <a v-if="shouldRenderBuildName" class="build-link" :href="buildPath"> {{buildName}} </a> - </td> + </div> - <td> + <div class="table-section section-25" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header"> + Commit + </div> <div v-if="!model.isFolder && hasLastDeploymentKey" - class="js-commit-component"> + class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" :commit-ref="commitRef" @@ -501,25 +511,30 @@ export default { :title="commitTitle" :author="commitAuthor"/> </div> - <p + <div v-if="!model.isFolder && !hasLastDeploymentKey" class="commit-title"> No deployments yet - </p> - </td> + </div> + </div> - <td> + <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header"> + Updated + </div> <span v-if="!model.isFolder && canShowDate" - class="environment-created-date-timeago"> + class="environment-created-date-timeago table-mobile-content"> {{createdDate}} </span> - </td> + </div> - <td class="environments-actions"> + <div class="table-section section-30 environments-actions table-button-footer" role="gridcell"> <div v-if="!model.isFolder" - class="btn-group pull-right" + class="btn-group environment-action-buttons" role="group"> <actions-component @@ -553,6 +568,6 @@ export default { :retry-url="retryUrl" /> </div> - </td> - </tr> + </div> + </div> </template> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 79c019b3491..07cf92281a0 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -19,7 +19,7 @@ export default { </script> <template> <a - class="btn monitoring-url has-tooltip" + class="btn monitoring-url has-tooltip hidden-xs hidden-sm" data-container="body" rel="noopener noreferrer nofollow" :href="monitoringUrl" diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 2ba985bfe3e..49dba38edfb 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -43,7 +43,7 @@ export default { <template> <button type="button" - class="btn" + class="btn hidden-xs hidden-sm" @click="onClick" :disabled="isLoading"> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index a904453ffa9..091c543860b 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -47,7 +47,7 @@ export default { <template> <button type="button" - class="btn stop-env-link has-tooltip" + class="btn stop-env-link has-tooltip hidden-xs hidden-sm" data-container="body" @click="onClick" :disabled="isLoading" diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index c8c1f17d4d8..1ca65a79951 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -29,7 +29,7 @@ export default { </script> <template> <a - class="btn terminal-button has-tooltip" + class="btn terminal-button has-tooltip hidden-xs hidden-sm" data-container="body" :title="title" :aria-label="title" diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 5148a2ae79b..f9262ab85c5 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -45,68 +45,59 @@ export default { }; </script> <template> - <table class="table ci-table"> - <thead> - <tr> - <th class="environments-name"> - Environment - </th> - <th class="environments-deploy"> - Last deployment - </th> - <th class="environments-build"> - Job - </th> - <th class="environments-commit"> - Commit - </th> - <th class="environments-date"> - Updated - </th> - <th class="environments-actions"></th> - </tr> - </thead> - <tbody> - <template - v-for="model in environments" - v-bind:model="model"> - <tr - is="environment-item" - :model="model" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <div class="ci-table" role="grid"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-10 environments-name" role="rowheader"> + Environment + </div> + <div class="table-section section-10 environments-deploy" role="rowheader"> + Deployment + </div> + <div class="table-section section-15 environments-build" role="rowheader"> + Job + </div> + <div class="table-section section-25 environments-commit" role="rowheader"> + Commit + </div> + <div class="table-section section-10 environments-date" role="rowheader"> + Updated + </div> + </div> + <template + v-for="model in environments" + v-bind:model="model"> + <div + is="environment-item" + :model="model" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> - <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> - <tr v-if="isLoadingFolderContent"> - <td colspan="6"> - <loading-icon size="2" /> - </td> - </tr> + <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> + <div v-if="isLoadingFolderContent"> + <loading-icon size="2" /> + </div> - <template v-else> - <tr - is="environment-item" - v-for="children in model.children" - :model="children" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <template v-else> + <div + is="environment-item" + v-for="children in model.children" + :model="children" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> - <tr> - <td - colspan="6" - class="text-center"> - <a - :href="folderUrl(model)" - class="btn btn-default"> - Show all - </a> - </td> - </tr> - </template> + <div> + <div class="text-center prepend-top-10"> + <a + :href="folderUrl(model)" + class="btn btn-default"> + Show all + </a> + </div> + </div> </template> </template> - </tbody> - </table> + </template> + </div> </template> diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js new file mode 100644 index 00000000000..e46c0e90255 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -0,0 +1,159 @@ +/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ +/* global IssuableIndex */ +/* global Flash */ + +export default { + init({ container, form, issues, prefixId } = {}) { + this.prefixId = prefixId || 'issue_'; + this.form = form || this.getElement('.bulk-update'); + this.$labelDropdown = this.form.find('.js-label-select'); + this.issues = issues || this.getElement('.issues-list .issue'); + this.willUpdateLabels = false; + this.bindEvents(); + }, + + bindEvents() { + return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); + }, + + onFormSubmit(e) { + e.preventDefault(); + return this.submit(); + }, + + submit() { + const _this = this; + const xhr = $.ajax({ + url: this.form.attr('action'), + method: this.form.attr('method'), + dataType: 'JSON', + data: this.getFormDataAsObject() + }); + xhr.done(() => window.location.reload()); + xhr.fail(() => this.onFormSubmitFailure()); + }, + + onFormSubmitFailure() { + this.form.find('[type="submit"]').enable(); + return new Flash("Issue update failed"); + }, + + getSelectedIssues() { + return this.issues.has('.selected_issue:checked'); + }, + + getLabelsFromSelection() { + const labels = []; + this.getSelectedIssues().map(function() { + const labelsData = $(this).data('labels'); + if (labelsData) { + return labelsData.map(function(labelId) { + if (labels.indexOf(labelId) === -1) { + return labels.push(labelId); + } + }); + } + }); + return labels; + }, + + /** + * Will return only labels that were marked previously and the user has unmarked + * @return {Array} Label IDs + */ + + getUnmarkedIndeterminedLabels() { + const result = []; + const labelsToKeep = this.$labelDropdown.data('indeterminate'); + + this.getLabelsFromSelection().forEach((id) => { + if (labelsToKeep.indexOf(id) === -1) { + result.push(id); + } + }); + + return result; + }, + + /** + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + */ + + getFormDataAsObject() { + const formData = { + update: { + state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests + assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], + milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), + issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), + subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + add_label_ids: [], + remove_label_ids: [] + } + }; + if (this.willUpdateLabels) { + formData.update.add_label_ids = this.$labelDropdown.data('marked'); + formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); + } + return formData; + }, + + setOriginalDropdownData() { + const $labelSelect = $('.bulk-update .js-label-select'); + $labelSelect.data('common', this.getOriginalCommonIds()); + $labelSelect.data('marked', this.getOriginalMarkedIds()); + $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); + }, + + // From issuable's initial bulk selection + getOriginalCommonIds() { + const labelIds = []; + + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalMarkedIds() { + const labelIds = []; + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalIndeterminateIds() { + const uniqueIds = []; + const labelIds = []; + let issuableLabels = []; + + // Collect unique label IDs for all checked issues + this.getElement('.selected_issue:checked').each((i, el) => { + issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); + issuableLabels.forEach((labelId) => { + // Store unique IDs + if (uniqueIds.indexOf(labelId) === -1) { + uniqueIds.push(labelId); + } + }); + // Store array of IDs per issuable + labelIds.push(issuableLabels); + }); + // Add uniqueIds to add it as argument for _.intersection + labelIds.unshift(uniqueIds); + // Return IDs that are present but not in all selected issueables + return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + }, + + getElement(selector) { + this.scopeEl = this.scopeEl || $('.content'); + return this.scopeEl.find(selector); + }, +}; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js new file mode 100644 index 00000000000..84bd2e092e6 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -0,0 +1,165 @@ +/* eslint-disable class-methods-use-this, no-new */ +/* global LabelsSelect */ +/* global MilestoneSelect */ +/* global IssueStatusSelect */ +/* global SubscriptionSelect */ + +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; + +const HIDDEN_CLASS = 'hidden'; +const DISABLED_CONTENT_CLASS = 'disabled-content'; +const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar'; +const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar'; + +export default class IssuableBulkUpdateSidebar { + constructor() { + this.initDomElements(); + this.bindEvents(); + this.initDropdowns(); + this.setupBulkUpdateActions(); + } + + initDomElements() { + this.$page = $('.page-with-sidebar'); + this.$sidebar = $('.right-sidebar'); + this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide'); + this.$bulkEditSubmitBtn = $('.update-selected-issues'); + this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle'); + this.$otherFilters = $('.issues-other-filters'); + this.$checkAllContainer = $('.check-all-holder'); + this.$issueChecks = $('.issue-check'); + this.$issuesList = $('.selected_issue'); + this.$issuableIdsInput = $('#update_issuable_ids'); + } + + bindEvents() { + this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true)); + this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false)); + this.$checkAllContainer.on('click', e => this.selectAll(e)); + this.$issuesList.on('change', () => this.updateFormState()); + this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); + this.$checkAllContainer.on('click', () => this.updateFormState()); + } + + initDropdowns() { + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); + new SubscriptionSelect(); + } + + getNavHeight() { + const navbarHeight = $('.navbar-gitlab').outerHeight(); + const layoutNavHeight = $('.layout-nav').outerHeight(); + const subNavScroll = $('.sub-nav-scroll').outerHeight(); + return navbarHeight + layoutNavHeight + subNavScroll; + } + + initSidebar() { + if (!this.navHeight) { + this.navHeight = this.getNavHeight(); + } + + if (!this.sidebarInitialized) { + $(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this)); + $(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this)); + this.sidebarInitialized = true; + } + } + + setupBulkUpdateActions() { + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + updateFormState() { + const noCheckedIssues = !$('.selected_issue:checked').length; + + this.toggleSubmitButtonDisabled(noCheckedIssues); + this.updateSelectedIssuableIds(); + + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + prepForSubmit() { + // if submit button is disabled, submission is blocked. This ensures we disable after + // form submission is carried out + setTimeout(() => this.$bulkEditSubmitBtn.disable()); + this.updateSelectedIssuableIds(); + } + + toggleBulkEdit(e, enable) { + e.preventDefault(); + + this.toggleSidebarDisplay(enable); + this.toggleBulkEditButtonDisabled(enable); + this.toggleOtherFiltersDisabled(enable); + this.toggleCheckboxDisplay(enable); + + if (enable) { + this.initSidebar(); + } + } + + updateSelectedIssuableIds() { + this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds()); + } + + selectAll() { + const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked'); + + this.$issuesList.prop('checked', checkAllButtonState); + } + + toggleSidebarDisplay(show) { + this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + } + + toggleBulkEditButtonDisabled(disable) { + if (disable) { + this.$bulkUpdateEnableBtn.disable(); + } else { + this.$bulkUpdateEnableBtn.enable(); + } + } + + toggleCheckboxDisplay(show) { + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); + this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); + } + + toggleOtherFiltersDisabled(disable) { + this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable); + } + + toggleSubmitButtonDisabled(disable) { + if (disable) { + this.$bulkEditSubmitBtn.disable(); + } else { + this.$bulkEditSubmitBtn.enable(); + } + } + // loosely based on method of the same name in right_sidebar.js + setSidebarHeight() { + const currentScrollDepth = window.pageYOffset || 0; + const diff = this.navHeight - currentScrollDepth; + + if (diff > 0) { + this.$sidebar.outerHeight(window.innerHeight - diff); + } else { + this.$sidebar.outerHeight('100%'); + } + } + + static getCheckedIssueIds() { + const $checkedIssues = $('.selected_issue:checked'); + + if ($checkedIssues.length > 0) { + return $.map($checkedIssues, value => $(value).data('id')); + } + + return []; + } +} diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable_index.js index 3bfce32768a..5c96646def8 100644 --- a/app/assets/javascripts/issuable.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,30 +1,33 @@ /* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */ -/* global Issuable */ +/* global IssuableIndex */ + +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; ((global) => { var issuable_created; issuable_created = false; - global.Issuable = { - init: function() { - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - Issuable.initResetFilters(); - Issuable.resetIncomingEmailToken(); - return Issuable.initLabelFilterRemove(); + global.IssuableIndex = { + init: function(pagePrefix) { + IssuableIndex.initTemplates(); + IssuableIndex.initSearch(); + IssuableIndex.initBulkUpdate(pagePrefix); + IssuableIndex.initResetFilters(); + IssuableIndex.resetIncomingEmailToken(); + IssuableIndex.initLabelFilterRemove(); }, initTemplates: function() { - return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); + return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); }, initSearch: function() { const $searchInput = $('#issuable_search'); - Issuable.initSearchState($searchInput); + IssuableIndex.initSearchState($searchInput); // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing - const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false); + const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false); $searchInput.off('keyup').on('keyup', debouncedExecSearch); @@ -37,16 +40,16 @@ initSearchState: function($searchInput) { const currentSearchVal = $searchInput.val(); - Issuable.searchState = { + IssuableIndex.searchState = { elem: $searchInput, current: currentSearchVal }; - Issuable.maybeFocusOnSearch(); + IssuableIndex.maybeFocusOnSearch(); }, accessSearchPristine: function(set) { // store reference to previous value to prevent search on non-mutating keyup - const state = Issuable.searchState; + const state = IssuableIndex.searchState; const currentSearchVal = state.elem.val(); if (set) { @@ -56,10 +59,10 @@ } }, maybeFocusOnSearch: function() { - const currentSearchVal = Issuable.searchState.current; + const currentSearchVal = IssuableIndex.searchState.current; if (currentSearchVal && currentSearchVal !== '') { const queryLength = currentSearchVal.length; - const $searchInput = Issuable.searchState.elem; + const $searchInput = IssuableIndex.searchState.elem; /* The following ensures that the cursor is initially placed at * the end of search input when focus is applied. It accounts @@ -80,7 +83,7 @@ const $searchValue = $search.val(); const $filtersForm = $('.js-filter-form'); const $input = $(`input[name='${$searchName}']`, $filtersForm); - const isPristine = Issuable.accessSearchPristine(); + const isPristine = IssuableIndex.accessSearchPristine(); if (isPristine) { return; @@ -92,7 +95,7 @@ $input.val($searchValue); } - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); }, initLabelFilterRemove: function() { return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { @@ -103,7 +106,7 @@ return this.value === $button.data('label'); }).remove(); // Submit the form to get new data - Issuable.filterResults($('.filter-form')); + IssuableIndex.filterResults($('.filter-form')); }); }, filterResults: (function(_this) { @@ -132,38 +135,18 @@ gl.utils.visitUrl(baseIssuesUrl); }); }, - initChecks: function() { - this.issuableBulkActions = $('.bulk-update').data('bulkActions'); - $('.check_all_issues').off('click').on('click', function() { - $('.selected_issue').prop('checked', this.checked); - return Issuable.checkChanged(); - }); - return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); - }, - checkChanged: function() { - const $checkedIssues = $('.selected_issue:checked'); - const $updateIssuesIds = $('#update_issuable_ids'); - const $issuesOtherFilters = $('.issues-other-filters'); - const $issuesBulkUpdate = $('.issues_bulk_update'); - - this.issuableBulkActions.willUpdateLabels = false; - this.issuableBulkActions.setOriginalDropdownData(); - - if ($checkedIssues.length > 0) { - const ids = $.map($checkedIssues, function(value) { - return $(value).data('id'); + initBulkUpdate: function(pagePrefix) { + const userCanBulkUpdate = $('.issues-bulk-update').length > 0; + const alreadyInitialized = !!this.bulkUpdateSidebar; + + if (userCanBulkUpdate && !alreadyInitialized) { + IssuableBulkUpdateActions.init({ + prefixId: pagePrefix, }); - $updateIssuesIds.val(ids); - $issuesOtherFilters.hide(); - $issuesBulkUpdate.show(); - } else { - $updateIssuesIds.val([]); - $issuesBulkUpdate.hide(); - $issuesOtherFilters.show(); + + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); } - return true; }, - resetIncomingEmailToken: function() { $('.incoming-email-token-reset').on('click', function(e) { e.preventDefault(); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js deleted file mode 100644 index fee3429e2b8..00000000000 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ -/* global Issuable */ -/* global Flash */ - -((global) => { - class IssuableBulkActions { - constructor({ container, form, issues, prefixId } = {}) { - this.prefixId = prefixId || 'issue_'; - this.form = form || this.getElement('.bulk-update'); - this.$labelDropdown = this.form.find('.js-label-select'); - this.issues = issues || this.getElement('.issues-list .issue'); - this.form.data('bulkActions', this); - this.willUpdateLabels = false; - this.bindEvents(); - // Fixes bulk-assign not working when navigating through pages - Issuable.initChecks(); - } - - bindEvents() { - return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); - } - - onFormSubmit(e) { - e.preventDefault(); - return this.submit(); - } - - submit() { - const _this = this; - const xhr = $.ajax({ - url: this.form.attr('action'), - method: this.form.attr('method'), - dataType: 'JSON', - data: this.getFormDataAsObject() - }); - xhr.done(() => window.location.reload()); - xhr.fail(() => new Flash("Issue update failed")); - return xhr.always(this.onFormSubmitAlways.bind(this)); - } - - onFormSubmitAlways() { - return this.form.find('[type="submit"]').enable(); - } - - getSelectedIssues() { - return this.issues.has('.selected_issue:checked'); - } - - getLabelsFromSelection() { - const labels = []; - this.getSelectedIssues().map(function() { - const labelsData = $(this).data('labels'); - if (labelsData) { - return labelsData.map(function(labelId) { - if (labels.indexOf(labelId) === -1) { - return labels.push(labelId); - } - }); - } - }); - return labels; - } - - /** - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - */ - - getUnmarkedIndeterminedLabels() { - const result = []; - const labelsToKeep = this.$labelDropdown.data('indeterminate'); - - this.getLabelsFromSelection().forEach((id) => { - if (labelsToKeep.indexOf(id) === -1) { - result.push(id); - } - }); - - return result; - } - - /** - * Simple form serialization, it will return just what we need - * Returns key/value pairs from form data - */ - - getFormDataAsObject() { - const formData = { - update: { - state_event: this.form.find('input[name="update[state_event]"]').val(), - // For Merge Requests - assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), - // For Issues - assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], - milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), - issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), - subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), - add_label_ids: [], - remove_label_ids: [] - } - }; - if (this.willUpdateLabels) { - formData.update.add_label_ids = this.$labelDropdown.data('marked'); - formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); - } - return formData; - } - - setOriginalDropdownData() { - const $labelSelect = $('.bulk-update .js-label-select'); - $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', this.getOriginalMarkedIds()); - $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); - } - - // From issuable's initial bulk selection - getOriginalCommonIds() { - const labelIds = []; - - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalMarkedIds() { - const labelIds = []; - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalIndeterminateIds() { - const uniqueIds = []; - const labelIds = []; - let issuableLabels = []; - - // Collect unique label IDs for all checked issues - this.getElement('.selected_issue:checked').each((i, el) => { - issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); - issuableLabels.forEach((labelId) => { - // Store unique IDs - if (uniqueIds.indexOf(labelId) === -1) { - uniqueIds.push(labelId); - } - }); - // Store array of IDs per issuable - labelIds.push(issuableLabels); - }); - // Add uniqueIds to add it as argument for _.intersection - labelIds.unshift(uniqueIds); - // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); - } - - getElement(selector) { - this.scopeEl = this.scopeEl || $('.content'); - return this.scopeEl.find(selector); - } - } - - global.IssuableBulkActions = IssuableBulkActions; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index ac5ce84e31b..8d7d3d73571 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -2,6 +2,8 @@ /* global Issuable */ /* global ListLabel */ +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; + (function() { this.LabelsSelect = (function() { function LabelsSelect(els) { @@ -430,20 +432,15 @@ if ($('.selected_issue:checked').length) { return; } - return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label'); + return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label'); }; LabelsSelect.prototype.enableBulkLabelDropdown = function() { - var issuableBulkActions; - if ($('.selected_issue:checked').length) { - issuableBulkActions = $('.bulk-update').data('bulkActions'); - return issuableBulkActions.willUpdateLabels = true; - } + IssuableBulkUpdateActions.willUpdateLabels = true; }; LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) { var i, markedIds, unmarkedIds, indeterminateIds; - var issuableBulkActions = $('.bulk-update').data('bulkActions'); markedIds = $dropdown.data('marked') || []; unmarkedIds = $dropdown.data('unmarked') || []; @@ -469,13 +466,13 @@ } // If an indeterminate item is being unmarked - if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) { + if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { unmarkedIds.push(value); } // If a marked item is being unmarked // (a marked item could also be a label that is present in all selection) - if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) { + if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { unmarkedIds.push(value); } } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1ac82b7e291..fe367d0c42a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -104,12 +104,11 @@ import './group_label_subscription'; import './groups_select'; import './header'; import './importer_status'; -import './issuable'; +import './issuable_index'; import './issuable_context'; import './issuable_form'; import './issue'; import './issue_status_select'; -import './issues_bulk_assignment'; import './label_manager'; import './labels'; import './labels_select'; diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index 8e22057e2e9..ff5ae28e062 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -135,8 +135,8 @@ export default { {{shortSha}} </a> - <p class="commit-title"> - <span v-if="title"> + <div class="commit-title flex-truncate-parent"> + <span v-if="title" class="flex-truncate-child"> <user-avatar-link v-if="hasAuthor" class="avatar-image-container" @@ -153,7 +153,7 @@ export default { <span v-else> Cant find HEAD commit for this branch </span> - </p> + </div> </div> `, }; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index b8ba77f4513..9dc9f9a9068 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -49,3 +49,4 @@ @import "framework/icons.scss"; @import "framework/snippets.scss"; @import "framework/memory_graph.scss"; +@import "framework/responsive-tables.scss"; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 57387b913dc..00c981f64c5 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -445,3 +445,9 @@ table { word-wrap: break-word; } } + +.disabled-content { + pointer-events: none; + opacity: .5; +} + diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 585f4871f5f..acf5de0e3b5 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -22,12 +22,6 @@ } @media (min-width: $screen-sm-min) { - .issues_bulk_update { - .dropdown-menu-toggle { - width: 132px; - } - } - .filter-item:not(:last-child) { margin-right: 6px; } @@ -376,12 +370,6 @@ padding: 0; } -@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .issue-bulk-update-dropdown-toggle { - width: 100px; - } -} - @media (max-width: $screen-xs-max) { .issues-details-filters { padding: 0 0 10px; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 0140dcf19c3..600a1f53b58 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -29,10 +29,6 @@ display: none; } - .issues-holder .issue-check { - display: none; - } - .rss-btn { display: none; } diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss new file mode 100644 index 00000000000..94528c7a222 --- /dev/null +++ b/app/assets/stylesheets/framework/responsive-tables.scss @@ -0,0 +1,86 @@ +@mixin flex-max-width($max) { + flex: 0 0 #{$max + '%'}; + max-width: #{$max + '%'}; +} + +.gl-responsive-table-row { + margin-top: 10px; + border: 1px solid $border-color; + + @media (min-width: $screen-md-min) { + padding: 15px 0; + margin: 0; + display: flex; + align-items: center; + border: none; + border-bottom: 1px solid $white-normal; + } + + .table-section { + white-space: nowrap; + + $section-widths: 10 15 25 30; + @each $width in $section-widths { + &.section-#{$width} { + flex: 0 0 #{$width + '%'}; + + @media (min-width: $screen-md-min) { + max-width: #{$width + '%'}; + } + } + } + + &:not(.table-button-footer) { + @media (max-width: $screen-sm-max) { + display: flex; + align-self: stretch; + padding: 10px; + align-items: center; + height: 62px; + + &:not(:first-of-type) { + border-top: 1px solid $white-normal; + } + } + } + } +} + +.table-row-header { + font-size: 13px; + + @media (max-width: $screen-sm-max) { + display: none; + } +} + +.table-mobile-header { + color: $gl-text-color-secondary; + @include flex-max-width(40); + + @media (min-width: $screen-md-min) { + display: none; + } +} + +.table-mobile-content { + @media (max-width: $screen-sm-max) { + @include flex-max-width(60); + text-align: right; + } +} + +.flex-truncate-parent { + display: flex; +} + +.flex-truncate-child { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (min-width: $screen-md-min) { + flex: 0 0 90%; + } +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 5b62d7fa3a7..d4421e3af74 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -33,7 +33,7 @@ padding-right: 0; @media (min-width: $screen-sm-min) { - &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { + &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; } @@ -56,7 +56,7 @@ z-index: 300; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { + &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; } } @@ -88,3 +88,35 @@ min-height: 100%; } } + +@mixin maintain-sidebar-dimensions { + display: block; + width: $gutter-width; + padding: 10px 20px; +} + +.issues-bulk-update.right-sidebar { + @include maintain-sidebar-dimensions; + transition: right $sidebar-transition-duration; + right: -$gutter-width; + + &.right-sidebar-expanded { + @include maintain-sidebar-dimensions; + right: 0; + } + + &.right-sidebar-collapsed { + @include maintain-sidebar-dimensions; + right: -$gutter-width; + + .block { + padding: 16px 0; + width: 250px; + border-bottom: 1px solid $border-color; + } + } + + .issuable-sidebar { + padding: 0 3px; + } +} diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index f269d53093d..bc151d2358f 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -11,34 +11,7 @@ } .environments-container { - .table-holder { - width: 100%; - - @media (max-width: $screen-sm-max) { - overflow: auto; - } - } - - .table.ci-table { - .environments-actions { - min-width: 300px; - } - - .environments-commit, - .environments-actions { - width: 20%; - } - - .environments-date { - width: 10%; - } - - .environments-name, - .environments-deploy, - .environments-build { - width: 15%; - } - + .ci-table { .deployment-column { > span { word-break: break-all; @@ -150,6 +123,49 @@ } } +.gl-responsive-table-row { + .environments-actions { + @media (min-width: $screen-md-min) { + text-align: right; + } + + @media (max-width: $screen-sm-max) { + background-color: $gray-normal; + align-self: stretch; + border-top: 1px solid $border-color; + + .environment-action-buttons { + padding: 10px; + display: flex; + + .btn { + border-radius: 3px; + } + + > .btn-group, + .external-url { + flex: 1; + flex-basis: 28px; + } + + .dropdown-new { + width: 100%; + } + } + } + } +} + +.folder-row { + padding: 15px 0; + border-bottom: 1px solid $white-normal; + + @media (max-width: $screen-sm-max) { + border-top: 1px solid $white-normal; + margin-top: 10px; + } +} + .prometheus-graph { text { fill: $gl-text-color; diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb new file mode 100644 index 00000000000..b0450ddc1fd --- /dev/null +++ b/app/finders/events_finder.rb @@ -0,0 +1,62 @@ +class EventsFinder + attr_reader :source, :params, :current_user + + # Used to filter Events + # + # Arguments: + # source - which user or project to looks for events on + # current_user - only return events for projects visible to this user + # params: + # action: string + # target_type: string + # before: datetime + # after: datetime + # + def initialize(params = {}) + @source = params.delete(:source) + @current_user = params.delete(:current_user) + @params = params + end + + def execute + events = source.events + + events = by_current_user_access(events) + events = by_action(events) + events = by_target_type(events) + events = by_created_at_before(events) + events = by_created_at_after(events) + + events + end + + private + + def by_current_user_access(events) + events.merge(ProjectsFinder.new(current_user: current_user).execute).references(:project) + end + + def by_action(events) + return events unless Event::ACTIONS[params[:action]] + + events.where(action: Event::ACTIONS[params[:action]]) + end + + def by_target_type(events) + return events unless Event::TARGET_TYPES[params[:target_type]] + + events.where(target_type: Event::TARGET_TYPES[params[:target_type]]) + end + + def by_created_at_before(events) + return events unless params[:before] + + events.where('events.created_at < ?', params[:before].beginning_of_day) + end + + def by_created_at_after(events) + return events unless params[:after] + + events.where('events.created_at > ?', params[:after].end_of_day) + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 46e89388bc1..d6d39473774 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -14,6 +14,30 @@ class Event < ActiveRecord::Base DESTROYED = 10 EXPIRED = 11 # User left project due to expiry + ACTIONS = HashWithIndifferentAccess.new( + created: CREATED, + updated: UPDATED, + closed: CLOSED, + reopened: REOPENED, + pushed: PUSHED, + commented: COMMENTED, + merged: MERGED, + joined: JOINED, + left: LEFT, + destroyed: DESTROYED, + expired: EXPIRED + ).freeze + + TARGET_TYPES = HashWithIndifferentAccess.new( + issue: Issue, + milestone: Milestone, + merge_request: MergeRequest, + note: Note, + project: Project, + snippet: Snippet, + user: User + ).freeze + RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true @@ -55,6 +79,14 @@ class Event < ActiveRecord::Base def limit_recent(limit = 20, offset = nil) recent.limit(limit).offset(offset) end + + def actions + ACTIONS.keys + end + + def target_types + TARGET_TYPES.keys + end end def visible_to_user?(user = nil) diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index f2f2fc1e32a..5d798247863 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -1,7 +1,7 @@ class PagesDomain < ActiveRecord::Base belongs_to :project - validates :domain, hostname: true + validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, uniqueness: { case_sensitive: false } validates :certificate, certificate: true, allow_nil: true, allow_blank: true validates :key, certificate_key: true, allow_nil: true, allow_blank: true @@ -98,7 +98,7 @@ class PagesDomain < ActiveRecord::Base def validate_pages_domain return unless domain - if domain.downcase.ends_with?(".#{Settings.pages.host}".downcase) + if domain.downcase.ends_with?(Settings.pages.host.downcase) self.errors.add(:domain, "*.#{Settings.pages.host} is restricted") end end diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index d90d4a27cd6..e2bddee0d13 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -2,7 +2,7 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) .project-action-button.dropdown.inline> - %button.btn{ 'data-toggle' => 'dropdown' } + %button.btn.has-tooltip{ title: 'Download', 'data-toggle' => 'dropdown', 'aria-label' => 'Download' } = icon('download') = icon("caret-down") %span.sr-only diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 67de8699b2e..76a2e720b68 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -1,6 +1,6 @@ - if current_user .project-action-button.dropdown.inline - %a.btn.dropdown-toggle{ href: '#', "data-toggle" => "dropdown" } + %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: 'Create new...', 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => 'Create new...' } = icon('plus') = icon("caret-down") %ul.dropdown-menu.dropdown-menu-align-right.project-home-dropdown diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index 851fe44a86d..0935ca7fa44 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -5,7 +5,7 @@ = custom_icon('icon_fork') %span Fork - else - = link_to new_namespace_project_fork_path(@project.namespace, @project), title: 'Fork project', class: 'btn' do + = link_to new_namespace_project_fork_path(@project.namespace, @project), class: 'btn' do = custom_icon('icon_fork') %span Fork .count-with-arrow diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index c184e0e0022..9e4e6934ca9 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,7 +1,7 @@ %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } .issue-box - - if @bulk_edit - .issue-check + - if @can_bulk_update + .issue-check.hidden = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" .issue-info-container .issue-title.title diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 60900e9d660..7183794ce72 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- @bulk_edit = can?(current_user, :admin_issue, @project) +- @can_bulk_update = can?(current_user, :admin_issue, @project) - page_title "Issues" - new_issue_email = @project.new_issue_address(current_user) @@ -20,6 +20,8 @@ .nav-controls = link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do = icon('rss') + - if @can_bulk_update + = button_tag "Edit Issues", class: "btn btn-default js-bulk-update-toggle" = link_to new_namespace_project_issue_path(@project.namespace, @project, issue: { assignee_id: issues_finder.assignee.try(:id), @@ -30,6 +32,9 @@ New issue = render 'shared/issuable/search_bar', type: :issues + - if @can_bulk_update + = render 'shared/issuable/bulk_update_sidebar', type: :issues + .issues-holder = render 'issues' - if new_issue_email diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 94b9577e9eb..c13110deb16 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,6 +1,6 @@ %li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } } - - if @bulk_edit - .issue-check + - if @can_bulk_update + .issue-check.hidden = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue" .issue-info-container diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 2cb3045f83e..6d75a9f34a3 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- @bulk_edit = can?(current_user, :admin_merge_request, @project) +- @can_bulk_update = can?(current_user, :admin_merge_request, @project) - page_title "Merge Requests" - unless @project.default_issues_tracker? @@ -18,6 +18,8 @@ .top-area = render 'shared/issuable/nav', type: :merge_requests .nav-controls + - if @can_bulk_update + = button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle" - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) - if merge_project = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do @@ -25,6 +27,9 @@ = render 'shared/issuable/search_bar', type: :merge_requests + - if @can_bulk_update + = render 'shared/issuable/bulk_update_sidebar', type: :merge_requests + .merge-requests-holder = render 'merge_requests' - else diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index e180cb8bad1..7b8be58554a 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -95,7 +95,7 @@ .form-group.project-visibility-level-holder = f.label :visibility_level, class: 'label-light' do Visibility Level - = link_to icon('question-circle'), help_page_path("public_access/public_access") + = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' } = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false = f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4 diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml new file mode 100644 index 00000000000..a8a6d84128d --- /dev/null +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -0,0 +1,53 @@ +- type = local_assigns.fetch(:type) + +%aside.issues-bulk-update.js-right-sidebar.right-sidebar.affix-top{ data: { "offset-top" => "50", "spy" => "affix" }, "aria-live" => "polite" } + .issuable-sidebar + = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do + .block + .filter-item.inline.update-issues-btn.pull-left + = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true + = button_tag "Cancel", class: "btn btn-default js-bulk-update-menu-hide pull-right" + .block + .title + Status + .filter-item + = dropdown_tag("Select status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do + %ul + %li + %a{ href: "#", data: { id: "reopen" } } Open + %li + %a{ href: "#", data: { id: "close" } } Closed + .block + .title + Assignee + .filter-item + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) + .block + .title + Milestone + .filter-item + = dropdown_tag("Select milestone", options: { title: "Assign milestone", toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) + .block + .title + Labels + .filter-item.labels-filter + = render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: "Apply a label", show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: "Select labels", no_default_styles: true + .block + .title + Subscriptions + .filter-item + = dropdown_tag("Select subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do + %ul + %li + %a{ href: "#", data: { id: "subscribe" } } Subscribe + %li + %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe + + = hidden_field_tag "update[issuable_ids]", [] + = hidden_field_tag :state_event, params[:state_event] + diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 6cd03f028a9..2cabbc8c560 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -6,10 +6,6 @@ = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - if params[:search].present? = hidden_field_tag :search, params[:search] - - if @bulk_edit - .check-all-holder - = check_box_tag "check_all_issues", nil, false, - class: "check_all_issues left" .issues-other-filters .filter-item.inline - if params[:author_id].present? @@ -36,35 +32,6 @@ .pull-right = render 'shared/sort_dropdown' - - if @bulk_edit - .issues_bulk_update.hide - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do - .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do - %ul - %li - %a{ href: "#", data: { id: "reopen" } } Open - %li - %a{ href: "#", data: {id: "close" } } Closed - .filter-item.inline - = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) - .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) - .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } - .filter-item.inline - = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do - %ul - %li - %a{ href: "#", data: { id: "subscribe" } } Subscribe - %li - %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe - - = hidden_field_tag 'update[issuable_ids]', [] - = hidden_field_tag :state_event, params[:state_event] - .filter-item.inline - = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" - has_labels = @labels && @labels.any? .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) } - if has_labels diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 1cf662e29c4..34911fd2712 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -11,6 +11,8 @@ - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") - dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"} - dropdown_data.merge!(data_options) +- label_name = local_assigns.fetch(:label_name, "Labels") +- no_default_styles = local_assigns.fetch(:no_default_styles, false) - classes << 'js-extra-options' if extra_options - classes << 'js-filter-submit' if filter_submit @@ -20,8 +22,9 @@ .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect{ class: classes.join(' '), type: "button", data: dropdown_data } - %span.dropdown-toggle-text{ class: ("is-default" if selected.nil? || selected.empty?) } - = multi_label_name(selected, "Labels") + - apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles + %span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) } + = multi_label_name(selected, label_name) = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable = render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index be9f9ee29c4..d3d290692a2 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -6,10 +6,9 @@ = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - if params[:search].present? = hidden_field_tag :search, params[:search] - - if @bulk_edit - .check-all-holder - = check_box_tag "check_all_issues", nil, false, - class: "check_all_issues left" + - if @can_bulk_update + .check-all-holder.hidden + = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" .issues-other-filters.filtered-search-wrapper .filtered-search-box - if type != :boards_modal && type != :boards @@ -110,55 +109,11 @@ - elsif type != :boards_modal = render 'shared/sort_dropdown' - - if @bulk_edit - .issues_bulk_update.hide - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do - .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do - %ul - %li - %a{ href: "#", data: { id: "reopen" } } Open - %li - %a{ href: "#", data: { id: "close" } } Closed - .filter-item.inline - - if type == :issues - - field_name = "update[assignee_ids][]" - - else - - field_name = "update[assignee_id]" - - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) - .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true, default_label: "Milestone" } }) - .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" } - .filter-item.inline - = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do - %ul - %li - %a{ href: "#", data: { id: "subscribe" } } Subscribe - %li - %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe - - = hidden_field_tag 'update[issuable_ids]', [] - = hidden_field_tag :state_event, params[:state_event] - .filter-item.inline.update-issues-btn - = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" - - unless type === :boards_modal :javascript - new LabelsSelect(); - new MilestoneSelect(); - new IssueStatusSelect(); - new SubscriptionSelect(); - $(document).off('page:restore').on('page:restore', function (event) { if (gl.FilteredSearchManager) { const filteredSearchManager = new gl.FilteredSearchManager(); filteredSearchManager.setup(); } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: 'issue_', - }); }); diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 1d072c16b32..e99d8d0973f 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -6,14 +6,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.notifications-btn#notifications-button{ type: "button", data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) = icon("caret-down") diff --git a/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml new file mode 100644 index 00000000000..9c17c3b949c --- /dev/null +++ b/changelogs/unreleased/14707-allow-activity-feed-to-be-accessible-through-api.yml @@ -0,0 +1,4 @@ +--- +title: Introduce an Events API +merge_request: 11755 +author: diff --git a/changelogs/unreleased/32642_last_commit_id_in_file_api.yml b/changelogs/unreleased/32642_last_commit_id_in_file_api.yml new file mode 100644 index 00000000000..80435352e10 --- /dev/null +++ b/changelogs/unreleased/32642_last_commit_id_in_file_api.yml @@ -0,0 +1,4 @@ +--- +title: 'Introduce optimistic locking support via optional parameter last_commit_sha on File Update API' +merge_request: 11694 +author: electroma diff --git a/changelogs/unreleased/allow_numeric_pages_domain.yml b/changelogs/unreleased/allow_numeric_pages_domain.yml new file mode 100644 index 00000000000..10d9f26f88d --- /dev/null +++ b/changelogs/unreleased/allow_numeric_pages_domain.yml @@ -0,0 +1,4 @@ +--- +title: Allow numeric pages domain +merge_request: 11550 +author: diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md index f707039827b..afafb6bf1f5 100644 --- a/doc/administration/container_registry.md +++ b/doc/administration/container_registry.md @@ -1,10 +1,7 @@ # GitLab Container Registry administration -> [Introduced][ce-4040] in GitLab 8.8. - ---- - > **Notes:** +- [Introduced][ce-4040] in GitLab 8.8. - Container Registry manifest `v1` support was added in GitLab 8.9 to support Docker versions earlier than 1.10. - This document is about the admin guide. To learn how to use GitLab Container @@ -514,8 +511,8 @@ configurable in future releases. ## Configure Container Registry notifications -You can configure the Container Registry to send webhook notifications in -response to events happening within the registry. +You can configure the Container Registry to send webhook notifications in +response to events happening within the registry. Read more about the Container Registry notifications config options in the [Docker Registry notifications documentation][notifications-config]. @@ -568,12 +565,25 @@ notifications: backoff: 1000 ``` -## Changelog +## Using self-signed certificates with Container Registry + +If you're using a self-signed certificate with your Container Registry, you +might encounter issues during the CI jobs like the following: + +``` +Error response from daemon: Get registry.example.com/v1/users/: x509: certificate signed by unknown authority +``` -**GitLab 8.8 ([source docs][8-8-docs])** +The Docker daemon running the command expects a cert signed by a recognized CA, +thus the error above. -- GitLab Container Registry feature was introduced. +While GitLab doesn't support using self-signed certificates with Container +Registry out of the box, it is possible to make it work if you follow +[Docker's documentation][docker-insecure]. You may find some additional +information in [issue 18239][ce-18239]. +[ce-18239]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18239 +[docker-insecure]: https://docs.docker.com/registry/insecure/#using-self-signed-certificates [reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure [restart gitlab]: restart_gitlab.md#installations-from-source [wildcard certificate]: https://en.wikipedia.org/wiki/Wildcard_certificate @@ -589,4 +599,4 @@ notifications: [existing-domain]: #configure-container-registry-under-an-existing-gitlab-domain [new-domain]: #configure-container-registry-under-its-own-domain [notifications-config]: https://docs.docker.com/registry/notifications/ -[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications
\ No newline at end of file +[registry-notifications-config]: https://docs.docker.com/registry/configuration/#notifications diff --git a/doc/api/README.md b/doc/api/README.md index 45579ccac4e..e1d4009dedc 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -15,6 +15,8 @@ following locations: - [Commits](commits.md) - [Deployments](deployments.md) - [Deploy Keys](deploy_keys.md) +- [Environments](environments.md) +- [Events](events.md) - [Gitignores templates](templates/gitignores.md) - [GitLab CI Config templates](templates/gitlab_ci_ymls.md) - [Groups](groups.md) diff --git a/doc/api/enviroments.md b/doc/api/environments.md index 5ca766bf87d..5ca766bf87d 100644 --- a/doc/api/enviroments.md +++ b/doc/api/environments.md diff --git a/doc/api/events.md b/doc/api/events.md new file mode 100644 index 00000000000..e7829c9f479 --- /dev/null +++ b/doc/api/events.md @@ -0,0 +1,347 @@ +# Events + +## Filter parameters + +### Action Types + +Available action types for the `action` parameter are: + +- `created` +- `updated` +- `closed` +- `reopened` +- `pushed` +- `commented` +- `merged` +- `joined` +- `left` +- `destroyed` +- `expired` + +Note that these options are downcased. + +### Target Types + +Available target types for the `target_type` parameter are: + +- `issue` +- `milestone` +- `merge_request` +- `note` +- `project` +- `snippet` +- `user` + +Note that these options are downcased. + +### Date formatting + +Dates for the `before` and `after` parameters should be supplied in the following format: + +``` +YYYY-MM-DD +``` + +## List currently authenticated user's events + +>**Note:** This endpoint was introduced in GitLab 9.3. + +Get a list of events for the authenticated user. + +``` +GET /events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +Example request: + +``` +curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +``` + +Example response: + +```json +[ + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":160, + "target_type":"Issue", + "author_id":25, + "data":null, + "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.", + "created_at":"2017-02-09T10:43:19.667Z", + "author":{ + "name":"User 3", + "username":"user3", + "id":25, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/user3" + }, + "author_username":"user3" + }, + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":159, + "target_type":"Issue", + "author_id":21, + "data":null, + "target_title":"Nostrum enim non et sed optio illo deleniti non.", + "created_at":"2017-02-09T10:43:19.426Z", + "author":{ + "name":"Test User", + "username":"ted", + "id":21, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/ted" + }, + "author_username":"ted" + } +] +``` + +### Get user contribution events + +>**Note:** Documentation was formerly located in the [Users API pages][users-api]. + +Get the contribution events for the specified user, sorted from newest to oldest. + +``` +GET /users/:id/events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID or Username of the user | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events +``` + +Example response: + +```json +[ + { + "title": null, + "project_id": 15, + "action_name": "closed", + "target_id": 830, + "target_type": "Issue", + "author_id": 1, + "data": null, + "target_title": "Public project search field", + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "author_username": "root" + }, + { + "title": null, + "project_id": 15, + "action_name": "opened", + "target_id": null, + "target_type": null, + "author_id": 1, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "author_username": "john", + "data": { + "before": "50d4420237a9de7be1304607147aec22e4a14af7", + "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "ref": "refs/heads/master", + "user_id": 1, + "user_name": "Dmitriy Zaporozhets", + "repository": { + "name": "gitlabhq", + "url": "git@dev.gitlab.org:gitlab/gitlabhq.git", + "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.", + "homepage": "https://dev.gitlab.org/gitlab/gitlabhq" + }, + "commits": [ + { + "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "message": "Add simple search to projects in public area", + "timestamp": "2013-05-13T18:18:08+00:00", + "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "author": { + "name": "Dmitriy Zaporozhets", + "email": "dmitriy.zaporozhets@gmail.com" + } + } + ], + "total_commits_count": 1 + }, + "target_title": null + }, + { + "title": null, + "project_id": 15, + "action_name": "closed", + "target_id": 840, + "target_type": "Issue", + "author_id": 1, + "data": null, + "target_title": "Finish & merge Code search PR", + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "author_username": "root" + }, + { + "title": null, + "project_id": 15, + "action_name": "commented on", + "target_id": 1312, + "target_type": "Note", + "author_id": 1, + "data": null, + "target_title": null, + "created_at": "2015-12-04T10:33:58.089Z", + "note": { + "id": 1312, + "body": "What an awesome day!", + "attachment": null, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2015-12-04T10:33:56.698Z", + "system": false, + "noteable_id": 377, + "noteable_type": "Issue" + }, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "author_username": "root" + } +] +``` + +## List a Project's visible events + +>**Note:** This endpoint has been around longer than the others. Documentation was formerly located in the [Projects API pages][projects-api]. + +Get a list of visible events for a particular project. + +``` +GET /:project_id/events +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `action` | string | no | Include only events of a particular [action type][action-types] | +| `target_type` | string | no | Include only events of a particular [target type][target-types] | +| `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | +| `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | + +Example request: + +``` +curl --header "PRIVATE-TOKEN 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:project_id/events&target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +``` + +Example response: + +```json +[ + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":160, + "target_type":"Issue", + "author_id":25, + "data":null, + "target_title":"Qui natus eos odio tempore et quaerat consequuntur ducimus cupiditate quis.", + "created_at":"2017-02-09T10:43:19.667Z", + "author":{ + "name":"User 3", + "username":"user3", + "id":25, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/97d6d9441ff85fdc730e02a6068d267b?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/user3" + }, + "author_username":"user3" + }, + { + "title":null, + "project_id":1, + "action_name":"opened", + "target_id":159, + "target_type":"Issue", + "author_id":21, + "data":null, + "target_title":"Nostrum enim non et sed optio illo deleniti non.", + "created_at":"2017-02-09T10:43:19.426Z", + "author":{ + "name":"Test User", + "username":"ted", + "id":21, + "state":"active", + "avatar_url":"http://www.gravatar.com/avatar/80fb888c9a48b9a3f87477214acaa63f?s=80\u0026d=identicon", + "web_url":"https://gitlab.example.com/ted" + }, + "author_username":"ted" + } +] +``` + +[target-types]: #target-types "Target Type parameter" +[action-types]: #action-types "Action Type parameter" +[date-formatting]: #date-formatting "Date Formatting guidance" +[projects-api]: projects.md "Projects API pages" +[users-api]: users.md "Users API pages" diff --git a/doc/api/projects.md b/doc/api/projects.md index 70cad8a6025..0debdcfae89 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -310,143 +310,7 @@ GET /projects/:id/users ### Get project events -Get the events for the specified project sorted from newest to oldest. This -endpoint can be accessed without authentication if the project is publicly -accessible. - -``` -GET /projects/:id/events -``` - -Parameters: - -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | - -```json -[ - { - "title": null, - "project_id": 15, - "action_name": "closed", - "target_id": 830, - "target_type": "Issue", - "author_id": 1, - "data": null, - "target_title": "Public project search field", - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "root" - }, - { - "title": null, - "project_id": 15, - "action_name": "opened", - "target_id": null, - "target_type": null, - "author_id": 1, - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "john", - "data": { - "before": "50d4420237a9de7be1304607147aec22e4a14af7", - "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", - "ref": "refs/heads/master", - "user_id": 1, - "user_name": "Dmitriy Zaporozhets", - "repository": { - "name": "gitlabhq", - "url": "git@dev.gitlab.org:gitlab/gitlabhq.git", - "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.", - "homepage": "https://dev.gitlab.org/gitlab/gitlabhq" - }, - "commits": [ - { - "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", - "message": "Add simple search to projects in public area", - "timestamp": "2013-05-13T18:18:08+00:00", - "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", - "author": { - "name": "Dmitriy Zaporozhets", - "email": "dmitriy.zaporozhets@gmail.com" - } - } - ], - "total_commits_count": 1 - }, - "target_title": null - }, - { - "title": null, - "project_id": 15, - "action_name": "closed", - "target_id": 840, - "target_type": "Issue", - "author_id": 1, - "data": null, - "target_title": "Finish & merge Code search PR", - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "root" - }, - { - "title": null, - "project_id": 15, - "action_name": "commented on", - "target_id": 1312, - "target_type": "Note", - "author_id": 1, - "data": null, - "target_title": null, - "created_at": "2015-12-04T10:33:58.089Z", - "note": { - "id": 1312, - "body": "What an awesome day!", - "attachment": null, - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "created_at": "2015-12-04T10:33:56.698Z", - "system": false, - "noteable_id": 377, - "noteable_type": "Issue" - }, - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "root" - } -] -``` +Please refer to the [Events API documentation](events.md#list-a-projects-visible-events) ### Create project diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 0b5782a8cc4..18ceb8f779e 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -111,6 +111,7 @@ Parameters: - `author_name` (optional) - Specify the commit author's name - `content` (required) - New file content - `commit_message` (required) - Commit message +- `last_commit_id` (optional) - Last known file commit id If the commit fails for any reason we return a 400 error with a non-specific error message. Possible causes for a failed commit include: diff --git a/doc/api/users.md b/doc/api/users.md index 7e118dcf4a9..f4167ba2605 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -701,147 +701,8 @@ Will return `201 OK` on success, `404 User Not Found` is user cannot be found or ### Get user contribution events -Get the contribution events for the specified user, sorted from newest to oldest. +Please refer to the [Events API documentation](events.md#get-user-contribution-events) -``` -GET /users/:id/events -``` - -Parameters: - -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of the user | - -```bash -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/:id/events -``` - -Example response: - -```json -[ - { - "title": null, - "project_id": 15, - "action_name": "closed", - "target_id": 830, - "target_type": "Issue", - "author_id": 1, - "data": null, - "target_title": "Public project search field", - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "root" - }, - { - "title": null, - "project_id": 15, - "action_name": "opened", - "target_id": null, - "target_type": null, - "author_id": 1, - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "john", - "data": { - "before": "50d4420237a9de7be1304607147aec22e4a14af7", - "after": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", - "ref": "refs/heads/master", - "user_id": 1, - "user_name": "Dmitriy Zaporozhets", - "repository": { - "name": "gitlabhq", - "url": "git@dev.gitlab.org:gitlab/gitlabhq.git", - "description": "GitLab: self hosted Git management software. \r\nDistributed under the MIT License.", - "homepage": "https://dev.gitlab.org/gitlab/gitlabhq" - }, - "commits": [ - { - "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", - "message": "Add simple search to projects in public area", - "timestamp": "2013-05-13T18:18:08+00:00", - "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", - "author": { - "name": "Dmitriy Zaporozhets", - "email": "dmitriy.zaporozhets@gmail.com" - } - } - ], - "total_commits_count": 1 - }, - "target_title": null - }, - { - "title": null, - "project_id": 15, - "action_name": "closed", - "target_id": 840, - "target_type": "Issue", - "author_id": 1, - "data": null, - "target_title": "Finish & merge Code search PR", - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "root" - }, - { - "title": null, - "project_id": 15, - "action_name": "commented on", - "target_id": 1312, - "target_type": "Note", - "author_id": 1, - "data": null, - "target_title": null, - "created_at": "2015-12-04T10:33:58.089Z", - "note": { - "id": 1312, - "body": "What an awesome day!", - "attachment": null, - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "created_at": "2015-12-04T10:33:56.698Z", - "system": false, - "noteable_id": 377, - "noteable_type": "Issue" - }, - "author": { - "name": "Dmitriy Zaporozhets", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", - "web_url": "http://localhost:3000/root" - }, - "author_username": "root" - } -] -``` ## Get all impersonation tokens of a user diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 56ff245f9f9..76ba78ea7be 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -120,7 +120,7 @@ The YAML-defined variables are also set to all created tune them. Variables can be defined at a global level, but also at a job level. To turn off -global defined variables in your job, define an empty array: +global defined variables in your job, define an empty hash: ```yaml job_name: @@ -345,20 +345,45 @@ All variables are set as environment variables in the build environment, and they are accessible with normal methods that are used to access such variables. In most cases `bash` or `sh` is used to execute the job script. -To access the variables (predefined and user-defined) in a `bash`/`sh` environment, -prefix the variable name with the dollar sign (`$`): +To access environment variables, use the syntax for your Runner's [shell][shellexecutors]. -``` +| Shell | Usage | +|----------------------|-----------------| +| bash/sh | `$variable` | +| windows batch | `%variable%` | +| PowerShell | `$env:variable` | + +To access environment variables in bash, prefix the variable name with (`$`): + +```yaml job_name: script: - echo $CI_JOB_ID ``` +To access environment variables in **Windows Batch**, surround the variable +with (`%`): + +```yaml +job_name: + script: + - echo %CI_JOB_ID% +``` + +To access environment variables in a **Windows PowerShell** environment, prefix +the variable name with (`$env:`): + +```yaml +job_name: + script: + - echo $env:CI_JOB_ID +``` + You can also list all environment variables with the `export` command, but be aware that this will also expose the values of all the secret variables you set, in the job log: -``` +```yaml job_name: script: - export @@ -405,3 +430,4 @@ export CI_REGISTRY_PASSWORD="longalfanumstring" [triggers]: ../triggers/README.md#pass-job-variables-to-a-trigger [protected branches]: ../../user/project/protected_branches.md [protected tags]: ../../user/project/protected_tags.md +[shellexecutors]: https://docs.gitlab.com/runner/executors/ diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 2c9aa437932..fc813694ff2 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -297,6 +297,15 @@ cache: untracked: true ``` +If you use **Windows PowerShell** to run your shell scripts you need to replace +`$` with `$env:`: + +```yaml +cache: + key: "$env:CI_JOB_STAGE/$env:CI_COMMIT_REF_NAME" + untracked: true +``` + ## Jobs `.gitlab-ci.yml` allows you to specify an unlimited number of jobs. Each job @@ -434,7 +443,7 @@ but allows you to define job-specific variables. When the `variables` keyword is used on a job level, it overrides the global YAML job variables and predefined ones. To turn off global defined variables -in your job, define an empty array: +in your job, define an empty hash: ```yaml job_name: @@ -909,6 +918,16 @@ job: untracked: true ``` +If you use **Windows PowerShell** to run your shell scripts you need to replace +`$` with `$env:`: + +```yaml +job: + artifacts: + name: "$env:CI_JOB_STAGE_$env:CI_COMMIT_REF_NAME" + untracked: true +``` + #### artifacts:when > Introduced in GitLab 8.9 and GitLab Runner v1.3.0. diff --git a/doc/development/architecture.md b/doc/development/architecture.md index b36fd52603b..acd5e3c2093 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -12,7 +12,7 @@ Both EE and CE require some add-on components called gitlab-shell and Gitaly. Th You can imagine GitLab as a physical office. -**The repositories** are the goods GitLab handling. +**The repositories** are the goods GitLab handles. They can be stored in a warehouse. This can be either a hard disk, or something more complex, such as a NFS filesystem; @@ -54,7 +54,7 @@ To serve repositories over SSH there's an add-on application called gitlab-shell ### Components -![GitLab Diagram Overview](gitlab_architecture_diagram.png) +<img src="https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/pub?w=987&h=797"> _[edit diagram (for GitLab team members only)](https://docs.google.com/drawings/d/1fBzAyklyveF-i-2q-OHUIqDkYfjjxC4mq5shwKSZHLs/edit)_ @@ -66,7 +66,9 @@ When serving repositories over HTTP/HTTPS GitLab utilizes the GitLab API to reso The add-on component gitlab-shell serves repositories over SSH. It manages the SSH keys within `/home/git/.ssh/authorized_keys` which should not be manually edited. gitlab-shell accesses the bare repositories through Gitaly to serve git objects and communicates with redis to submit jobs to Sidekiq for GitLab to process. gitlab-shell queries the GitLab API to determine authorization and access. -Gitaly executes git operations from gitlab-shell and Workhorse, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files) +Gitaly executes git operations from gitlab-shell and the GitLab web app, and provides an API to the GitLab web app to get attributes from git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files). + +You may also be interested in the [production architecture of GitLab.com](https://about.gitlab.com/handbook/infrastructure/production-architecture/). ### Installation Folder Summary diff --git a/doc/user/project/img/project_settings_list.png b/doc/user/project/img/project_settings_list.png Binary files differdeleted file mode 100644 index 0bb761b45c9..00000000000 --- a/doc/user/project/img/project_settings_list.png +++ /dev/null diff --git a/doc/user/project/integrations/img/accessing_integrations.png b/doc/user/project/integrations/img/accessing_integrations.png Binary files differdeleted file mode 100644 index 3b941f64998..00000000000 --- a/doc/user/project/integrations/img/accessing_integrations.png +++ /dev/null diff --git a/doc/user/project/integrations/index.md b/doc/user/project/integrations/index.md index 99093ebaed5..e384ed57de9 100644 --- a/doc/user/project/integrations/index.md +++ b/doc/user/project/integrations/index.md @@ -1,10 +1,8 @@ # Project integrations -You can find the available integrations under the **Integrations** page by -navigating to the cog icon in the upper right corner of your project. You need -to have at least [master permission][permissions] on the project. - -![Accessing the integrations](img/accessing_integrations.png) +You can find the available integrations under your project's +**Settings ➔ Integrations** page. You need to have at least +[master permission][permissions] on the project. ## Project services diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index 31baea507d7..51989ccaaea 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -6,18 +6,13 @@ functionality to GitLab. ## Accessing the project services -You can find the available services under the **Integrations** page in your -project's settings. +You can find the available services under your project's +**Settings ➔ Integrations** page. -1. Navigate to the cog icon in the upper right corner of your project. You need - to have at least [master permission][permissions] on the project. +There are more than 20 services to integrate with. Click on the one that you +want to configure. - ![Accessing the services](img/accessing_integrations.png) - -1. There are more than 20 services to integrate with. Click on the one that you - want to configure. - - ![Project services list](img/project_services.png) + ![Project services list](img/project_services.png) Below, you will find a list of the currently supported ones accompanied with comprehensive documentation. diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index d0bb1cd11a8..0517ed3ec18 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -14,11 +14,8 @@ to the webhook URL. Webhooks can be used to update an external issue tracker, trigger CI jobs, update a backup mirror, or even deploy to your production server. -Navigate to the webhooks page by going to the **Integrations** page from your -project's settings which can be found under the wheel icon in the upper right -corner. - -![Accessing the integrations](img/accessing_integrations.png) +Navigate to the webhooks page by going to your project's +**Settings ➔ Integrations**. ## Webhook endpoint tips diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md index 1b42c43cf8f..1d2eba4f74b 100644 --- a/doc/user/project/pipelines/settings.md +++ b/doc/user/project/pipelines/settings.md @@ -1,12 +1,7 @@ # Pipelines settings -To reach the pipelines settings: - -1. Navigate to your project and click the cog icon in the upper right corner. - - ![Project settings menu](../img/project_settings_list.png) - -1. Select **Pipelines** from the menu. +To reach the pipelines settings navigate to your project's +**Settings ➔ CI/CD Pipelines**. The following settings can be configured per project. diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md index f7a686d2ccf..7650020b37e 100644 --- a/doc/user/project/protected_branches.md +++ b/doc/user/project/protected_branches.md @@ -27,11 +27,8 @@ See the [Changelog](#changelog) section for changes over time. To protect a branch, you need to have at least Master permission level. Note that the `master` branch is protected by default. -1. Navigate to the main page of the project. -1. In the upper right corner, click the settings wheel and select **Protected branches**. - - ![Project settings list](img/project_settings_list.png) - +1. Navigate to your project's **Settings ➔ Repository** +1. Scroll to find the **Protected branches** section. 1. From the **Branch** dropdown menu, select the branch you want to protect and click **Protect**. In the screenshot below, we chose the `develop` branch. diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb index 7591e7d5612..14932491daa 100644 --- a/features/steps/project/fork.rb +++ b/features/steps/project/fork.rb @@ -5,7 +5,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps step 'I click link "Fork"' do expect(page).to have_content "Shop" - click_link "Fork project" + click_link "Fork" end step 'I am a member of project "Shop"' do diff --git a/lib/api/api.rb b/lib/api/api.rb index 7ae2f3cad40..88f91c07194 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -94,6 +94,7 @@ module API mount ::API::DeployKeys mount ::API::Deployments mount ::API::Environments + mount ::API::Events mount ::API::Features mount ::API::Files mount ::API::Groups diff --git a/lib/api/events.rb b/lib/api/events.rb new file mode 100644 index 00000000000..dabdf579119 --- /dev/null +++ b/lib/api/events.rb @@ -0,0 +1,86 @@ +module API + class Events < Grape::API + include PaginationParams + + helpers do + params :event_filter_params do + optional :action, type: String, values: Event.actions, desc: 'Event action to filter on' + optional :target_type, type: String, values: Event.target_types, desc: 'Event target type to filter on' + optional :before, type: Date, desc: 'Include only events created before this date' + optional :after, type: Date, desc: 'Include only events created after this date' + end + + params :sort_params do + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Return events sorted in ascending and descending order' + end + + def present_events(events) + events = events.reorder(created_at: params[:sort]) + + present paginate(events), with: Entities::Event + end + end + + resource :events do + desc "List currently authenticated user's events" do + detail 'This feature was introduced in GitLab 9.3.' + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + get do + authenticate! + + events = EventsFinder.new(params.merge(source: current_user, current_user: current_user)).execute.preload(:author, :target) + + present_events(events) + end + end + + params do + requires :id, type: String, desc: 'The ID or Username of the user' + end + resource :users do + desc 'Get the contribution events of a specified user' do + detail 'This feature was introduced in GitLab 8.13.' + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + get ':id/events' do + user = find_user(params[:id]) + not_found!('User') unless user + + events = EventsFinder.new(params.merge(source: user, current_user: current_user)).execute.preload(:author, :target) + + present_events(events) + end + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: { id: %r{[^/]+} } do + desc "List a Project's visible events" do + success Entities::Event + end + params do + use :pagination + use :event_filter_params + use :sort_params + end + get ":id/events" do + events = EventsFinder.new(params.merge(source: user_project, current_user: current_user)).execute.preload(:author, :target) + + present_events(events) + end + end + end +end diff --git a/lib/api/files.rb b/lib/api/files.rb index e6ea12c5ab7..25b0968a271 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -10,7 +10,8 @@ module API file_content: attrs[:content], file_content_encoding: attrs[:encoding], author_email: attrs[:author_email], - author_name: attrs[:author_name] + author_name: attrs[:author_name], + last_commit_sha: attrs[:last_commit_id] } end @@ -46,6 +47,7 @@ module API use :simple_file_params requires :content, type: String, desc: 'File content' optional :encoding, type: String, values: %w[base64], desc: 'File encoding' + optional :last_commit_id, type: String, desc: 'Last known commit id for this file' end end @@ -111,7 +113,12 @@ module API authorize! :push_code, user_project file_params = declared_params(include_missing: false) - result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute + + begin + result = ::Files::UpdateService.new(user_project, current_user, commit_params(file_params)).execute + rescue ::Files::UpdateService::FileChangedError => e + render_api_error!(e.message, 400) + end if result[:status] == :success status(200) diff --git a/lib/api/projects.rb b/lib/api/projects.rb index deac3934d57..56046742e08 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -167,16 +167,6 @@ module API user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics] end - desc 'Get events for a single project' do - success Entities::Event - end - params do - use :pagination - end - get ":id/events" do - present paginate(user_project.events.recent), with: Entities::Event - end - desc 'Fork new project for the current user or provided namespace.' do success Entities::Project end diff --git a/lib/api/users.rb b/lib/api/users.rb index e8694e90cf2..3f87a403a09 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -328,27 +328,6 @@ module API end end - desc 'Get the contribution events of a specified user' do - detail 'This feature was introduced in GitLab 8.13.' - success Entities::Event - end - params do - requires :id, type: Integer, desc: 'The ID of the user' - use :pagination - end - get ':id/events' do - user = User.find_by(id: params[:id]) - not_found!('User') unless user - - events = user.events. - merge(ProjectsFinder.new(current_user: current_user).execute). - references(:project). - with_associations. - recent - - present paginate(events), with: Entities::Event - end - params do requires :user_id, type: Integer, desc: 'The ID of the user' end diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb index 0a6f645b27e..95b4930cd32 100644 --- a/spec/features/issues/bulk_assignment_labels_spec.rb +++ b/spec/features/issues/bulk_assignment_labels_spec.rb @@ -18,13 +18,13 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'can bulk assign' do before do - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end context 'a label' do context 'to all issues' do before do - check 'check_all_issues' + check 'check-all-issues' open_labels_dropdown ['bug'] update_issues end @@ -52,7 +52,7 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'multiple labels' do context 'to all issues' do before do - check 'check_all_issues' + check 'check-all-issues' open_labels_dropdown %w(bug feature) update_issues end @@ -86,9 +86,10 @@ feature 'Issues > Labels bulk assignment', feature: true do before do issue2.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - check 'check_all_issues' + enable_bulk_update + check 'check-all-issues' + open_labels_dropdown ['bug'] update_issues end @@ -107,9 +108,8 @@ feature 'Issues > Labels bulk assignment', feature: true do issue2.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - - check 'check_all_issues' + enable_bulk_update + check 'check-all-issues' unmark_labels_in_dropdown %w(bug feature) update_issues end @@ -127,8 +127,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - + enable_bulk_update check_issue issue1 unmark_labels_in_dropdown ['bug'] update_issues @@ -147,8 +146,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue2.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) - + enable_bulk_update check_issue issue1 check_issue issue2 unmark_labels_in_dropdown ['bug'] @@ -171,14 +169,15 @@ feature 'Issues > Labels bulk assignment', feature: true do before do issue1.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps labels' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue2.id}")).to have_content 'feature' - check 'check_all_issues' + check 'check-all-issues' + open_milestone_dropdown(['First Release']) update_issues @@ -192,14 +191,13 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'setting a milestone and adding another label' do before do issue1.labels << bug - - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps existing label and new label is present' do expect(find("#issue_#{issue1.id}")).to have_content 'bug' - check 'check_all_issues' + check 'check-all-issues' open_milestone_dropdown ['First Release'] open_labels_dropdown ['feature'] update_issues @@ -218,7 +216,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << feature issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps existing label and new label is present' do @@ -226,7 +224,8 @@ feature 'Issues > Labels bulk assignment', feature: true do expect(find("#issue_#{issue1.id}")).to have_content 'bug' expect(find("#issue_#{issue2.id}")).to have_content 'feature' - check 'check_all_issues' + check 'check-all-issues' + open_milestone_dropdown ['First Release'] unmark_labels_in_dropdown ['feature'] update_issues @@ -248,7 +247,7 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << bug issue2.labels << feature - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'keeps labels' do @@ -257,7 +256,7 @@ feature 'Issues > Labels bulk assignment', feature: true do expect(find("#issue_#{issue2.id}")).to have_content 'feature' expect(find("#issue_#{issue2.id}")).to have_content 'First Release' - check 'check_all_issues' + check 'check-all-issues' open_milestone_dropdown(['No Milestone']) update_issues @@ -272,8 +271,7 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'toggling checked issues' do before do issue1.labels << bug - - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it do @@ -298,14 +296,14 @@ feature 'Issues > Labels bulk assignment', feature: true do issue1.labels << feature issue2.labels << bug - visit namespace_project_issues_path(project.namespace, project) + enable_bulk_update end it 'applies label from filtered results' do - check 'check_all_issues' + check 'check-all-issues' - page.within('.issues_bulk_update') do - click_button 'Labels' + page.within('.issues-bulk-update') do + click_button 'Select labels' wait_for_requests expect(find('.dropdown-menu-labels li', text: 'bug')).to have_css('.is-active') @@ -340,15 +338,16 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'cannot bulk assign labels' do it do - expect(page).not_to have_css '.check_all_issues' + expect(page).not_to have_button 'Edit Issues' + expect(page).not_to have_css '.check-all-issues' expect(page).not_to have_css '.issue-check' end end end def open_milestone_dropdown(items = []) - page.within('.issues_bulk_update') do - click_button 'Milestone' + page.within('.issues-bulk-update') do + click_button 'Select milestone' wait_for_requests items.map do |item| click_link item @@ -357,8 +356,8 @@ feature 'Issues > Labels bulk assignment', feature: true do end def open_labels_dropdown(items = [], unmark = false) - page.within('.issues_bulk_update') do - click_button 'Labels' + page.within('.issues-bulk-update') do + click_button 'Select labels' wait_for_requests items.map do |item| click_link item @@ -391,7 +390,12 @@ feature 'Issues > Labels bulk assignment', feature: true do end def update_issues - click_button 'Update issues' + click_button 'Update all' wait_for_requests end + + def enable_bulk_update + visit namespace_project_issues_path(project.namespace, project) + click_button 'Edit Issues' + end end diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 0911f1db9ba..8595847d313 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -14,7 +14,8 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'sets to closed' do visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click find('.js-issue-status').click find('.dropdown-menu-status a', text: 'Closed').click @@ -26,7 +27,8 @@ feature 'Multiple issue updating from issues#index', feature: true do create_closed visit namespace_project_issues_path(project.namespace, project, state: 'closed') - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click find('.js-issue-status').click find('.dropdown-menu-status a', text: 'Open').click @@ -39,7 +41,8 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'updates to current user' do visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click click_update_assignee_button find('.dropdown-menu-user-link', text: user.username).click @@ -54,7 +57,8 @@ feature 'Multiple issue updating from issues#index', feature: true do create_assigned visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click + click_button 'Edit Issues' + find('#check-all-issues').click click_update_assignee_button click_link 'Unassigned' @@ -69,8 +73,9 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'updates milestone' do visit namespace_project_issues_path(project.namespace, project) - find('#check_all_issues').click - find('.issues_bulk_update .js-milestone-select').click + click_button 'Edit Issues' + find('#check-all-issues').click + find('.issues-bulk-update .js-milestone-select').click find('.dropdown-menu-milestone a', text: milestone.title).click click_update_issues_button @@ -84,8 +89,9 @@ feature 'Multiple issue updating from issues#index', feature: true do expect(first('.issue')).to have_content milestone.title - find('#check_all_issues').click - find('.issues_bulk_update .js-milestone-select').click + click_button 'Edit Issues' + find('#check-all-issues').click + find('.issues-bulk-update .js-milestone-select').click find('.dropdown-menu-milestone a', text: "No Milestone").click click_update_issues_button @@ -112,7 +118,7 @@ feature 'Multiple issue updating from issues#index', feature: true do end def click_update_issues_button - find('.update_selected_issues').click + find('.update-selected-issues').click wait_for_requests end end diff --git a/spec/features/merge_requests/update_merge_requests_spec.rb b/spec/features/merge_requests/update_merge_requests_spec.rb index 4ef59a8aeb8..bcdfdf78a44 100644 --- a/spec/features/merge_requests/update_merge_requests_spec.rb +++ b/spec/features/merge_requests/update_merge_requests_spec.rb @@ -98,14 +98,16 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t end def change_status(text) - find('#check_all_issues').click + click_button 'Edit Merge Requests' + find('#check-all-issues').click find('.js-issue-status').click find('.dropdown-menu-status a', text: text).click click_update_merge_requests_button end def change_assignee(text) - find('#check_all_issues').click + click_button 'Edit Merge Requests' + find('#check-all-issues').click find('.js-update-assignee').click wait_for_requests @@ -117,14 +119,15 @@ feature 'Multiple merge requests updating from merge_requests#index', feature: t end def change_milestone(text) - find('#check_all_issues').click - find('.issues_bulk_update .js-milestone-select').click + click_button 'Edit Merge Requests' + find('#check-all-issues').click + find('.issues-bulk-update .js-milestone-select').click find('.dropdown-menu-milestone a', text: text).click click_update_merge_requests_button end def click_update_merge_requests_button - find('.update_selected_issues').click + find('.update-selected-issues').click wait_for_requests end end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 31345403702..613b1edba36 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -31,7 +31,7 @@ feature 'Environments page', :feature, :js do it 'should show one environment' do visit namespace_project_environments_path(project.namespace, project, scope: 'available') expect(page).to have_css('.environments-container') - expect(page.all('tbody > tr').length).to eq(1) + expect(page.all('.environment-name').length).to eq(1) end end @@ -59,7 +59,7 @@ feature 'Environments page', :feature, :js do it 'should show one environment' do visit namespace_project_environments_path(project.namespace, project, scope: 'stopped') expect(page).to have_css('.environments-container') - expect(page.all('tbody > tr').length).to eq(1) + expect(page.all('.environment-name').length).to eq(1) end end end diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb new file mode 100644 index 00000000000..30a2bd14f10 --- /dev/null +++ b/spec/finders/events_finder_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe EventsFinder do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:project1) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) } + let(:project2) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) } + let(:closed_issue) { create(:closed_issue, project: project1, author: user) } + let(:opened_merge_request) { create(:merge_request, source_project: project2, author: user) } + let!(:closed_issue_event) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } + let!(:opened_merge_request_event) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 1, 31)) } + let(:closed_issue2) { create(:closed_issue, project: project1, author: user) } + let(:opened_merge_request2) { create(:merge_request, source_project: project2, author: user) } + let!(:closed_issue_event2) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 2, 2)) } + let!(:opened_merge_request_event2) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 2, 2)) } + + context 'when targeting a user' do + it 'returns events between specified dates filtered on action and type' do + events = described_class.new(source: user, current_user: user, action: 'created', target_type: 'merge_request', after: Date.new(2017, 1, 1), before: Date.new(2017, 2, 1)).execute + + expect(events).to eq([opened_merge_request_event]) + end + + it 'does not return events the current_user does not have access to' do + events = described_class.new(source: user, current_user: other_user).execute + + expect(events).not_to include(opened_merge_request_event) + end + end + + context 'when targeting a project' do + it 'returns project events between specified dates filtered on action and type' do + events = described_class.new(source: project1, current_user: user, action: 'closed', target_type: 'issue', after: Date.new(2016, 12, 1), before: Date.new(2017, 1, 1)).execute + + expect(events).to eq([closed_issue_event]) + end + + it 'does not return events the current_user does not have access to' do + events = described_class.new(source: project2, current_user: other_user).execute + + expect(events).to be_empty + end + end +end diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js index 398c593eec2..ebfd60198b2 100644 --- a/spec/javascripts/commit/pipelines/pipelines_spec.js +++ b/spec/javascripts/commit/pipelines/pipelines_spec.js @@ -71,7 +71,7 @@ describe('Pipelines table in Commits and Merge requests', () => { it('should render a table with the received pipelines', (done) => { setTimeout(() => { - expect(this.component.$el.querySelectorAll('table > tbody > tr').length).toEqual(1); + expect(this.component.$el.querySelectorAll('.ci-table .commit').length).toEqual(1); expect(this.component.$el.querySelector('.realtime-loading')).toBe(null); expect(this.component.$el.querySelector('.empty-state')).toBe(null); expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBe(null); @@ -108,7 +108,7 @@ describe('Pipelines table in Commits and Merge requests', () => { expect(this.component.$el.querySelector('.js-pipelines-error-state')).toBeDefined(); expect(this.component.$el.querySelector('.realtime-loading')).toBe(null); expect(this.component.$el.querySelector('.js-empty-state')).toBe(null); - expect(this.component.$el.querySelector('table')).toBe(null); + expect(this.component.$el.querySelector('.ci-table')).toBe(null); done(); }, 0); }); diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js index c31642ac788..6639a6b5e7b 100644 --- a/spec/javascripts/environments/environment_spec.js +++ b/spec/javascripts/environments/environment_spec.js @@ -271,7 +271,7 @@ describe('Environment', () => { // wait for next async request setTimeout(() => { expect(component.$el.querySelectorAll('.js-child-row').length).toEqual(1); - expect(component.$el.querySelector('td.text-center > a.btn').textContent).toContain('Show all'); + expect(component.$el.querySelector('.text-center > a.btn').textContent).toContain('Show all'); Vue.http.interceptors = _.without(Vue.http.interceptors, folderInterceptor); done(); diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js index effbc6c3ee1..2862971bec4 100644 --- a/spec/javascripts/environments/environment_table_spec.js +++ b/spec/javascripts/environments/environment_table_spec.js @@ -29,6 +29,6 @@ describe('Environment item', () => { }, }).$mount(); - expect(component.$el.tagName).toEqual('TABLE'); + expect(component.$el.getAttribute('class')).toContain('ci-table'); }); }); diff --git a/spec/javascripts/fixtures/issuable_filter.html.haml b/spec/javascripts/fixtures/issuable_filter.html.haml index ae745b292e6..84fa5395cb8 100644 --- a/spec/javascripts/fixtures/issuable_filter.html.haml +++ b/spec/javascripts/fixtures/issuable_filter.html.haml @@ -1,6 +1,6 @@ %form.js-filter-form{action: '/user/project/issues?scope=all&state=closed'} %input{id: 'utf8', name: 'utf8', value: '✓'} - %input{id: 'check_all_issues', name: 'check_all_issues'} + %input{id: 'check-all-issues', name: 'check-all-issues'} %input{id: 'search', name: 'search'} %input{id: 'author_id', name: 'author_id'} %input{id: 'assignee_id', name: 'assignee_id'} diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js index 49fa2cb8367..45f55395d3a 100644 --- a/spec/javascripts/issuable_spec.js +++ b/spec/javascripts/issuable_spec.js @@ -1,7 +1,7 @@ -/* global Issuable */ +/* global IssuableIndex */ import '~/lib/utils/url_utility'; -import '~/issuable'; +import '~/issuable_index'; (() => { const BASE_URL = '/user/project/issues?scope=all&state=closed'; @@ -24,11 +24,11 @@ import '~/issuable'; beforeEach(() => { loadFixtures('static/issuable_filter.html.raw'); - Issuable.init(); + IssuableIndex.init(); }); it('should be defined', () => { - expect(window.Issuable).toBeDefined(); + expect(window.IssuableIndex).toBeDefined(); }); describe('filtering', () => { @@ -43,7 +43,7 @@ import '~/issuable'; it('should contain only the default parameters', () => { spyOn(gl.utils, 'visitUrl'); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + DEFAULT_PARAMS); }); @@ -52,7 +52,7 @@ import '~/issuable'; spyOn(gl.utils, 'visitUrl'); updateForm({ search: 'broken' }, $filtersForm); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); const params = `${DEFAULT_PARAMS}&search=broken`; expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); @@ -64,14 +64,14 @@ import '~/issuable'; // initial filter updateForm({ milestone_title: 'v1.0' }, $filtersForm); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); let params = `${DEFAULT_PARAMS}&milestone_title=v1.0`; expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); // update filter updateForm({ label_name: 'Frontend' }, $filtersForm); - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); params = `${DEFAULT_PARAMS}&milestone_title=v1.0&label_name=Frontend`; expect(gl.utils.visitUrl).toHaveBeenCalledWith(BASE_URL + params); }); diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index c6c45d78990..f9d060d4e0e 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -6,7 +6,7 @@ describe PagesDomain, models: true do end describe 'validate domain' do - subject { build(:pages_domain, domain: domain) } + subject(:pages_domain) { build(:pages_domain, domain: domain) } context 'is unique' do let(:domain) { 'my.domain.com' } @@ -14,36 +14,25 @@ describe PagesDomain, models: true do it { is_expected.to validate_uniqueness_of(:domain) } end - context 'valid domain' do - let(:domain) { 'my.domain.com' } - - it { is_expected.to be_valid } - end - - context 'valid hexadecimal-looking domain' do - let(:domain) { '0x12345.com'} - - it { is_expected.to be_valid } - end - - context 'no domain' do - let(:domain) { nil } - - it { is_expected.not_to be_valid } - end - - context 'invalid domain' do - let(:domain) { '0123123' } - - it { is_expected.not_to be_valid } - end - - context 'domain from .example.com' do - let(:domain) { 'my.domain.com' } - - before { allow(Settings.pages).to receive(:host).and_return('domain.com') } - - it { is_expected.not_to be_valid } + { + 'my.domain.com' => true, + '123.456.789' => true, + '0x12345.com' => true, + '0123123' => true, + '_foo.com' => false, + 'reserved.com' => false, + 'a.reserved.com' => false, + nil => false + }.each do |value, validity| + context "domain #{value.inspect} validity" do + before do + allow(Settings.pages).to receive(:host).and_return('reserved.com') + end + + let(:domain) { value } + + it { expect(pages_domain.valid?).to eq(validity) } + end end end diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb new file mode 100644 index 00000000000..a19870a95e8 --- /dev/null +++ b/spec/requests/api/events_spec.rb @@ -0,0 +1,142 @@ +require 'spec_helper' + +describe API::Events, api: true do + include ApiHelpers + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:other_user) { create(:user, username: 'otheruser') } + let(:private_project) { create(:empty_project, :private, creator_id: user.id, namespace: user.namespace) } + let(:closed_issue) { create(:closed_issue, project: private_project, author: user) } + let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } + + describe 'GET /events' do + context 'when unauthenticated' do + it 'returns authentication error' do + get api('/events') + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + it 'returns users events' do + get api('/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31', user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + end + end + + describe 'GET /users/:id/events' do + context "as a user that cannot see the event's project" do + it 'returns no events' do + get api("/users/#{user.id}/events", other_user) + + expect(response).to have_http_status(200) + expect(json_response).to be_empty + end + end + + context "as a user that can see the event's project" do + it 'accepts a username' do + get api("/users/#{user.username}/events", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + it 'returns the events' do + get api("/users/#{user.id}/events", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + context 'when there are multiple events from different projects' do + let(:second_note) { create(:note_on_issue, project: create(:empty_project)) } + + before do + second_note.project.add_user(user, :developer) + + [second_note].each do |note| + EventCreateService.new.leave_note(note, user) + end + end + + it 'returns events in the correct order (from newest to oldest)' do + get api("/users/#{user.id}/events", user) + + comment_events = json_response.select { |e| e['action_name'] == 'commented on' } + close_events = json_response.select { |e| e['action_name'] == 'closed' } + + expect(comment_events[0]['target_id']).to eq(second_note.id) + expect(close_events[0]['target_id']).to eq(closed_issue.id) + end + + it 'accepts filter parameters' do + get api("/users/#{user.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user) + + expect(json_response.size).to eq(1) + expect(json_response[0]['target_id']).to eq(closed_issue.id) + end + end + end + + it 'returns a 404 error if not found' do + get api('/users/42/events', user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + end + + describe 'GET /projects/:id/events' do + context 'when unauthenticated ' do + it 'returns 404 for private project' do + get api("/projects/#{private_project.id}/events") + + expect(response).to have_http_status(404) + end + + it 'returns 200 status for a public project' do + public_project = create(:empty_project, :public) + + get api("/projects/#{public_project.id}/events") + + expect(response).to have_http_status(200) + end + end + + context 'when not permitted to read' do + it 'returns 404' do + get api("/projects/#{private_project.id}/events", non_member) + + expect(response).to have_http_status(404) + end + end + + context 'when authenticated' do + it 'returns project events' do + get api("/projects/#{private_project.id}/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + end + + it 'returns 404 if project does not exist' do + get api("/projects/1234/events", user) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index deb2cac6869..d325c6eff9d 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -258,6 +258,25 @@ describe API::Files do expect(last_commit.author_name).to eq(user.name) end + it "returns a 400 bad request if update existing file with stale last commit id" do + params_with_stale_id = valid_params.merge(last_commit_id: 'stale') + + put api(route(file_path), user), params_with_stale_id + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('You are attempting to update a file that has changed since you started editing it.') + end + + it "updates existing file in project repo with accepts correct last commit id" do + last_commit = Gitlab::Git::Commit + .last_for_path(project.repository, 'master', URI.unescape(file_path)) + params_with_correct_id = valid_params.merge(last_commit_id: last_commit.id) + + put api(route(file_path), user), params_with_correct_id + + expect(response).to have_http_status(200) + end + it "returns a 400 bad request if no params given" do put api(route(file_path), user) diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 5c13cea69fb..86c57204971 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -762,64 +762,6 @@ describe API::Projects do end end - describe 'GET /projects/:id/events' do - shared_examples_for 'project events response' do - it 'returns the project events' do - member = create(:user) - create(:project_member, :developer, user: member, project: project) - note = create(:note_on_issue, note: 'What an awesome day!', project: project) - EventCreateService.new.leave_note(note, note.author) - - get api("/projects/#{project.id}/events", current_user) - - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - - first_event = json_response.first - expect(first_event['action_name']).to eq('commented on') - expect(first_event['note']['body']).to eq('What an awesome day!') - - last_event = json_response.last - - expect(last_event['action_name']).to eq('joined') - expect(last_event['project_id'].to_i).to eq(project.id) - expect(last_event['author_username']).to eq(member.username) - expect(last_event['author']['name']).to eq(member.name) - end - end - - context 'when unauthenticated' do - it_behaves_like 'project events response' do - let(:project) { create(:empty_project, :public) } - let(:current_user) { nil } - end - end - - context 'when authenticated' do - context 'valid request' do - it_behaves_like 'project events response' do - let(:current_user) { user } - end - end - - it 'returns a 404 error if not found' do - get api('/projects/42/events', user) - - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Project Not Found') - end - - it 'returns a 404 error if user is not a member' do - other_user = create(:user) - - get api("/projects/#{project.id}/events", other_user) - - expect(response).to have_http_status(404) - end - end - end - describe 'GET /projects/:id/users' do shared_examples_for 'project users response' do it 'returns the project users' do @@ -1480,7 +1422,7 @@ describe API::Projects do expect(json_response['owner']['id']).to eq(user2.id) expect(json_response['namespace']['id']).to eq(user2.namespace.id) expect(json_response['forked_from_project']['id']).to eq(project.id) - expect(json_response['import_status']).to eq('started') + expect(json_response['import_status']).to eq('scheduled') expect(json_response).to include("import_error") end @@ -1493,7 +1435,7 @@ describe API::Projects do expect(json_response['owner']['id']).to eq(admin.id) expect(json_response['namespace']['id']).to eq(admin.namespace.id) expect(json_response['forked_from_project']['id']).to eq(project.id) - expect(json_response['import_status']).to eq('started') + expect(json_response['import_status']).to eq('scheduled') expect(json_response).to include("import_error") end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 1c33b8f9502..4efc3e1a1e2 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1130,83 +1130,6 @@ describe API::Users do end end - describe 'GET /users/:id/events' do - let(:user) { create(:user) } - let(:project) { create(:empty_project) } - let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) } - - before do - project.add_user(user, :developer) - EventCreateService.new.leave_note(note, user) - end - - context "as a user than cannot see the event's project" do - it 'returns no events' do - other_user = create(:user) - - get api("/users/#{user.id}/events", other_user) - - expect(response).to have_http_status(200) - expect(json_response).to be_empty - end - end - - context "as a user than can see the event's project" do - context 'joined event' do - it 'returns the "joined" event' do - get api("/users/#{user.id}/events", user) - - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - - comment_event = json_response.find { |e| e['action_name'] == 'commented on' } - - expect(comment_event['project_id'].to_i).to eq(project.id) - expect(comment_event['author_username']).to eq(user.username) - expect(comment_event['note']['id']).to eq(note.id) - expect(comment_event['note']['body']).to eq('What an awesome day!') - - joined_event = json_response.find { |e| e['action_name'] == 'joined' } - - expect(joined_event['project_id'].to_i).to eq(project.id) - expect(joined_event['author_username']).to eq(user.username) - expect(joined_event['author']['name']).to eq(user.name) - end - end - - context 'when there are multiple events from different projects' do - let(:second_note) { create(:note_on_issue, project: create(:empty_project)) } - let(:third_note) { create(:note_on_issue, project: project) } - - before do - second_note.project.add_user(user, :developer) - - [second_note, third_note].each do |note| - EventCreateService.new.leave_note(note, user) - end - end - - it 'returns events in the correct order (from newest to oldest)' do - get api("/users/#{user.id}/events", user) - - comment_events = json_response.select { |e| e['action_name'] == 'commented on' } - - expect(comment_events[0]['target_id']).to eq(third_note.id) - expect(comment_events[1]['target_id']).to eq(second_note.id) - expect(comment_events[2]['target_id']).to eq(note.id) - end - end - end - - it 'returns a 404 error if not found' do - get api('/users/42/events', user) - - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 User Not Found') - end - end - context "user activities", :redis do let!(:old_active_user) { create(:user, last_activity_on: Time.utc(2000, 1, 1)) } let!(:newly_active_user) { create(:user, last_activity_on: 2.days.ago.midday) } |