diff options
80 files changed, 889 insertions, 375 deletions
diff --git a/.eslintrc.yml b/.eslintrc.yml index 1e6df6f5a77..fddaf4929fe 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -48,3 +48,4 @@ overrides: - '**/spec/**/*' rules: "@gitlab/require-i18n-strings": off + "@gitlab/no-runtime-template-compiler": off diff --git a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue index 5da38495010..5da38495010 100644 --- a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index f35b57c4e1a..00a84259c22 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import UsagePingDisabled from './components/usage_ping_disabled.vue'; import AdminUsersApp from './components/app.vue'; -export default function (el = document.querySelector('#js-admin-users-app')) { +export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => { if (!el) { return false; } @@ -19,4 +20,24 @@ export default function (el = document.querySelector('#js-admin-users-app')) { }, }), }); -} +}; + +export const initCohortsEmptyState = (el = document.querySelector('#js-cohorts-empty-state')) => { + if (!el) { + return false; + } + + const { emptyStateSvgPath, enableUsagePingLink, docsLink } = el.dataset; + + return new Vue({ + el, + provide: { + svgPath: emptyStateSvgPath, + primaryButtonPath: enableUsagePingLink, + docsLink, + }, + render(h) { + return h(UsagePingDisabled); + }, + }); +}; diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js new file mode 100644 index 00000000000..9ada77396c7 --- /dev/null +++ b/app/assets/javascripts/admin/users/tabs.js @@ -0,0 +1,23 @@ +import { historyPushState } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; + +const COHORTS_PANE = 'cohorts'; + +const tabClickHandler = (e) => { + const { hash } = e.currentTarget; + const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null; + const newUrl = mergeUrlParams({ tab }, window.location.href); + historyPushState(newUrl); +}; + +const initTabs = () => { + const tabLinks = document.querySelectorAll('.js-users-tab-item a'); + + if (tabLinks.length) { + tabLinks.forEach((tabLink) => { + tabLink.addEventListener('click', (e) => tabClickHandler(e)); + }); + } +}; + +export default initTabs; diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index bf3dc5c608f..32640f83b83 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -1,4 +1,7 @@ -/* eslint-disable no-new */ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/shared/boards/components/_sidebar.html.haml for its +// template. +/* eslint-disable no-new, @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue new file mode 100644 index 00000000000..59ee47937c9 --- /dev/null +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -0,0 +1,52 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { hide } from '~/tooltips'; + +export default { + components: { + GlIcon, + }, + props: { + issueBoardsContentSelector: { + type: String, + required: true, + }, + }, + data() { + return { + isFullscreen: false, + }; + }, + methods: { + toggleFocusMode() { + hide(this.$refs.toggleFocusModeButton); + + const issueBoardsContent = document.querySelector(this.issueBoardsContentSelector); + issueBoardsContent.classList.toggle('is-focused'); + + this.isFullscreen = !this.isFullscreen; + }, + }, + i18n: { + toggleFocusMode: __('Toggle focus mode'), + }, +}; +</script> + +<template> + <div class="board-extra-actions"> + <a + ref="toggleFocusModeButton" + href="#" + class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" + data-qa-selector="focus_mode_button" + role="button" + :aria-label="$options.i18n.toggleFocusMode" + :title="$options.i18n.toggleFocusMode" + @click="toggleFocusMode" + > + <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index 347deb81846..0a230f72dcc 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1,45 +1,17 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { GlIcon } from '@gitlab/ui'; -import { hide } from '~/tooltips'; +import ToggleFocus from './components/toggle_focus.vue'; -export default (ModalStore, boardsStore) => { - const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); +export default () => { + const issueBoardsContentSelector = '.content-wrapper > .js-focus-mode-board'; return new Vue({ - el: document.getElementById('js-toggle-focus-btn'), - components: { - GlIcon, + el: '#js-toggle-focus-btn', + render(h) { + return h(ToggleFocus, { + props: { + issueBoardsContentSelector, + }, + }); }, - data: { - modal: ModalStore.store, - store: boardsStore.state, - isFullscreen: false, - }, - methods: { - toggleFocusMode() { - const $el = $(this.$refs.toggleFocusModeButton); - hide($el); - - issueBoardsContent.classList.toggle('is-focused'); - - this.isFullscreen = !this.isFullscreen; - }, - }, - template: ` - <div class="board-extra-actions"> - <a - href="#" - class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" - data-qa-selector="focus_mode_button" - role="button" - aria-label="Toggle focus mode" - title="Toggle focus mode" - ref="toggleFocusModeButton" - @click="toggleFocusMode"> - <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> - </a> - </div> - `, }); }; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index bd5a6cc40c4..ddec09e3165 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -1,3 +1,7 @@ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/projects/cycle_analytics/show.html.haml for its +// template. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; import Cookies from 'js-cookie'; diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index 6c8e77a7fe5..1182cb34210 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -28,19 +28,18 @@ class RecentSearchesRoot { const { state } = this.store; this.vm = new Vue({ el: this.wrapperElement, - components: { - RecentSearchesDropdownContent, - }, data() { return state; }, - template: ` - <recent-searches-dropdown-content - :items="recentSearches" - :is-local-storage-available="isLocalStorageAvailable" - :allowed-keys="allowedKeys" - /> - `, + render(h) { + return h(RecentSearchesDropdownContent, { + props: { + items: this.recentSearches, + isLocalStorageAvailable: this.isLocalStorageAvailable, + allowedKeys: this.allowedKeys, + }, + }); + }, }); } diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 064c0f38464..87642c7a698 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -1,4 +1,7 @@ -/* eslint-disable no-param-reassign */ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on +// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml +// for its template. +/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js index bc926cb9155..47214e288ae 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js @@ -1,4 +1,7 @@ -/* eslint-disable no-param-reassign */ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on +// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml +// for its template. +/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import actionsMixin from '../mixins/line_conflict_actions'; diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js index bb306e74825..1d5946cd78a 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js @@ -15,6 +15,9 @@ import utilsMixin from '../mixins/line_conflict_utils'; required: true, }, }, + // This is a true violation of @gitlab/no-runtime-template-compiler, as it + // has a template string. + // eslint-disable-next-line @gitlab/no-runtime-template-compiler template: ` <table class="diff-wrap-lines code js-syntax-highlight"> <tr class="line_holder parallel" v-for="section in file.parallelLines"> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 229f6f3e339..2bd907cad40 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,3 +1,7 @@ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its +// template. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index 307154c9a84..2f8c3e2a0c4 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -26,7 +26,24 @@ export default (props = {}) => { dashboardProps: { ...dataProps, ...props }, }; }, - template: `<router-view :dashboardProps="dashboardProps"/>`, + render(h) { + return h('RouterView', { + // This is attrs rather than props because: + // 1. RouterView only actually defines one prop: `name`. + // 2. The RouterView [throws away other props][1] given to it, in + // favour of those configured in the route config/params. + // 3. The Vue template compiler itself in general compiles anything + // like <some-component :foo="bar" /> into roughly + // h('some-component', { attrs: { foo: bar } }). Then later, Vue + // [extract props from attrs and merges them with props][2], + // matching them up according to the component's definition. + // [1]: https://github.com/vuejs/vue-router/blob/v3.4.9/src/components/view.js#L124 + // [2]: https://github.com/vuejs/vue/blob/v2.6.12/src/core/vdom/helpers/extract-props.js#L12-L50 + attrs: { + dashboardProps: this.dashboardProps, + }, + }); + }, }); } }; diff --git a/app/assets/javascripts/onboarding_issues/index.js b/app/assets/javascripts/onboarding_issues/index.js index b23a10c9254..f5f971f7bb5 100644 --- a/app/assets/javascripts/onboarding_issues/index.js +++ b/app/assets/javascripts/onboarding_issues/index.js @@ -35,16 +35,16 @@ const showPopover = (el, path, footer, options) => { boundary: 'window', html: true, placement: 'top', - template: `<div class="popover blue learn-gitlab d-none d-xl-block" role="tooltip"> + template: `<div class="gl-popover popover blue learn-gitlab d-none d-xl-block" role="tooltip"> <div class="arrow"></div> - <div class="close cursor-pointer gl-font-base text-white gl-opacity-10 p-2">✕</div> - <div class="popover-body gl-font-base gl-line-height-20 pb-0 px-3"></div> - <div class="bold text-right text-white p-2">${footer}</div> + <div class="js-close-learn-gitlab gl-font-weight-bold gl-line-height-normal float-right gl-cursor-pointer gl-font-base gl-text-white gl-opacity-10 gl-p-3">✕</div> + <div class="popover-body gl-font-base"></div> + <div class="gl-font-weight-bold gl-text-right gl-text-white gl-p-3 gl-pt-0">${footer}</div> </div>`, }; // When one of the popovers is dismissed, remove the cookie. - const closeButton = () => document.querySelector('.learn-gitlab.popover .close'); + const closeButton = () => document.querySelector('.js-close-learn-gitlab'); // We still have to use jQuery, since Bootstrap's Popover is based on jQuery. const jQueryEl = $(el); diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index af1595398a8..d3cd783e146 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,3 +1,7 @@ +// This is a true violation of @gitlab/no-runtime-template-compiler, as it +// relies on app/views/admin/application_settings/_gitpod.html.haml for its +// template. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import initUserInternalRegexPlaceholder from '../account_and_limits'; import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; diff --git a/app/assets/javascripts/pages/admin/cohorts/index.js b/app/assets/javascripts/pages/admin/cohorts/index.js deleted file mode 100644 index 1cc54df15a1..00000000000 --- a/app/assets/javascripts/pages/admin/cohorts/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import UsagePingDisabled from '~/admin/cohorts/components/usage_ping_disabled.vue'; - -document.addEventListener('DOMContentLoaded', () => { - const emptyStateContainer = document.getElementById('js-cohorts-empty-state'); - - if (!emptyStateContainer) return false; - - const { emptyStateSvgPath, enableUsagePingLink, docsLink } = emptyStateContainer.dataset; - - return new Vue({ - el: emptyStateContainer, - provide: { - svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, - docsLink, - }, - render(h) { - return h(UsagePingDisabled); - }, - }); -}); diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 75a8284f5f8..63a2813206e 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -4,7 +4,8 @@ import Translate from '~/vue_shared/translate'; import ModalManager from './components/user_modal_manager.vue'; import csrf from '~/lib/utils/csrf'; import initConfirmModal from '~/confirm_modal'; -import initAdminUsersApp from '~/admin/users'; +import { initAdminUsersApp, initCohortsEmptyState } from '~/admin/users'; +import initTabs from '~/admin/users/tabs'; const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts'; const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal'; @@ -58,4 +59,6 @@ document.addEventListener('DOMContentLoaded', () => { initConfirmModal(); initAdminUsersApp(); + initCohortsEmptyState(); + initTabs(); }); diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index c343a37b292..f66c09cb1ac 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -7,10 +7,13 @@ document.addEventListener('DOMContentLoaded', () => { remainingTimeElements.forEach( (el) => new Vue({ - ...GlCountdown, el, - propsData: { - endDateString: el.dateTime, + render(h) { + return h(GlCountdown, { + props: { + endDateString: el.dateTime, + }, + }); }, }), ); diff --git a/app/assets/javascripts/performance/vue_performance_plugin.js b/app/assets/javascripts/performance/vue_performance_plugin.js index 7329b83b1d1..18ff9f7f8c4 100644 --- a/app/assets/javascripts/performance/vue_performance_plugin.js +++ b/app/assets/javascripts/performance/vue_performance_plugin.js @@ -1,3 +1,6 @@ +// This is a false violation of @gitlab/no-runtime-template-compiler, since it +// is simply defining a global Vue mixin. +/* eslint-disable @gitlab/no-runtime-template-compiler */ const ComponentPerformancePlugin = { install(Vue, options) { Vue.mixin({ diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 4b4fb6082c6..6b7381883cc 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -114,7 +114,7 @@ export default { <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> <div class="table-mobile-content text-center"> <div - class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" + class="ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" :class="`ci-status-icon-${testCase.status}`" > <gl-icon :size="24" :name="testCase.icon" /> diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 5c1f27b166a..8d1a941058c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -4,16 +4,16 @@ import { TestStatus } from '../../constants'; export function iconForTestStatus(status) { switch (status) { case TestStatus.SUCCESS: - return 'status_success_borderless'; + return 'status_success'; case TestStatus.FAILED: - return 'status_failed_borderless'; + return 'status_failed'; case TestStatus.ERROR: - return 'status_warning_borderless'; + return 'status_warning'; case TestStatus.SKIPPED: - return 'status_skipped_borderless'; + return 'status_skipped'; case TestStatus.UNKNOWN: default: - return 'status_notfound_borderless'; + return 'status_notfound'; } } diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index d0d49233334..81712ce84c9 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; @@ -10,6 +10,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { components: { CiBadge, + GlAlert, GlBadge, GlIcon, GlLink, @@ -105,6 +106,7 @@ export default { :items="states" :fields="fields" data-testid="terraform-states-table" + details-td-class="gl-p-0!" fixed stacked="md" > @@ -189,5 +191,21 @@ export default { <template v-if="terraformAdmin" #cell(actions)="{ item }"> <state-actions :state="item" /> </template> + + <template #row-details="row"> + <gl-alert + data-testid="terraform-states-table-error" + variant="danger" + @dismiss="row.toggleDetails" + > + <span + v-for="errorMessage in row.item.errorMessages" + :key="errorMessage" + class="gl-display-flex gl-justify-content-start" + > + {{ errorMessage }} + </span> + </gl-alert> + </template> </gl-table> </template> diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue index 44b0713e544..ba064825034 100644 --- a/app/assets/javascripts/terraform/components/states_table_actions.vue +++ b/app/assets/javascripts/terraform/components/states_table_actions.vue @@ -10,6 +10,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { s__ } from '~/locale'; +import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql'; import lockState from '../graphql/mutations/lock_state.mutation.graphql'; import unlockState from '../graphql/mutations/unlock_state.mutation.graphql'; import removeState from '../graphql/mutations/remove_state.mutation.graphql'; @@ -33,13 +34,13 @@ export default { }, data() { return { - loading: false, showRemoveModal: false, removeConfirmText: '', }; }, i18n: { downloadJSON: s__('Terraform|Download JSON'), + errorUpdate: s__('Terraform|An error occurred while changing the state file'), lock: s__('Terraform|Lock'), modalBody: s__( 'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.', @@ -76,19 +77,37 @@ export default { this.removeConfirmText = ''; }, lock() { - this.stateMutation(lockState); + this.stateActionMutation(lockState); }, unlock() { - this.stateMutation(unlockState); + this.stateActionMutation(unlockState); + }, + updateStateCache(newData) { + this.$apollo.mutate({ + mutation: addDataToState, + variables: { + terraformState: { + ...this.state, + ...newData, + }, + }, + }); }, remove() { if (!this.disableModalSubmit) { this.hideModal(); - this.stateMutation(removeState); + this.stateActionMutation(removeState); } }, - stateMutation(mutation) { - this.loading = true; + stateActionMutation(mutation) { + let errorMessages = []; + + this.updateStateCache({ + _showDetails: false, + errorMessages, + loadingActions: true, + }); + this.$apollo .mutate({ mutation, @@ -99,9 +118,22 @@ export default { awaitRefetchQueries: true, notifyOnNetworkStatusChange: true, }) - .catch(() => {}) + .then(({ data }) => { + errorMessages = + data?.terraformStateDelete?.errors || + data?.terraformStateLock?.errors || + data?.terraformStateUnlock?.errors || + []; + }) + .catch(() => { + errorMessages = [this.$options.i18n.errorUpdate]; + }) .finally(() => { - this.loading = false; + this.updateStateCache({ + _showDetails: Boolean(errorMessages.length), + errorMessages, + loadingActions: false, + }); }); }, }, @@ -114,7 +146,7 @@ export default { icon="ellipsis_v" right :data-testid="`terraform-state-actions-${state.name}`" - :disabled="loading" + :disabled="state.loadingActions" toggle-class="gl-px-3! gl-shadow-none!" > <template #button-content> diff --git a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql index 49f9ae3dd97..f876fea64ac 100644 --- a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql +++ b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql @@ -2,6 +2,10 @@ #import "./state_version.fragment.graphql" fragment State on TerraformState { + _showDetails @client + errorMessages @client + loadingActions @client + id name lockedAt diff --git a/app/assets/javascripts/terraform/graphql/mutations/add_data_to_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/add_data_to_state.mutation.graphql new file mode 100644 index 00000000000..645b9766e2b --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/mutations/add_data_to_state.mutation.graphql @@ -0,0 +1,3 @@ +mutation addDataToTerraformState($terraformState: State!) { + addDataToTerraformState(terraformState: $terraformState) @client +} diff --git a/app/assets/javascripts/terraform/graphql/resolvers.js b/app/assets/javascripts/terraform/graphql/resolvers.js new file mode 100644 index 00000000000..2845a1e5279 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/resolvers.js @@ -0,0 +1,41 @@ +import TerraformState from './fragments/state.fragment.graphql'; + +export default { + TerraformState: { + _showDetails: (state) => { + // eslint-disable-next-line no-underscore-dangle + return state._showDetails || false; + }, + errorMessages: (state) => { + return state.errorMessages || []; + }, + loadingActions: (state) => { + return state.loadingActions || false; + }, + }, + Mutation: { + addDataToTerraformState: (_, { terraformState }, { client }) => { + const fragmentData = { + id: terraformState.id, + fragment: TerraformState, + // eslint-disable-next-line @gitlab/require-i18n-strings + fragmentName: 'State', + }; + + const previousTerraformState = client.readFragment(fragmentData); + + if (previousTerraformState) { + client.writeFragment({ + ...fragmentData, + data: { + ...previousTerraformState, + // eslint-disable-next-line no-underscore-dangle + _showDetails: terraformState._showDetails, + errorMessages: terraformState.errorMessages, + loadingActions: terraformState.loadingActions, + }, + }); + } + }, + }, +}; diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js index e27a29433f3..879c4c292e8 100644 --- a/app/assets/javascripts/terraform/index.js +++ b/app/assets/javascripts/terraform/index.js @@ -1,7 +1,9 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import TerraformList from './components/terraform_list.vue'; import createDefaultClient from '~/lib/graphql'; +import resolvers from './graphql/resolvers'; Vue.use(VueApollo); @@ -12,7 +14,13 @@ export default () => { return null; } - const defaultClient = createDefaultClient(); + const defaultClient = createDefaultClient(resolvers, { + cacheConfig: { + dataIdFromObject: (object) => { + return object.id || defaultDataIdFromObject(object); + }, + }, + }); const { emptyStateImage, projectPath } = el.dataset; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 4c130945487..e0729e69283 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -177,7 +177,7 @@ export default { </template> <template v-else-if="hasPipeline"> <a :href="status.details_path" class="align-self-start gl-mr-3"> - <ci-icon :status="status" :size="24" :borderless="true" class="add-border" /> + <ci-icon :status="status" :size="24" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index d512877a20d..d2da8547d1c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,3 +1,7 @@ +// This is a false violation of @gitlab/no-runtime-template-compiler, since it +// creates a new Vue instance by spreading a _valid_ Vue component definition +// into the Vue constructor. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import Vue from 'vue'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import VueApollo from 'vue-apollo'; diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js index e1734809bce..3b46e677636 100644 --- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js +++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js @@ -1,3 +1,6 @@ +// This is a false violation of @gitlab/no-runtime-template-compiler, since it +// is simply defining a global Vue mixin. +/* eslint-disable @gitlab/no-runtime-template-compiler */ export default (Vue) => { Vue.mixin({ provide: { diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js index 616848639f1..2390aff3bca 100644 --- a/app/assets/javascripts/vue_shared/translate.js +++ b/app/assets/javascripts/vue_shared/translate.js @@ -1,3 +1,6 @@ +// This is a false violation of @gitlab/no-runtime-template-compiler, since it +// is simply defining a global Vue mixin. +/* eslint-disable @gitlab/no-runtime-template-compiler */ import { __, n__, s__, sprintf } from '../locale'; export default (Vue) => { diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index af53f10bbd5..222e10f51ad 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -3,10 +3,6 @@ svg { fill: $green-500; } - - &.add-border { - @include borderless-status-icon($green-500); - } } .ci-status-icon-error, @@ -14,10 +10,6 @@ svg { fill: $red-500; } - - &.add-border { - @include borderless-status-icon($red-500); - } } .ci-status-icon-pending, @@ -27,31 +19,21 @@ svg { fill: $orange-500; } - - &.add-border { - @include borderless-status-icon($orange-500); - } } .ci-status-icon-running { svg { fill: $blue-400; } - - &.add-border { - @include borderless-status-icon($blue-400); - } } .ci-status-icon-canceled, -.ci-status-icon-disabled { +.ci-status-icon-disabled, +.ci-status-icon-scheduled, +.ci-status-icon-manual { svg { fill: $gl-text-color; } - - &.add-border { - @include borderless-status-icon($gl-text-color); - } } .ci-status-icon-preparing, @@ -61,17 +43,6 @@ svg { fill: var(--gray-400, $gray-400); } - - &.add-border { - @include borderless-status-icon(var(--gray-400, $gray-400)); - } -} - -.ci-status-icon-scheduled, -.ci-status-icon-manual { - svg { - fill: $gl-text-color; - } } .icon-link { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index e3d02d01496..e29e204b14f 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -354,13 +354,6 @@ } } -@mixin borderless-status-icon($color) { - svg { - border: 1px solid $color; - border-radius: 50%; - } -} - @mixin emoji-menu-toggle-button { line-height: 1; padding: 0; diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index a26dc554506..9bb73c822b0 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -1,19 +1,11 @@ # frozen_string_literal: true class Admin::CohortsController < Admin::ApplicationController - include Analytics::UniqueVisitsHelper - - track_unique_visits :index, target_id: 'i_analytics_cohorts' - feature_category :devops_reports + # Backwards compatibility. Remove it and routing in 14.0 + # @see https://gitlab.com/gitlab-org/gitlab/-/issues/299303 def index - if Gitlab::CurrentSettings.usage_ping_enabled - cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do - CohortsService.new.execute - end - - @cohorts = CohortsSerializer.new.represent(cohorts_results) - end + redirect_to admin_users_path(tab: 'cohorts') end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 3fe972d1917..d0761083c8b 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -2,6 +2,7 @@ class Admin::UsersController < Admin::ApplicationController include RoutableActions + include Analytics::UniqueVisitsHelper before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate @@ -15,6 +16,10 @@ class Admin::UsersController < Admin::ApplicationController @users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) + + @cohorts = load_cohorts + + track_cohorts_visit if params[:tab] == 'cohorts' end def show @@ -307,6 +312,22 @@ class Admin::UsersController < Admin::ApplicationController def log_impersonation_event Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username }) end + + def load_cohorts + if Gitlab::CurrentSettings.usage_ping_enabled + cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do + CohortsService.new.execute + end + + CohortsSerializer.new.represent(cohorts_results) + end + end + + def track_cohorts_visit + if request.format.html? && request.headers['DNT'] != '1' + track_visit('i_analytics_cohorts') + end + end end Admin::UsersController.prepend_if_ee('EE::Admin::UsersController') diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 1c2930f6e9b..5576d5766c7 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -86,7 +86,7 @@ class Projects::ForksController < Projects::ApplicationController def fork_service strong_memoize(:fork_service) do - ::Projects::ForkService.new(project, current_user, namespace: fork_namespace) + ::Projects::ForkService.new(project, current_user, fork_params) end end @@ -96,6 +96,12 @@ class Projects::ForksController < Projects::ApplicationController end end + def fork_params + params.permit(:path, :name, :description, :visibility).tap do |param| + param[:namespace] = fork_namespace + end + end + def authorize_fork_namespace! access_denied! unless fork_namespace && fork_service.valid_fork_target? end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 3c757a4ef26..c170e58b4ce 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -64,7 +64,7 @@ module NavHelper end def admin_analytics_nav_links - %w(dev_ops_report cohorts) + %w(dev_ops_report) end def group_issues_sub_menu_items diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb index c7a996a557f..64b6a43436b 100644 --- a/app/services/pages/migrate_from_legacy_storage_service.rb +++ b/app/services/pages/migrate_from_legacy_storage_service.rb @@ -24,7 +24,9 @@ module Pages @queue.close @logger.info("Waiting for threads to finish...") - threads.each(&:join) + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + threads.each(&:join) + end { migrated: @migrated, errored: @errored } end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 050bfdd862d..fd9b64a4ee0 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -43,8 +43,8 @@ module Projects def new_fork_params new_params = { forked_from_project: @project, - visibility_level: allowed_visibility_level, - description: @project.description, + visibility_level: target_visibility_level, + description: target_description, name: target_name, path: target_path, shared_runners_enabled: @project.shared_runners_enabled, @@ -107,6 +107,10 @@ module Projects @target_name ||= @params[:name] || @project.name end + def target_description + @target_description ||= @params[:description] || @project.description + end + def target_namespace @target_namespace ||= @params[:namespace] || current_user.namespace end @@ -115,8 +119,9 @@ module Projects @skip_disk_validation ||= @params[:skip_disk_validation] || false end - def allowed_visibility_level + def target_visibility_level target_level = [@project.visibility_level, target_namespace.visibility_level].min + target_level = [target_level, Gitlab::VisibilityLevel.level_value(params[:visibility])].min if params.key?(:visibility) Gitlab::VisibilityLevel.closest_allowed_level(target_level) end diff --git a/app/views/admin/cohorts/index.html.haml b/app/views/admin/users/_cohorts.html.haml index 03cd392d370..013c6072165 100644 --- a/app/views/admin/cohorts/index.html.haml +++ b/app/views/admin/users/_cohorts.html.haml @@ -1,6 +1,3 @@ -- breadcrumb_title _("Cohorts") -- page_title _("Cohorts") - - if @cohorts = render 'cohorts_table' - else diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/users/_cohorts_table.html.haml index bb6266b38f6..bb6266b38f6 100644 --- a/app/views/admin/cohorts/_cohorts_table.html.haml +++ b/app/views/admin/users/_cohorts_table.html.haml diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml new file mode 100644 index 00000000000..697c0175b4f --- /dev/null +++ b/app/views/admin/users/_users.html.haml @@ -0,0 +1,90 @@ +.top-area.scrolling-tabs-container.inner-page-scroll-tabs + .fade-left + = sprite_icon('chevron-lg-left', size: 12) + .fade-right + = sprite_icon('chevron-lg-right', size: 12) + %ul.nav-links.nav.nav-tabs.scrolling-tabs + = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do + = link_to admin_users_path do + = s_('AdminUsers|Active') + %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) + = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do + = link_to admin_users_path(filter: "admins") do + = s_('AdminUsers|Admins') + %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do + = link_to admin_users_path(filter: 'two_factor_enabled') do + = s_('AdminUsers|2FA Enabled') + %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do + = link_to admin_users_path(filter: 'two_factor_disabled') do + = s_('AdminUsers|2FA Disabled') + %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) + = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do + = link_to admin_users_path(filter: 'external') do + = s_('AdminUsers|External') + %small.badge.badge-pill= limited_counter_with_delimiter(User.external) + = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do + = link_to admin_users_path(filter: "blocked") do + = s_('AdminUsers|Blocked') + %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do + = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do + = s_('AdminUsers|Pending approval') + %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval) + = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do + = link_to admin_users_path(filter: "deactivated") do + = s_('AdminUsers|Deactivated') + %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) + = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do + = link_to admin_users_path(filter: "wop") do + = s_('AdminUsers|Without projects') + %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) + .nav-controls + = render_if_exists 'admin/users/admin_email_users' + = render_if_exists 'admin/users/admin_export_user_permissions' + = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right' + +.filtered-search-block.row-content-block.border-top-0 + = form_tag admin_users_path, method: :get do + - if params[:filter].present? + = hidden_field_tag "filter", h(params[:filter]) + .search-holder + .search-field-holder.gl-mb-4 + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' } + - if @sort.present? + = hidden_field_tag :sort, @sort + = sprite_icon('search', css_class: 'search-icon') + = button_tag s_('AdminUsers|Search users') if Rails.env.test? + .dropdown.user-sort-dropdown + = label_tag 'Sort by', nil, class: 'label-bold' + - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name + = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) + %ul.dropdown-menu.dropdown-menu-right + %li.dropdown-header + = s_('AdminUsers|Sort by') + %li + - users_sort_options_hash.each do |value, title| + = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do + = title + +- if Feature.enabled?(:vue_admin_users) + #js-admin-users-app{ data: admin_users_data_attributes(@users) } + .gl-spinner-container.gl-my-7 + %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } +- elsif @users.empty? + .nothing-here-block.border-top-0 + = s_('AdminUsers|No users found') +- else + .table-holder + .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-40{ role: 'rowheader' }= _('Name') + .table-section.section-10{ role: 'rowheader' }= _('Projects') + .table-section.section-15{ role: 'rowheader' }= _('Created on') + .table-section.section-15{ role: 'rowheader' }= _('Last activity') + + = render partial: 'admin/users/user', collection: @users + += paginate @users, theme: "gitlab" + += render partial: 'admin/users/modals' diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index cef16b1881e..8da0c7f4300 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,92 +1,17 @@ - page_title _("Users") -.top-area.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left - = sprite_icon('chevron-lg-left', size: 12) - .fade-right - = sprite_icon('chevron-lg-right', size: 12) - %ul.nav-links.nav.nav-tabs.scrolling-tabs - = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do - = link_to admin_users_path do - = s_('AdminUsers|Active') - %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) - = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do - = link_to admin_users_path(filter: "admins") do - = s_('AdminUsers|Admins') - %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do - = link_to admin_users_path(filter: 'two_factor_enabled') do - = s_('AdminUsers|2FA Enabled') - %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do - = link_to admin_users_path(filter: 'two_factor_disabled') do - = s_('AdminUsers|2FA Disabled') - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) - = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do - = link_to admin_users_path(filter: 'external') do - = s_('AdminUsers|External') - %small.badge.badge-pill= limited_counter_with_delimiter(User.external) - = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do - = link_to admin_users_path(filter: "blocked") do - = s_('AdminUsers|Blocked') - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do - = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do - = s_('AdminUsers|Pending approval') - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval) - = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do - = link_to admin_users_path(filter: "deactivated") do - = s_('AdminUsers|Deactivated') - %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) - = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do - = link_to admin_users_path(filter: "wop") do - = s_('AdminUsers|Without projects') - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) - .nav-controls - = render_if_exists 'admin/users/admin_email_users' - = render_if_exists 'admin/users/admin_export_user_permissions' - = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right' +%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' } + %li.nav-item.js-users-tab-item{ role: 'presentation' } + %a.nav-link{ href: '#users', class: active_when(params[:tab] != 'cohorts'), data: { toggle: 'tab' }, role: 'tab' } + = s_('AdminUsers|Users') + %li.nav-item.js-users-tab-item{ role: 'presentation' } + %a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab', track: { event: 'i_analytics_cohorts', action: 'click_tab' } }, role: 'tab' } + = s_('AdminUsers|Cohorts') -.filtered-search-block.row-content-block.border-top-0 - = form_tag admin_users_path, method: :get do - - if params[:filter].present? - = hidden_field_tag "filter", h(params[:filter]) - .search-holder - .search-field-holder.gl-mb-4 - = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' } - - if @sort.present? - = hidden_field_tag :sort, @sort - = sprite_icon('search', css_class: 'search-icon') - = button_tag s_('AdminUsers|Search users') if Rails.env.test? - .dropdown.user-sort-dropdown - = label_tag 'Sort by', nil, class: 'label-bold' - - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) - %ul.dropdown-menu.dropdown-menu-right - %li.dropdown-header - = s_('AdminUsers|Sort by') - %li - - users_sort_options_hash.each do |value, title| - = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do - = title +.tab-content + .tab-pane{ id: 'users', class: ('active' if params[:tab] != 'cohorts') } + = render 'users' + .tab-pane{ id: 'cohorts', class: ('active' if params[:tab] == 'cohorts') } + = render 'cohorts' -- if Feature.enabled?(:vue_admin_users) - #js-admin-users-app{ data: admin_users_data_attributes(@users) } - .gl-spinner-container.gl-my-7 - %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } -- elsif @users.empty? - .nothing-here-block.border-top-0 - = s_('AdminUsers|No users found') -- else - .table-holder - .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } - .table-section.section-40{ role: 'rowheader' }= _('Name') - .table-section.section-10{ role: 'rowheader' }= _('Projects') - .table-section.section-15{ role: 'rowheader' }= _('Created on') - .table-section.section-15{ role: 'rowheader' }= _('Last activity') - = render partial: 'admin/users/user', collection: @users - -= paginate @users, theme: "gitlab" - -= render partial: 'admin/users/modals' diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 323cbcdb24d..ca2d3828c14 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -65,10 +65,6 @@ = link_to admin_dev_ops_report_path, title: _('DevOps Report') do %span = _('DevOps Report') - = nav_link(controller: :cohorts) do - = link_to admin_cohorts_path, title: _('Cohorts') do - %span - = _('Cohorts') - if Feature.enabled?(:instance_statistics, default_enabled: true) = nav_link(controller: :instance_statistics) do = link_to admin_instance_statistics_path, title: _('Usage Trends') do diff --git a/changelogs/unreleased/15013-fork-to-different-name.yml b/changelogs/unreleased/15013-fork-to-different-name.yml new file mode 100644 index 00000000000..54689ab10a2 --- /dev/null +++ b/changelogs/unreleased/15013-fork-to-different-name.yml @@ -0,0 +1,5 @@ +--- +title: Support setting more attributes when forking a project +merge_request: 51962 +author: +type: added diff --git a/changelogs/unreleased/207385-services-unique-constraint-on-type.yml b/changelogs/unreleased/207385-services-unique-constraint-on-type.yml new file mode 100644 index 00000000000..bafc9787de2 --- /dev/null +++ b/changelogs/unreleased/207385-services-unique-constraint-on-type.yml @@ -0,0 +1,5 @@ +--- +title: Add unique index on services project_id and type +merge_request: 52563 +author: +type: changed diff --git a/changelogs/unreleased/281963-move-cohorts-to-users.yml b/changelogs/unreleased/281963-move-cohorts-to-users.yml new file mode 100644 index 00000000000..3ef6ea39d34 --- /dev/null +++ b/changelogs/unreleased/281963-move-cohorts-to-users.yml @@ -0,0 +1,5 @@ +--- +title: Move Cohorts page to Overiew-Users +merge_request: 51707 +author: +type: changed diff --git a/changelogs/unreleased/299559-remove-add-border-borderless-prop-from-ci-icon-when-used-together.yml b/changelogs/unreleased/299559-remove-add-border-borderless-prop-from-ci-icon-when-used-together.yml new file mode 100644 index 00000000000..c2e5fa78629 --- /dev/null +++ b/changelogs/unreleased/299559-remove-add-border-borderless-prop-from-ci-icon-when-used-together.yml @@ -0,0 +1,6 @@ +--- +title: Make CI Icon in merge request pipeline detail consistent with other widget + icons +merge_request: 52516 +author: +type: fixed diff --git a/changelogs/unreleased/terraform-state-errors-graphql.yml b/changelogs/unreleased/terraform-state-errors-graphql.yml new file mode 100644 index 00000000000..93b115ae93d --- /dev/null +++ b/changelogs/unreleased/terraform-state-errors-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Display Terraform list errors to user +merge_request: 51397 +author: +type: changed diff --git a/db/migrate/20210126091713_add_unique_index_services_project_id_and_type.rb b/db/migrate/20210126091713_add_unique_index_services_project_id_and_type.rb new file mode 100644 index 00000000000..272dca70a8b --- /dev/null +++ b/db/migrate/20210126091713_add_unique_index_services_project_id_and_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddUniqueIndexServicesProjectIdAndType < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + INDEX_NAME = 'index_services_on_project_id_and_type_unique' + + def up + add_concurrent_index :services, [:project_id, :type], name: INDEX_NAME, unique: true + end + + def down + remove_concurrent_index_by_name :services, name: INDEX_NAME + end +end diff --git a/db/migrate/20210126092102_remove_index_services_project_id_and_type.rb b/db/migrate/20210126092102_remove_index_services_project_id_and_type.rb new file mode 100644 index 00000000000..49780d03b7b --- /dev/null +++ b/db/migrate/20210126092102_remove_index_services_project_id_and_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class RemoveIndexServicesProjectIdAndType < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + INDEX_NAME = 'index_services_on_project_id_and_type' + + # Replaced by the index added in 20210126091713_add_unique_index_services_project_id_and_type.rb + def up + remove_concurrent_index_by_name :services, name: INDEX_NAME + end + + def down + add_concurrent_index :services, [:project_id, :type], name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20210126091713 b/db/schema_migrations/20210126091713 new file mode 100644 index 00000000000..b75636bd437 --- /dev/null +++ b/db/schema_migrations/20210126091713 @@ -0,0 +1 @@ +3906739d07514e6e59f79a4a81d28859a2481614a299c95ec1b1d9825a07ec64
\ No newline at end of file diff --git a/db/schema_migrations/20210126092102 b/db/schema_migrations/20210126092102 new file mode 100644 index 00000000000..803643389f6 --- /dev/null +++ b/db/schema_migrations/20210126092102 @@ -0,0 +1 @@ +124c5ae1a1ccade5dec01f72b726e03febc8f56411d7d8990f976bb2a9516037
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 21035098449..bae7dc05122 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -23003,7 +23003,7 @@ CREATE INDEX index_service_desk_enabled_projects_on_id_creator_id_created_at ON CREATE INDEX index_services_on_inherit_from_id ON services USING btree (inherit_from_id); -CREATE INDEX index_services_on_project_id_and_type ON services USING btree (project_id, type); +CREATE UNIQUE INDEX index_services_on_project_id_and_type_unique ON services USING btree (project_id, type); CREATE INDEX index_services_on_template ON services USING btree (template); diff --git a/doc/api/projects.md b/doc/api/projects.md index f9a4b3ba55e..ae394d12a77 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1280,6 +1280,8 @@ POST /projects/:id/fork | `namespace_path` | string | **{dotted-circle}** No | The path of the namespace that the project is forked to. | | `namespace` | integer/string | **{dotted-circle}** No | _(Deprecated)_ The ID or path of the namespace that the project is forked to. | | `path` | string | **{dotted-circle}** No | The path assigned to the resultant project after forking. | +| `description` | string | **{dotted-circle}** No | The description assigned to the resultant project after forking. | +| `visibility` | string | **{dotted-circle}** No | The [visibility level](#project-visibility-level) assigned to the resultant project after forking. | ## List Forks of a project diff --git a/doc/user/admin_area/analytics/img/cohorts_v13_4.png b/doc/user/admin_area/analytics/img/cohorts_v13_4.png Binary files differdeleted file mode 100644 index 6f7dd5101f2..00000000000 --- a/doc/user/admin_area/analytics/img/cohorts_v13_4.png +++ /dev/null diff --git a/doc/user/admin_area/analytics/img/cohorts_v13_9.png b/doc/user/admin_area/analytics/img/cohorts_v13_9.png Binary files differnew file mode 100644 index 00000000000..3e141f24950 --- /dev/null +++ b/doc/user/admin_area/analytics/img/cohorts_v13_9.png diff --git a/doc/user/admin_area/analytics/user_cohorts.md b/doc/user/admin_area/analytics/user_cohorts.md index 7adc9ad59a5..2e8cc8f4200 100644 --- a/doc/user/admin_area/analytics/user_cohorts.md +++ b/doc/user/admin_area/analytics/user_cohorts.md @@ -9,14 +9,14 @@ info: To determine the technical writer assigned to the Stage/Group associated w As a benefit of having the [usage ping active](../settings/usage_statistics.md), you can analyze your users' GitLab activities over time. -To see user cohorts, go to **Admin Area > Analytics > Cohorts**. +To see user cohorts, go to **Admin Area > Overview > Users**. ## Overview How do you interpret the user cohorts table? Let's review an example with the following user cohorts: -![User cohort example](img/cohorts_v13_4.png) +![User cohort example](img/cohorts_v13_9.png) For the cohort of March 2020, three users were added to this server and have been active since this month. One month later (April 2020), two users are still diff --git a/doc/user/project/requirements/index.md b/doc/user/project/requirements/index.md index 54fa69c2b17..4e239431d02 100644 --- a/doc/user/project/requirements/index.md +++ b/doc/user/project/requirements/index.md @@ -260,7 +260,8 @@ For GitLab.com, it is set to 10 MB. ## Export requirements to a CSV file -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290813) in GitLab 13.8. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290813) in GitLab 13.8. +> - Revised CSV column headers [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299247) in GitLab 13.9. You can export GitLab requirements to a [CSV file](https://en.wikipedia.org/wiki/Comma-separated_values) sent to your default notification @@ -285,11 +286,24 @@ You can preview the exported CSV file in a spreadsheet editor, such as Microsoft OpenOffice Calc, or Google Sheets. <!-- vale gitlab.Spelling = YES --> -The exported CSV file contains the following columns: +The exported CSV file contains the following headers: -- Requirement ID -- Title -- Description -- Author Username -- Latest Test Report State -- Latest Test Report Created At (UTC) +- In GitLab 13.8: + + - Requirement ID + - Title + - Description + - Author Username + - Latest Test Report State + - Latest Test Report Created At (UTC) + +- In GitLab 13.9 and later: + + - Requirement ID + - Title + - Description + - Author + - Author Username + - Created At (UTC) + - State + - State Updated At (UTC) diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 2d09ad01757..fca68c3606b 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -295,6 +295,8 @@ module API optional :namespace_path, type: String, desc: 'The path of the namespace that the project will be forked into' optional :path, type: String, desc: 'The path that will be assigned to the fork' optional :name, type: String, desc: 'The name that will be assigned to the fork' + optional :description, type: String, desc: 'The description that will be assigned to the fork' + optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the fork' end post ':id/fork', feature_category: :source_code_management do Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42284') diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ac192421b3c..88010cebc97 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2203,6 +2203,9 @@ msgstr "" msgid "AdminUsers|Cannot unblock LDAP blocked users" msgstr "" +msgid "AdminUsers|Cohorts" +msgstr "" + msgid "AdminUsers|Deactivate" msgstr "" @@ -2341,6 +2344,9 @@ msgstr "" msgid "AdminUsers|User will not be able to login" msgstr "" +msgid "AdminUsers|Users" +msgstr "" + msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account" msgstr "" @@ -7063,9 +7069,6 @@ msgstr "" msgid "CodeOwner|Pattern" msgstr "" -msgid "Cohorts" -msgstr "" - msgid "Cohorts|Inactive users" msgstr "" @@ -27881,6 +27884,9 @@ msgstr "" msgid "Terraform|Actions" msgstr "" +msgid "Terraform|An error occurred while changing the state file" +msgstr "" + msgid "Terraform|An error occurred while loading your Terraform States" msgstr "" @@ -29937,6 +29943,9 @@ msgstr "" msgid "Toggle emoji award" msgstr "" +msgid "Toggle focus mode" +msgstr "" + msgid "Toggle navigation" msgstr "" diff --git a/package.json b/package.json index efc98ec4696..c42290837df 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,7 @@ }, "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.10.1", - "@gitlab/eslint-plugin": "6.0.0", + "@gitlab/eslint-plugin": "7.0.0", "@testing-library/dom": "^7.16.2", "@vue/test-utils": "1.1.2", "acorn": "^6.3.0", diff --git a/qa/qa/page/admin/overview/users/index.rb b/qa/qa/page/admin/overview/users/index.rb index fea95fdb84a..1885644d226 100644 --- a/qa/qa/page/admin/overview/users/index.rb +++ b/qa/qa/page/admin/overview/users/index.rb @@ -6,7 +6,7 @@ module QA module Overview module Users class Index < QA::Page::Base - view 'app/views/admin/users/index.html.haml' do + view 'app/views/admin/users/_users.html.haml' do element :user_search_field element :pending_approval_tab end diff --git a/qa/qa/page/component/issue_board/show.rb b/qa/qa/page/component/issue_board/show.rb index 15c1c25cbf9..4c1f03fbe86 100644 --- a/qa/qa/page/component/issue_board/show.rb +++ b/qa/qa/page/component/issue_board/show.rb @@ -39,7 +39,7 @@ module QA element :boards_list end - view 'app/assets/javascripts/boards/toggle_focus.js' do + view 'app/assets/javascripts/boards/components/toggle_focus.vue' do element :focus_mode_button end diff --git a/spec/controllers/admin/cohorts_controller_spec.rb b/spec/controllers/admin/cohorts_controller_spec.rb index 9eb2a713517..77a9c8eb223 100644 --- a/spec/controllers/admin/cohorts_controller_spec.rb +++ b/spec/controllers/admin/cohorts_controller_spec.rb @@ -3,37 +3,15 @@ require 'spec_helper' RSpec.describe Admin::CohortsController do - context 'as admin' do - let(:user) { create(:admin) } + let(:user) { create(:admin) } - before do - sign_in(user) - end - - it 'renders 200' do - get :index - - expect(response).to have_gitlab_http_status(:success) - end - - describe 'GET #index' do - it_behaves_like 'tracking unique visits', :index do - let(:target_id) { 'i_analytics_cohorts' } - end - end + before do + sign_in(user) end - context 'as normal user' do - let(:user) { create(:user) } - - before do - sign_in(user) - end - - it 'renders a 404' do - get :index + it 'redirects to Overview->Users' do + get :index - expect(response).to have_gitlab_http_status(:not_found) - end + expect(response).to redirect_to(admin_users_path(tab: 'cohorts')) end end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 20df72c25ea..6faec315eb6 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -29,6 +29,11 @@ RSpec.describe Admin::UsersController do expect(assigns(:users).first.association(:authorized_projects)).to be_loaded end + + it_behaves_like 'tracking unique visits', :index do + let(:target_id) { 'i_analytics_cohorts' } + let(:request_params) { { tab: 'cohorts' } } + end end describe 'GET :id' do diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index e8b30294cdd..7da3d403b53 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -209,6 +209,13 @@ RSpec.describe Projects::ForksController do } end + let(:created_project) do + Namespace + .find_by_id(params[:namespace_key]) + .projects + .find_by_path(params.fetch(:path, project.path)) + end + subject do post :create, params: params end @@ -260,6 +267,21 @@ RSpec.describe Projects::ForksController do expect(response).to redirect_to(namespace_project_import_path(user.namespace, project, continue: continue_params)) end end + + context 'custom attributes set' do + let(:params) { super().merge(path: 'something_custom', name: 'Something Custom', description: 'Something Custom', visibility: 'private') } + + it 'creates a project with custom values' do + subject + + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(namespace_project_import_path(user.namespace, params[:path])) + expect(created_project.path).to eq(params[:path]) + expect(created_project.name).to eq(params[:name]) + expect(created_project.description).to eq(params[:description]) + expect(created_project.visibility).to eq(params[:visibility]) + end + end end context 'when user is not signed in' do diff --git a/spec/features/admin/admin_cohorts_spec.rb b/spec/features/admin/admin_cohorts_spec.rb deleted file mode 100644 index 982a9333275..00000000000 --- a/spec/features/admin/admin_cohorts_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Cohorts page' do - before do - admin = create(:admin) - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - end - - context 'with usage ping enabled' do - it 'shows users count per month' do - stub_application_setting(usage_ping_enabled: true) - - create_list(:user, 2) - - visit admin_cohorts_path - - expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0") - end - end - - context 'with usage ping disabled' do - it 'shows empty state', :js do - stub_application_setting(usage_ping_enabled: false) - - visit admin_cohorts_path - - expect(page).to have_selector(".js-empty-state") - end - end -end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb new file mode 100644 index 00000000000..4fc60d17886 --- /dev/null +++ b/spec/features/admin/admin_users_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Admin::Users" do + let(:current_user) { create(:admin) } + + before do + sign_in(current_user) + gitlab_enable_admin_mode_sign_in(current_user) + end + + describe 'Tabs', :js do + let(:tabs_selector) { '.js-users-tabs' } + let(:active_tab_selector) { '.nav-link.active' } + + it 'does not add the tab param when the Users tab is selected' do + visit admin_users_path + + within tabs_selector do + click_link 'Users' + end + + expect(page).to have_current_path(admin_users_path) + end + + it 'adds the ?tab=cohorts param when the Cohorts tab is selected' do + visit admin_users_path + + within tabs_selector do + click_link 'Cohorts' + end + + expect(page).to have_current_path(admin_users_path(tab: 'cohorts')) + end + + it 'shows the cohorts tab when the tab param is set' do + visit admin_users_path(tab: 'cohorts') + + within tabs_selector do + expect(page).to have_selector active_tab_selector, text: 'Cohorts' + end + end + end + + describe 'Cohorts tab content' do + context 'with usage ping enabled' do + it 'shows users count per month' do + stub_application_setting(usage_ping_enabled: true) + + create_list(:user, 2) + + visit admin_users_path(tab: 'cohorts') + + expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0") + end + end + + context 'with usage ping disabled' do + it 'shows empty state', :js do + stub_application_setting(usage_ping_enabled: false) + + visit admin_users_path(tab: 'cohorts') + + expect(page).to have_selector(".js-empty-state") + expect(page).to have_content("Activate user activity analysis") + end + end + end +end diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js index 171d54c8f4f..20b60bd8640 100644 --- a/spec/frontend/admin/users/index_spec.js +++ b/spec/frontend/admin/users/index_spec.js @@ -1,5 +1,5 @@ import { createWrapper } from '@vue/test-utils'; -import initAdminUsers from '~/admin/users'; +import { initAdminUsersApp } from '~/admin/users'; import AdminUsersApp from '~/admin/users/components/app.vue'; import { users, paths } from './mock_data'; @@ -16,7 +16,7 @@ describe('initAdminUsersApp', () => { document.body.appendChild(el); - wrapper = createWrapper(initAdminUsers(el)); + wrapper = createWrapper(initAdminUsersApp(el)); }); afterEach(() => { diff --git a/spec/frontend/filtered_search/recent_searches_root_spec.js b/spec/frontend/filtered_search/recent_searches_root_spec.js index 6bb9e68d591..fa3267c98a1 100644 --- a/spec/frontend/filtered_search/recent_searches_root_spec.js +++ b/spec/frontend/filtered_search/recent_searches_root_spec.js @@ -1,32 +1,51 @@ -import Vue from 'vue'; +import { setHTMLFixture } from 'helpers/fixtures'; import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; -jest.mock('vue'); +const containerId = 'test-container'; +const dropdownElementId = 'test-dropdown-element'; describe('RecentSearchesRoot', () => { describe('render', () => { - let recentSearchesRoot; - let data; - let template; + let recentSearchesRootMockInstance; + let vm; + let containerEl; beforeEach(() => { - recentSearchesRoot = { + setHTMLFixture(` + <div id="${containerId}"> + <div id="${dropdownElementId}"></div> + </div> + `); + + containerEl = document.getElementById(containerId); + + recentSearchesRootMockInstance = { store: { - state: 'state', + state: { + recentSearches: ['foo', 'bar', 'qux'], + isLocalStorageAvailable: true, + allowedKeys: ['test'], + }, }, + wrapperElement: document.getElementById(dropdownElementId), }; - Vue.mockImplementation((options) => { - ({ data, template } = options); - }); + RecentSearchesRoot.prototype.render.call(recentSearchesRootMockInstance); + vm = recentSearchesRootMockInstance.vm; - RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + return vm.$nextTick(); }); - it('should instantiate Vue', () => { - expect(Vue).toHaveBeenCalled(); - expect(data()).toBe(recentSearchesRoot.store.state); - expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + afterEach(() => { + vm.$destroy(); + }); + + it('should render the recent searches', () => { + const { recentSearches } = recentSearchesRootMockInstance.store.state; + + recentSearches.forEach((recentSearch) => { + expect(containerEl.textContent).toContain(recentSearch); + }); }); }); }); diff --git a/spec/frontend/onboarding_issues/index_spec.js b/spec/frontend/onboarding_issues/index_spec.js index d476ba1cf5a..13093901e16 100644 --- a/spec/frontend/onboarding_issues/index_spec.js +++ b/spec/frontend/onboarding_issues/index_spec.js @@ -118,7 +118,7 @@ describe('Onboarding Issues Popovers', () => { describe('when dismissing the popover', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); - document.querySelector('.learn-gitlab.popover .close').click(); + document.querySelector('.learn-gitlab.popover .js-close-learn-gitlab').click(); }); it('deletes the cookie', () => { diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index 3f5df8a96f8..1f9bede2e6e 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -1,6 +1,7 @@ import { GlDropdown, GlModal, GlSprintf } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import VueApollo from 'vue-apollo'; import StateActions from '~/terraform/components/states_table_actions.vue'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; @@ -14,6 +15,7 @@ describe('StatesTableActions', () => { let lockResponse; let removeResponse; let unlockResponse; + let updateStateResponse; let wrapper; const defaultProps = { @@ -26,7 +28,9 @@ describe('StatesTableActions', () => { }; const createMockApolloProvider = () => { - lockResponse = jest.fn().mockResolvedValue({ data: { terraformStateLock: { errors: [] } } }); + lockResponse = jest + .fn() + .mockResolvedValue({ data: { terraformStateLock: { errors: ['There was an error'] } } }); removeResponse = jest .fn() @@ -36,11 +40,20 @@ describe('StatesTableActions', () => { .fn() .mockResolvedValue({ data: { terraformStateUnlock: { errors: [] } } }); - return createMockApollo([ - [lockStateMutation, lockResponse], - [removeStateMutation, removeResponse], - [unlockStateMutation, unlockResponse], - ]); + updateStateResponse = jest.fn().mockResolvedValue({}); + + return createMockApollo( + [ + [lockStateMutation, lockResponse], + [removeStateMutation, removeResponse], + [unlockStateMutation, unlockResponse], + ], + { + Mutation: { + addDataToTerraformState: updateStateResponse, + }, + }, + ); }; const createComponent = (propsData = defaultProps) => { @@ -56,6 +69,7 @@ describe('StatesTableActions', () => { return wrapper.vm.$nextTick(); }; + const findActionsDropdown = () => wrapper.find(GlDropdown); const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]'); const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]'); const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]'); @@ -70,9 +84,25 @@ describe('StatesTableActions', () => { lockResponse = null; removeResponse = null; unlockResponse = null; + updateStateResponse = null; wrapper.destroy(); }); + describe('when the state is loading', () => { + beforeEach(() => { + return createComponent({ + state: { + ...defaultProps.state, + loadingActions: true, + }, + }); + }); + + it('disables the actions dropdown', () => { + expect(findActionsDropdown().props('disabled')).toBe(true); + }); + }); + describe('download button', () => { it('displays a download button', () => { expect(findDownloadBtn().text()).toBe('Download JSON'); @@ -104,7 +134,8 @@ describe('StatesTableActions', () => { describe('when clicking the unlock button', () => { beforeEach(() => { findUnlockBtn().vm.$emit('click'); - return wrapper.vm.$nextTick(); + + return waitForPromises(); }); it('calls the unlock mutation', () => { @@ -137,7 +168,8 @@ describe('StatesTableActions', () => { describe('when clicking the lock button', () => { beforeEach(() => { findLockBtn().vm.$emit('click'); - return wrapper.vm.$nextTick(); + + return waitForPromises(); }); it('calls the lock mutation', () => { @@ -145,6 +177,42 @@ describe('StatesTableActions', () => { stateID: unlockedProps.state.id, }); }); + + it('calls mutations to set loading and errors', () => { + // loading update + expect(updateStateResponse).toHaveBeenNthCalledWith( + 1, + {}, + { + terraformState: { + ...unlockedProps.state, + _showDetails: false, + errorMessages: [], + loadingActions: true, + }, + }, + // Apollo fields + expect.any(Object), + expect.any(Object), + ); + + // final update + expect(updateStateResponse).toHaveBeenNthCalledWith( + 2, + {}, + { + terraformState: { + ...unlockedProps.state, + _showDetails: true, + errorMessages: ['There was an error'], + loadingActions: false, + }, + }, + // Apollo fields + expect.any(Object), + expect.any(Object), + ); + }); }); }); @@ -156,7 +224,8 @@ describe('StatesTableActions', () => { describe('when clicking the remove button', () => { beforeEach(() => { findRemoveBtn().vm.$emit('click'); - return wrapper.vm.$nextTick(); + + return waitForPromises(); }); it('displays a remove modal', () => { diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js index f2b7bc00e5b..ba676c9741e 100644 --- a/spec/frontend/terraform/components/states_table_spec.js +++ b/spec/frontend/terraform/components/states_table_spec.js @@ -11,6 +11,8 @@ describe('StatesTable', () => { const defaultProps = { states: [ { + _showDetails: true, + errorMessages: ['State 1 has errored'], name: 'state-1', lockedAt: '2020-10-13T00:00:00Z', lockedByUser: { @@ -20,6 +22,8 @@ describe('StatesTable', () => { latestVersion: null, }, { + _showDetails: false, + errorMessages: [], name: 'state-2', lockedAt: null, lockedByUser: null, @@ -27,6 +31,8 @@ describe('StatesTable', () => { latestVersion: null, }, { + _showDetails: false, + errorMessages: [], name: 'state-3', lockedAt: '2020-10-10T00:00:00Z', lockedByUser: { @@ -54,6 +60,8 @@ describe('StatesTable', () => { }, }, { + _showDetails: true, + errorMessages: ['State 4 has errored'], name: 'state-4', lockedAt: '2020-10-10T00:00:00Z', lockedByUser: null, @@ -154,6 +162,17 @@ describe('StatesTable', () => { expect(findActions().length).toEqual(0); }); + it.each` + errorMessage | lineNumber + ${defaultProps.states[0].errorMessages[0]} | ${0} + ${defaultProps.states[3].errorMessages[0]} | ${1} + `('displays table error message "$errorMessage"', ({ errorMessage, lineNumber }) => { + const states = wrapper.findAll('[data-testid="terraform-states-table-error"]'); + const state = states.at(lineNumber); + + expect(state.text()).toBe(errorMessage); + }); + describe('when user is a terraform administrator', () => { beforeEach(() => { return createComponent({ diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js index fb56a7135a3..043fde3be17 100644 --- a/spec/frontend/terraform/components/terraform_list_spec.js +++ b/spec/frontend/terraform/components/terraform_list_spec.js @@ -27,6 +27,15 @@ describe('TerraformList', () => { }, }; + // Override @client _showDetails + getStatesQuery.getStates.definitions[1].selectionSet.selections[0].directives = []; + + // Override @client errorMessages + getStatesQuery.getStates.definitions[1].selectionSet.selections[1].directives = []; + + // Override @client loadingActions + getStatesQuery.getStates.definitions[1].selectionSet.selections[2].directives = []; + const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse); const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]); @@ -52,20 +61,26 @@ describe('TerraformList', () => { describe('when there is a list of terraform states', () => { const states = [ { + _showDetails: false, + errorMessages: [], id: 'gid://gitlab/Terraform::State/1', name: 'state-1', + latestVersion: null, + loadingActions: false, lockedAt: null, - updatedAt: null, lockedByUser: null, - latestVersion: null, + updatedAt: null, }, { + _showDetails: false, + errorMessages: [], id: 'gid://gitlab/Terraform::State/2', name: 'state-2', + latestVersion: null, + loadingActions: false, lockedAt: null, - updatedAt: null, lockedByUser: null, - latestVersion: null, + updatedAt: null, }, ]; diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 4acd0eea448..a2589ef3401 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -3328,8 +3328,8 @@ RSpec.describe API::Projects do expect(json_response['message']['path']).to eq(['has already been taken']) end - it 'accepts a name for the target project' do - post api("/projects/#{project.id}/fork", user2), params: { name: 'My Random Project' } + it 'accepts custom parameters for the target project' do + post api("/projects/#{project.id}/fork", user2), params: { name: 'My Random Project', description: 'A description', visibility: 'private' } expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq('My Random Project') @@ -3337,6 +3337,8 @@ RSpec.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['description']).to eq('A description') + expect(json_response['visibility']).to eq('private') expect(json_response['import_status']).to eq('scheduled') expect(json_response).to include("import_error") end @@ -3368,6 +3370,13 @@ RSpec.describe API::Projects do expect(json_response['message']['path']).to eq(['has already been taken']) expect(json_response['message']['name']).to eq(['has already been taken']) end + + it 'fails to fork with an unknown visibility level' do + post api("/projects/#{project.id}/fork", user2), params: { visibility: 'something' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('visibility does not have a valid value') + end end context 'when unauthenticated' do diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index 9374df0c4a2..8c36d7d4668 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -141,13 +141,6 @@ RSpec.describe Admin::DevOpsReportController, "routing" do end end -# admin_cohorts GET /admin/cohorts(.:format) admin/cohorst#index -RSpec.describe Admin::CohortsController, "routing" do - it "to #index" do - expect(get("/admin/cohorts")).to route_to('admin/cohorts#index') - end -end - RSpec.describe Admin::GroupsController, "routing" do let(:name) { 'complex.group-namegit' } diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index a11f16573f5..df02f8ea15d 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -323,6 +323,50 @@ RSpec.describe Projects::ForkService do end end end + + describe 'fork with optional attributes' do + let(:public_project) { create(:project, :public) } + + it 'sets optional attributes to specified values' do + forked_project = fork_project( + public_project, + nil, + namespace: public_project.namespace, + path: 'forked', + name: 'My Fork', + description: 'Description', + visibility: 'internal', + using_service: true + ) + + expect(forked_project.path).to eq('forked') + expect(forked_project.name).to eq('My Fork') + expect(forked_project.description).to eq('Description') + expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) + end + + it 'sets visibility level to private if an unknown visibility is requested' do + forked_project = fork_project(public_project, nil, using_service: true, visibility: 'unknown') + + expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + + it 'sets visibility level to project visibility level if requested visibility is greater' do + private_project = create(:project, :private) + + forked_project = fork_project(private_project, nil, using_service: true, visibility: 'public') + + expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + + it 'sets visibility level to target namespace visibility level if requested visibility is greater' do + private_group = create(:group, :private) + + forked_project = fork_project(public_project, nil, namespace: private_group, using_service: true, visibility: 'public') + + expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + end end context 'when a project is already forked' do diff --git a/yarn.lock b/yarn.lock index 4824b030767..0f3504345d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -845,10 +845,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/at.js/-/at.js-1.5.7.tgz#1ee6f838cc4410a1d797770934df91d90df8179e" integrity sha512-c6ySRK/Ma7lxwpIVbSAF3P+xiTLrNTGTLRx4/pHK111AdFxwgUwrYF6aVZFXvmG65jHOJHoa0eQQ21RW6rm0Rg== -"@gitlab/eslint-plugin@6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-6.0.0.tgz#deb18f63808af1cb1cc117a92558f07edb1e2256" - integrity sha512-3TihEG0EzbGtc6wxZLANZN1ge2tnAv0qU8w6smUACmPhqFj0/DrCq9V6QKPqAHk/Yn3hrfGk5nznAzzuMEgwDQ== +"@gitlab/eslint-plugin@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-7.0.0.tgz#3c46d88dde2f7aa0be2a7df5af8e593006becea9" + integrity sha512-XqISaNqQwJ12jTanESvAFNVAniqFN/UFKj068ESiNumlsxnQA36V945wZ6LnwI7WgSCGQCUfHi9MEgyjUvuvdg== dependencies: babel-eslint "^10.0.3" eslint-config-airbnb-base "^14.0.0" |