Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/issue_templates/Experimentation.md (renamed from .gitlab/issue_templates/Adoption Engineering.md)15
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue5
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js38
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue226
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue11
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue19
-rw-r--r--app/assets/javascripts/boards/graphql/board_labels.query.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql20
-rw-r--r--app/assets/javascripts/boards/stores/actions.js25
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js12
-rw-r--r--app/assets/javascripts/boards/stores/state.js8
-rw-r--r--app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue (renamed from app/assets/javascripts/issues_list/components/jira_issues_list_root.vue)6
-rw-r--r--app/assets/javascripts/issues_list/index.js6
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/formatter_factory.js70
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js342
-rw-r--r--app/assets/javascripts/locale/index.js20
-rw-r--r--app/assets/javascripts/tooltips/components/tooltips.vue6
-rw-r--r--app/assets/javascripts/tooltips/index.js1
-rw-r--r--app/controllers/repositories/git_http_controller.rb6
-rw-r--r--app/models/onboarding_progress.rb7
-rw-r--r--app/services/onboarding_progress_service.rb18
-rw-r--r--app/views/projects/issues/index.html.haml2
-rw-r--r--app/workers/all_queues.yml8
-rw-r--r--app/workers/namespaces/onboarding_progress_worker.rb20
-rw-r--r--changelogs/unreleased/321514-fix-clipboard-buttons.yml5
-rw-r--r--changelogs/unreleased/321745-remove-temporary-index-idx_on_issues_where_service_desk_reply_to_i.yml5
-rw-r--r--changelogs/unreleased/cmiskell-upgrade-sidekiq-reliable-fetch.yml6
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--data/whats_new/202102180001_13_09.yml2
-rw-r--r--db/fixtures/development/30_composer_packages.rb8
-rw-r--r--db/migrate/20210216223335_remove_index_on_issues_where_service_desk_reply_to_is_not_null.rb21
-rw-r--r--db/schema_migrations/202102162233351
-rw-r--r--db/structure.sql2
-rw-r--r--doc/development/fe_guide/style/scss.md2
-rw-r--r--doc/development/feature_flags/development.md3
-rw-r--r--doc/development/usage_ping.md2
-rw-r--r--doc/security/two_factor_authentication.md11
-rw-r--r--doc/user/admin_area/settings/account_and_limit_settings.md2
-rw-r--r--lib/gitlab/ci/variables/collection.rb10
-rw-r--r--lib/gitlab/ci/variables/collection/sort.rb (renamed from lib/gitlab/ci/variables/collection/sorted.rb)11
-rw-r--r--locale/gitlab.pot18
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/repository/push_over_ssh_spec.rb3
-rw-r--r--spec/controllers/repositories/git_http_controller_spec.rb19
-rw-r--r--spec/features/boards/boards_spec.rb18
-rw-r--r--spec/features/boards/user_adds_lists_to_board_spec.rb92
-rw-r--r--spec/frontend/boards/components/board_add_new_column_spec.js151
-rw-r--r--spec/frontend/boards/stores/actions_spec.js14
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js20
-rw-r--r--spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js (renamed from spec/frontend/issues_list/components/jira_issues_list_root_spec.js)6
-rw-r--r--spec/frontend/lib/utils/unit_format/index_spec.js304
-rw-r--r--spec/frontend/locale/index_spec.js66
-rw-r--r--spec/frontend/tooltips/components/tooltips_spec.js10
-rw-r--r--spec/lib/gitlab/ci/variables/collection/sort_spec.rb (renamed from spec/lib/gitlab/ci/variables/collection/sorted_spec.rb)16
-rw-r--r--spec/lib/gitlab/ci/variables/collection_spec.rb45
-rw-r--r--spec/lib/gitlab/database/bulk_update_spec.rb36
-rw-r--r--spec/models/onboarding_progress_spec.rb24
-rw-r--r--spec/services/onboarding_progress_service_spec.rb41
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb2
-rw-r--r--spec/workers/namespaces/onboarding_progress_worker_spec.rb22
63 files changed, 1433 insertions, 472 deletions
diff --git a/.gitlab/issue_templates/Adoption Engineering.md b/.gitlab/issue_templates/Experimentation.md
index b4632345e3a..f84c4305c2c 100644
--- a/.gitlab/issue_templates/Adoption Engineering.md
+++ b/.gitlab/issue_templates/Experimentation.md
@@ -1,7 +1,11 @@
+<!-- Title suggestion: Experiment: [description] -->
+
+# Experiment Summary
+<!-- Quick rundown of what is being done -->
+
# Design
<!-- This should include the contexts that determine the reproducibility (stickiness) of an experiment. This means that if you want the same behavior for a user, the context would be user, or if you want all users when viewing a specific project, the context would be the project being viewed, etc. -->
-
# Rollout strategy
<!-- This is currently called A/B test, which isn't accurate for multi-variants. Let's call this rollout strategy. It should outline the percentages for variants and if there's more than one step to this, each of those steps and the timing for those steps (e.g. 30 days after initial rollout). -->
@@ -11,4 +15,11 @@
# Segmentation
<!-- Rules for always saying context with these criteria always get this variant. For instance, if you want to always give groups less than N number of days old the experiment experience, they are specified here. This is different from the exclusion rules above. -->
-# Tracking
+# Tracking Details
+
+- [json schema](https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/0-3-0) used in `gitlab-experiment` tracking.
+- see [taxonomy](https://docs.gitlab.com/ee/development/snowplow.html#structured-event-taxonomy) for a guide.
+
+| activity | category | action | label | context | property | value |
+| -------- | -------- | ------ | ----- | ------- | -------- | ----- |
+| | | | | json schema | | |
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 538237b3638..5de63def412 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-4139a25fddd1f6b99cc80aa89bd0ebc6594f5f4a
+8c3a416f8fbd8e1187e8d7869bd77f933e91be1b
diff --git a/Gemfile b/Gemfile
index 5e8e54332ac..59a031be2d1 100644
--- a/Gemfile
+++ b/Gemfile
@@ -196,7 +196,7 @@ gem 'acts-as-taggable-on', '~> 7.0'
gem 'sidekiq', '~> 5.2.7'
gem 'sidekiq-cron', '~> 1.0'
gem 'redis-namespace', '~> 1.7.0'
-gem 'gitlab-sidekiq-fetcher', '0.5.3', require: 'sidekiq-reliable-fetch'
+gem 'gitlab-sidekiq-fetcher', '0.5.4', require: 'sidekiq-reliable-fetch'
# Cron Parser
gem 'fugit', '~> 1.2.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 1b3da5628b0..6a0b6e89c24 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -451,7 +451,7 @@ GEM
gitlab-pry-byebug (3.9.0)
byebug (~> 11.0)
pry (~> 0.13.0)
- gitlab-sidekiq-fetcher (0.5.3)
+ gitlab-sidekiq-fetcher (0.5.4)
sidekiq (~> 5)
gitlab-styles (6.0.0)
rubocop (~> 0.91.1)
@@ -1379,7 +1379,7 @@ DEPENDENCIES
gitlab-markup (~> 1.7.1)
gitlab-net-dns (~> 0.9.1)
gitlab-pry-byebug
- gitlab-sidekiq-fetcher (= 0.5.3)
+ gitlab-sidekiq-fetcher (= 0.5.4)
gitlab-styles (~> 6.0.0)
gitlab_chronic_duration (~> 0.10.6.2)
gitlab_omniauth-ldap (~> 2.1.1)
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 9a0a4f61a74..0630cca93ae 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -2,7 +2,7 @@
import * as Sentry from '@sentry/browser';
import MetricCard from '~/analytics/shared/components/metric_card.vue';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
+import { number } from '~/lib/utils/unit_format';
import { s__ } from '~/locale';
import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql';
@@ -24,8 +24,7 @@ export default {
update(data) {
return Object.entries(data).map(([key, obj]) => {
const label = this.$options.i18n.labels[key];
- const formatter = getFormatter(SUPPORTED_FORMATS.number);
- const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null;
+ const value = obj.nodes?.length ? number(obj.nodes[0].count, defaultPrecision) : null;
return {
key,
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index a31bcc2cb41..de248340738 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,31 +1,27 @@
import Clipboard from 'clipboard';
import $ from 'jquery';
import { sprintf, __ } from '~/locale';
-import { fixTitle, show } from '~/tooltips';
+import { fixTitle, add, show, once } from '~/tooltips';
function showTooltip(target, title) {
- const { originalTitle } = target.dataset;
- const hideTooltip = () => {
- target.removeEventListener('mouseout', hideTooltip);
- setTimeout(() => {
+ const { title: originalTitle } = target.dataset;
+
+ once('hidden', (tooltip) => {
+ if (tooltip.target === target) {
target.setAttribute('title', originalTitle);
fixTitle(target);
- }, 100);
- };
+ }
+ });
target.setAttribute('title', title);
-
fixTitle(target);
show(target);
-
- target.addEventListener('mouseout', hideTooltip);
+ setTimeout(() => target.blur(), 1000);
}
function genericSuccess(e) {
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
- $(e.trigger).blur();
-
showTooltip(e.trigger, __('Copied'));
}
@@ -88,24 +84,8 @@ export default function initCopyToClipboard() {
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
- const $btnElement = $(btnElement);
-
// Ensure the button has already been tooltip'd.
- // If the use hasn't yet interacted (i.e. hovered or clicked)
- // with the button, Bootstrap hasn't yet initialized
- // the tooltip, and its `data-original-title` will be `undefined`.
- // This value is used in the functions above.
- $btnElement.tooltip();
- btnElement.dispatchEvent(new MouseEvent('mouseover'));
+ add([btnElement], { show: true });
btnElement.click();
-
- // Manually trigger the necessary events to hide the
- // button's tooltip and allow the button to perform its
- // tooltip cleanup (updating the title from "Copied" back
- // to its original title, "Copy branch name").
- setTimeout(() => {
- btnElement.dispatchEvent(new MouseEvent('mouseout'));
- $btnElement.tooltip('hide');
- }, 2000);
}
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
new file mode 100644
index 00000000000..1a846918321
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -0,0 +1,226 @@
+<script>
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlLabel,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import boardsStore from '../stores/boards_store';
+
+export default {
+ i18n: {
+ add: __('Add'),
+ cancel: __('Cancel'),
+ formDescription: __('A label list displays all issues with the selected label.'),
+ newLabelList: __('New label list'),
+ noLabelSelected: __('No label selected'),
+ searchPlaceholder: __('Search labels'),
+ selectLabel: __('Select label'),
+ selected: __('Selected'),
+ },
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlLabel,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['scopedLabelsAvailable'],
+ data() {
+ return {
+ searchTerm: '',
+ selectedLabelId: null,
+ };
+ },
+ computed: {
+ ...mapState(['labels', 'labelsLoading']),
+ ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
+ selectedLabel() {
+ return this.labels.find(({ id }) => id === this.selectedLabelId);
+ },
+ },
+ created() {
+ this.filterLabels();
+ },
+ methods: {
+ ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
+ getListByLabel(label) {
+ if (this.shouldUseGraphQL) {
+ return this.getListByLabelId(label);
+ }
+ return boardsStore.findListByLabelId(label.id);
+ },
+ columnExists(label) {
+ return Boolean(this.getListByLabel(label));
+ },
+ highlight(listId) {
+ if (this.shouldUseGraphQL) {
+ this.highlightList(listId);
+ } else {
+ const list = boardsStore.state.lists.find(({ id }) => id === listId);
+ list.highlighted = true;
+ setTimeout(() => {
+ list.highlighted = false;
+ }, 2000);
+ }
+ },
+ addList() {
+ if (!this.selectedLabelId) {
+ return;
+ }
+
+ const label = this.selectedLabel;
+
+ if (!label) {
+ return;
+ }
+
+ this.setAddColumnFormVisibility(false);
+
+ if (this.columnExists({ id: this.selectedLabelId })) {
+ const listId = this.getListByLabel(label).id;
+ this.highlight(listId);
+ return;
+ }
+
+ if (this.shouldUseGraphQL) {
+ this.createList({ labelId: this.selectedLabelId });
+ } else {
+ boardsStore.new({
+ title: label.title,
+ position: boardsStore.state.lists.length - 2,
+ list_type: 'label',
+ label: {
+ id: label.id,
+ title: label.title,
+ color: label.color,
+ },
+ });
+
+ this.highlight(boardsStore.findListByLabelId(label.id).id);
+ }
+ },
+
+ filterLabels() {
+ this.fetchLabels(this.searchTerm);
+ },
+
+ showScopedLabels(label) {
+ return this.scopedLabelsAvailable && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
+ data-qa-selector="board_add_new_list"
+ >
+ <div
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
+ >
+ <h3
+ class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ data-testid="board-add-column-form-title"
+ >
+ {{ $options.i18n.newLabelList }}
+ </h3>
+
+ <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
+ <!-- selectbox is here in EE -->
+
+ <p class="gl-m-5">{{ $options.i18n.formDescription }}</p>
+
+ <div class="gl-px-5 gl-pb-4">
+ <label class="gl-mb-2">{{ $options.i18n.selected }}</label>
+ <div>
+ <gl-label
+ v-if="selectedLabel"
+ v-gl-tooltip
+ :title="selectedLabel.title"
+ :description="selectedLabel.description"
+ :background-color="selectedLabel.color"
+ :scoped="showScopedLabels(selectedLabel)"
+ />
+ <div v-else class="gl-text-gray-500">{{ $options.i18n.noLabelSelected }}</div>
+ </div>
+ </div>
+
+ <gl-form-group
+ class="gl-mx-5 gl-mb-3"
+ :label="$options.i18n.selectLabel"
+ label-for="board-available-labels"
+ >
+ <gl-search-box-by-type
+ id="board-available-labels"
+ v-model.trim="searchTerm"
+ debounce="250"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @input="filterLabels"
+ />
+ </gl-form-group>
+
+ <div v-if="labelsLoading" class="gl-m-5">
+ <gl-skeleton-loader :width="500" :height="172">
+ <rect width="480" height="20" x="10" y="15" rx="4" />
+ <rect width="380" height="20" x="10" y="50" rx="4" />
+ <rect width="430" height="20" x="10" y="85" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+
+ <gl-form-radio-group
+ v-else
+ v-model="selectedLabelId"
+ class="gl-overflow-y-auto gl-px-5 gl-pt-3"
+ >
+ <label
+ v-for="label in labels"
+ :key="label.id"
+ class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
+ >
+ <gl-form-radio :value="label.id" class="gl-mb-0 gl-mr-3" />
+ <span
+ class="dropdown-label-box gl-top-0"
+ :style="{
+ backgroundColor: label.color,
+ }"
+ ></span>
+ <span>{{ label.title }}</span>
+ </label>
+ </gl-form-radio-group>
+ </div>
+
+ <div
+ class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10"
+ >
+ <gl-button
+ data-testid="cancelAddNewColumn"
+ class="gl-ml-auto gl-mr-3"
+ @click="setAddColumnFormVisibility(false)"
+ >{{ $options.i18n.cancel }}</gl-button
+ >
+ <gl-button
+ data-testid="addNewColumnButton"
+ :disabled="!selectedLabelId"
+ variant="success"
+ class="gl-mr-4"
+ @click="addList"
+ >{{ $options.i18n.add }}</gl-button
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 95a90d7ab11..c9e667d526c 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -46,11 +46,20 @@ export default {
watch: {
filterParams: {
handler() {
- this.fetchItemsForList({ listId: this.list.id });
+ if (this.list.id) {
+ this.fetchItemsForList({ listId: this.list.id });
+ }
},
deep: true,
immediate: true,
},
+ 'list.id': {
+ handler(id) {
+ if (id) {
+ this.fetchItemsForList({ listId: this.list.id });
+ }
+ },
+ },
highlighted: {
handler(highlighted) {
if (highlighted) {
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 6b7e04df7a4..4233f6bffe7 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -6,11 +6,13 @@ import { mapState, mapGetters, mapActions } from 'vuex';
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import BoardAddNewColumn from './board_add_new_column.vue';
import BoardColumn from './board_column.vue';
import BoardColumnDeprecated from './board_column_deprecated.vue';
export default {
components: {
+ BoardAddNewColumn,
BoardColumn:
gon.features?.graphqlBoardLists || gon.features?.epicBoards
? BoardColumn
@@ -36,8 +38,11 @@ export default {
},
},
computed: {
- ...mapState(['boardLists', 'error', 'isEpicBoard']),
+ ...mapState(['boardLists', 'error', 'addColumnForm', 'isEpicBoard']),
...mapGetters(['isSwimlanesOn']),
+ addColumnFormVisible() {
+ return this.addColumnForm?.visible;
+ },
boardListsToUse() {
return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard
? sortBy([...Object.values(this.boardLists)], 'position')
@@ -65,6 +70,10 @@ export default {
},
methods: {
...mapActions(['moveList']),
+ afterFormEnters() {
+ const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
+ el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
+ },
handleDragOnStart() {
sortableStart();
},
@@ -103,13 +112,17 @@ export default {
@end="handleDragOnEnd"
>
<board-column
- v-for="list in boardListsToUse"
- :key="list.id"
+ v-for="(list, index) in boardListsToUse"
+ :key="index"
ref="board"
:can-admin-list="canAdminList"
:list="list"
:disabled="disabled"
/>
+
+ <transition name="slide" @after-enter="afterFormEnters">
+ <board-add-new-column v-if="addColumnFormVisible" />
+ </transition>
</component>
<epics-swimlanes
diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
index 42a94419a97..b19a24e8808 100644
--- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
@@ -7,14 +7,14 @@ query BoardLabels(
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
- labels(searchTerm: $searchTerm) {
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) {
nodes {
...Label
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
- labels(searchTerm: $searchTerm) {
+ labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
nodes {
...Label
}
diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
index f78a21baa7f..3eb23f62940 100644
--- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
@@ -1,21 +1,7 @@
-#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
+#import "./board_list.fragment.graphql"
-mutation CreateBoardList(
- $boardId: BoardID!
- $backlog: Boolean
- $labelId: LabelID
- $milestoneId: MilestoneID
- $assigneeId: UserID
-) {
- boardListCreate(
- input: {
- boardId: $boardId
- backlog: $backlog
- labelId: $labelId
- milestoneId: $milestoneId
- assigneeId: $assigneeId
- }
- ) {
+mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) {
+ boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) {
list {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index b8d84899782..a48c55969b9 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,4 +1,5 @@
import { pick } from 'lodash';
+import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import {
BoardType,
@@ -21,7 +22,6 @@ import {
transformNotFilters,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
-import createBoardListMutation from '../graphql/board_list_create.mutation.graphql';
import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
@@ -161,14 +161,17 @@ export default {
dispatch('highlightList', list.id);
}
})
- .catch(() => commit(types.CREATE_LIST_FAILURE));
+ .catch((e) => {
+ commit(types.CREATE_LIST_FAILURE);
+ throw e;
+ });
},
addList: ({ commit }, list) => {
commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list));
},
- fetchLabels: ({ state, commit }, searchTerm) => {
+ fetchLabels: ({ state, commit, getters }, searchTerm) => {
const { fullPath, boardType } = state;
const variables = {
@@ -178,15 +181,29 @@ export default {
isProject: boardType === BoardType.project,
};
+ commit(types.RECEIVE_LABELS_REQUEST);
+
return gqlClient
.query({
query: boardLabelsQuery,
variables,
})
.then(({ data }) => {
- const labels = data[boardType]?.labels.nodes;
+ let labels = data[boardType]?.labels.nodes;
+
+ if (!getters.shouldUseGraphQL) {
+ labels = labels.map((label) => ({
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ }));
+ }
+
commit(types.RECEIVE_LABELS_SUCCESS, labels);
return labels;
+ })
+ .catch((e) => {
+ commit(types.RECEIVE_LABELS_FAILURE);
+ throw e;
});
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 4b43cca9675..2a748d122d9 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -2,7 +2,9 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
+export const RECEIVE_LABELS_REQUEST = 'RECEIVE_LABELS_REQUEST';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 8246ed8eb09..5b8bf5b5357 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -64,8 +64,18 @@ export default {
state.error = s__('Boards|An error occurred while creating the list. Please try again.');
},
+ [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => {
+ state.labelsLoading = true;
+ },
+
[mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => {
state.labels = labels;
+ state.labelsLoading = false;
+ },
+
+ [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => {
+ state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.');
+ state.labelsLoading = false;
},
[mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => {
@@ -270,7 +280,7 @@ export default {
},
[mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => {
- state.addColumnFormVisible = visible;
+ Vue.set(state.addColumnForm, 'visible', visible);
},
[mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => {
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 85d92589d30..525a6bae6f1 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,4 +1,4 @@
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, ListType } from '~/boards/constants';
export default () => ({
boardType: null,
@@ -15,6 +15,7 @@ export default () => ({
boardItems: {},
filterParams: {},
boardConfig: {},
+ labelsLoading: false,
labels: [],
highlightedLists: [],
selectedBoardItems: [],
@@ -26,7 +27,10 @@ export default () => ({
},
selectedProject: {},
error: undefined,
- addColumnFormVisible: false,
+ addColumnForm: {
+ visible: false,
+ columnType: ListType.label,
+ },
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,
});
diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
index 7396cfe27b3..ba0ca57523a 100644
--- a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue
+++ b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
@@ -11,7 +11,7 @@ import { n__ } from '~/locale';
import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
export default {
- name: 'JiraIssuesList',
+ name: 'JiraIssuesImportStatus',
components: {
GlAlert,
GlLabel,
@@ -89,13 +89,13 @@ export default {
</script>
<template>
- <div class="issuable-list-root">
+ <div class="gl-my-5">
<gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
{{ __('Import in progress. Refresh page to see newly added issues.') }}
</gl-alert>
<gl-alert
- v-if="jiraImport.shouldShowFinishedAlert"
+ v-else-if="jiraImport.shouldShowFinishedAlert"
variant="success"
@dismiss="hideFinishedAlert"
>
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 5c3910955bc..7873f46581d 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -3,10 +3,10 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
-import JiraIssuesListRoot from './components/jira_issues_list_root.vue';
+import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
function mountJiraIssuesListApp() {
- const el = document.querySelector('.js-projects-issues-root');
+ const el = document.querySelector('.js-jira-issues-import-status');
if (!el) {
return false;
@@ -23,7 +23,7 @@ function mountJiraIssuesListApp() {
el,
apolloProvider,
render(createComponent) {
- return createComponent(JiraIssuesListRoot, {
+ return createComponent(JiraIssuesImportStatusRoot, {
props: {
canEdit: parseBoolean(el.dataset.canEdit),
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
index 15f9512fe92..418cc69bf5a 100644
--- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
+++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js
@@ -1,39 +1,30 @@
+import { formatNumber } from '~/locale';
+
/**
- * Formats a number as string using `toLocaleString`.
+ * Formats a number as a string using `toLocaleString`.
*
* @param {Number} number to be converted
- * @param {params} Parameters
- * @param {params.fractionDigits} Number of decimal digits
- * to display, defaults to using `toLocaleString` defaults.
- * @param {params.maxLength} Max output char lenght at the
+ *
+ * @param {options.maxCharLength} Max output char length at the
* expense of precision, if the output is longer than this,
* the formatter switches to using exponential notation.
- * @param {params.factor} Value is multiplied by this factor,
- * useful for value normalization.
- * @returns Formatted value
+ *
+ * @param {options.valueFactor} Value is multiplied by this factor,
+ * useful for value normalization or to alter orders of magnitude.
+ *
+ * @param {options} Other options to be passed to
+ * `formatNumber` such as `valueFactor`, `unit` and `style`.
+ *
*/
-function formatNumber(
- value,
- { fractionDigits = undefined, valueFactor = 1, style = undefined, maxLength = undefined },
-) {
- if (value === null) {
- return '';
- }
-
- const locale = document.documentElement.lang || undefined;
- const num = value * valueFactor;
- const formatted = num.toLocaleString(locale, {
- minimumFractionDigits: fractionDigits,
- maximumFractionDigits: fractionDigits,
- style,
- });
+const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...options }) => {
+ const formatted = formatNumber(value * valueFactor, options);
- if (maxLength !== undefined && formatted.length > maxLength) {
+ if (maxCharLength !== undefined && formatted.length > maxCharLength) {
// 123456 becomes 1.23e+8
- return num.toExponential(2);
+ return value.toExponential(2);
}
return formatted;
-}
+};
/**
* Formats a number as a string scaling it up according to units.
@@ -76,7 +67,10 @@ const scaledFormatter = (units, unitFactor = 1000) => {
const unit = units[scale];
- return `${formatNumber(num, { fractionDigits })}${unit}`;
+ return `${formatNumberNormalized(num, {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}${unit}`;
};
};
@@ -84,8 +78,14 @@ const scaledFormatter = (units, unitFactor = 1000) => {
* Returns a function that formats a number as a string.
*/
export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
- return (value, fractionDigits, maxLength) => {
- return `${formatNumber(value, { fractionDigits, maxLength, valueFactor, style })}`;
+ return (value, fractionDigits, maxCharLength) => {
+ return `${formatNumberNormalized(value, {
+ maxCharLength,
+ valueFactor,
+ style,
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}`;
};
};
@@ -93,9 +93,15 @@ export const numberFormatter = (style = 'decimal', valueFactor = 1) => {
* Returns a function that formats a number as a string with a suffix.
*/
export const suffixFormatter = (unit = '', valueFactor = 1) => {
- return (value, fractionDigits, maxLength) => {
- const length = maxLength !== undefined ? maxLength - unit.length : undefined;
- return `${formatNumber(value, { fractionDigits, maxLength: length, valueFactor })}${unit}`;
+ return (value, fractionDigits, maxCharLength) => {
+ const length = maxCharLength !== undefined ? maxCharLength - unit.length : undefined;
+
+ return `${formatNumberNormalized(value, {
+ maxCharLength: length,
+ valueFactor,
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })}${unit}`;
};
};
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index 9f979f7ea4b..bc82c6aa74d 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -46,227 +46,261 @@ export const SUPPORTED_FORMATS = {
};
/**
- * Returns a function that formats number to different units
- * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
+ * Returns a function that formats number to different units.
*
+ * Used for dynamic formatting, for more convenience, use the functions below.
*
+ * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
*/
export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
// Number
-
if (format === SUPPORTED_FORMATS.number) {
- /**
- * Formats a number
- *
- * @function
- * @param {Number} value - Number to format
- * @param {Number} fractionDigits - precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter();
}
if (format === SUPPORTED_FORMATS.percent) {
- /**
- * Formats a percentge (0 - 1)
- *
- * @function
- * @param {Number} value - Number to format, `1` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter('percent');
}
if (format === SUPPORTED_FORMATS.percentHundred) {
- /**
- * Formats a percentge (0 to 100)
- *
- * @function
- * @param {Number} value - Number to format, `100` is rendered as `100%`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return numberFormatter('percent', 1 / 100);
}
// Durations
-
if (format === SUPPORTED_FORMATS.seconds) {
- /**
- * Formats a number of seconds
- *
- * @function
- * @param {Number} value - Number to format, `1` is rendered as `1s`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return suffixFormatter(s__('Units|s'));
}
if (format === SUPPORTED_FORMATS.milliseconds) {
- /**
- * Formats a number of milliseconds with ms as units
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1ms`
- * @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max length of formatted number
- * if length is exceeded, exponential format is used.
- */
return suffixFormatter(s__('Units|ms'));
}
// Digital (Metric)
-
if (format === SUPPORTED_FORMATS.decimalBytes) {
- /**
- * Formats a number of bytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B');
}
if (format === SUPPORTED_FORMATS.kilobytes) {
- /**
- * Formats a number of kilobytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.megabytes) {
- /**
- * Formats a number of megabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gigabytes) {
- /**
- * Formats a number of gigabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.terabytes) {
- /**
- * Formats a number of terabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.petabytes) {
- /**
- * Formats a number of petabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledSIFormatter('B', 5);
}
// Digital (IEC)
-
if (format === SUPPORTED_FORMATS.bytes) {
- /**
- * Formats a number of bytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1B`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B');
}
if (format === SUPPORTED_FORMATS.kibibytes) {
- /**
- * Formats a number of kilobytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1kB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 1);
}
if (format === SUPPORTED_FORMATS.mebibytes) {
- /**
- * Formats a number of megabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1MB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 2);
}
if (format === SUPPORTED_FORMATS.gibibytes) {
- /**
- * Formats a number of gigabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 3);
}
if (format === SUPPORTED_FORMATS.tebibytes) {
- /**
- * Formats a number of terabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1GB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 4);
}
if (format === SUPPORTED_FORMATS.pebibytes) {
- /**
- * Formats a number of petabytes scaled up to larger digital
- * units for larger numbers.
- *
- * @function
- * @param {Number} value - Number to format, `1` is formatted as `1PB`
- * @param {Number} fractionDigits - number of precision decimals
- */
return scaledBinaryFormatter('B', 5);
}
+ // Default
if (format === SUPPORTED_FORMATS.engineering) {
- /**
- * Formats via engineering notation
- *
- * @function
- * @param {Number} value - Value to format
- * @param {Number} fractionDigits - precision decimals - Defaults to 2
- */
return engineeringNotation;
}
// Fail so client library addresses issue
throw TypeError(`${format} is not a valid number format`);
};
+
+/**
+ * Formats a number
+ *
+ * @function
+ * @param {Number} value - Number to format
+ * @param {Number} fractionDigits - precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const number = getFormatter(SUPPORTED_FORMATS.number);
+
+/**
+ * Formats a percentage (0 - 1)
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `100%`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const percent = getFormatter(SUPPORTED_FORMATS.percent);
+
+/**
+ * Formats a percentage (0 to 100)
+ *
+ * @function
+ * @param {Number} value - Number to format, `100` is rendered as `100%`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
+
+/**
+ * Formats a number of seconds
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is rendered as `1s`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const seconds = getFormatter(SUPPORTED_FORMATS.seconds);
+
+/**
+ * Formats a number of milliseconds with ms as units
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1ms`
+ * @param {Number} fractionDigits - number of precision decimals
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
+ */
+export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
+
+/**
+ * Formats a number of bytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1B`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
+
+/**
+ * Formats a number of kilobytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1kB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes);
+
+/**
+ * Formats a number of megabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1MB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes);
+
+/**
+ * Formats a number of gigabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes);
+
+/**
+ * Formats a number of terabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes);
+
+/**
+ * Formats a number of petabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1PB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes);
+
+/**
+ * Formats a number of bytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1B`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const bytes = getFormatter(SUPPORTED_FORMATS.bytes);
+
+/**
+ * Formats a number of kilobytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1kB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes);
+
+/**
+ * Formats a number of megabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1MB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes);
+
+/**
+ * Formats a number of gigabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes);
+
+/**
+ * Formats a number of terabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1GB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes);
+
+/**
+ * Formats a number of petabytes scaled up to larger digital
+ * units for larger numbers.
+ *
+ * @function
+ * @param {Number} value - Number to format, `1` is formatted as `1PB`
+ * @param {Number} fractionDigits - number of precision decimals
+ */
+export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes);
+
+/**
+ * Formats via engineering notation
+ *
+ * @function
+ * @param {Number} value - Value to format
+ * @param {Number} fractionDigits - precision decimals - Defaults to 2
+ */
+export const engineering = getFormatter();
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 35087b920c7..10518fa73d9 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -58,10 +58,30 @@ const pgettext = (keyOrContext, key) => {
*/
const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(languageCode(), formatOptions);
+/**
+ * Formats a number as a string using `toLocaleString`.
+ *
+ * @param {Number} value - number to be converted
+ * @param {options?} options - options to be passed to
+ * `toLocaleString` such as `unit` and `style`.
+ * @param {langCode?} langCode - If set, forces a different
+ * language code from the one currently in the document.
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
+ *
+ * @returns If value is a number, the formatted value as a string
+ */
+function formatNumber(value, options = {}, langCode = languageCode()) {
+ if (typeof value !== 'number' && typeof value !== 'bigint') {
+ return value;
+ }
+ return value.toLocaleString(langCode, options);
+}
+
export { languageCode };
export { gettext as __ };
export { ngettext as n__ };
export { pgettext as s__ };
export { sprintf };
export { createDateTimeFormat };
+export { formatNumber };
export default locale;
diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue
index 90bdf06bc4c..1ad18508294 100644
--- a/app/assets/javascripts/tooltips/components/tooltips.vue
+++ b/app/assets/javascripts/tooltips/components/tooltips.vue
@@ -82,9 +82,10 @@ export default {
},
triggerEvent(target, event) {
const tooltip = this.findTooltipByTarget(target);
+ const tooltipRef = this.$refs[tooltip?.id];
- if (tooltip) {
- this.$refs[tooltip.id][0].$emit(event);
+ if (tooltipRef) {
+ tooltipRef[0].$emit(event);
}
},
tooltipExists(element) {
@@ -113,6 +114,7 @@ export default {
:boundary="tooltip.boundary"
:disabled="tooltip.disabled"
:show="tooltip.show"
+ @hidden="$emit('hidden', tooltip)"
>
<span v-if="tooltip.html" v-safe-html:[$options.safeHtmlConfig]="tooltip.title"></span>
<span v-else>{{ tooltip.title }}</span>
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index a9978c03a6e..f60c0759c72 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -92,6 +92,7 @@ export const hide = createTooltipApiInvoker((element) =>
export const show = createTooltipApiInvoker((element) =>
tooltipsApp().triggerEvent(element, 'open'),
);
+export const once = (event, cb) => tooltipsApp().$once(event, cb);
export const destroy = () => {
tooltipsApp().$destroy();
app = null;
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index 9ad700404ff..d68ba80ab5d 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -78,11 +78,11 @@ module Repositories
def update_fetch_statistics
return unless project
return if Gitlab::Database.read_only?
- return if Feature.enabled?(:disable_git_http_fetch_writes)
-
return unless repo_type.project?
- OnboardingProgressService.new(project.namespace).execute(action: :git_read)
+ OnboardingProgressService.async(project.namespace_id).execute(action: :git_pull)
+
+ return if Feature.enabled?(:disable_git_http_fetch_writes)
if Feature.enabled?(:project_statistics_sync, project, default_enabled: true)
Projects::FetchStatisticsIncrementService.new(project).execute
diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb
index 8a444f8934e..fb627d1b67e 100644
--- a/app/models/onboarding_progress.rb
+++ b/app/models/onboarding_progress.rb
@@ -66,6 +66,13 @@ class OnboardingProgress < ApplicationRecord
where(namespace: namespace).where.not(action_column => nil).exists?
end
+ def not_completed?(namespace_id, action)
+ return unless ACTIONS.include?(action)
+
+ action_column = column_name(action)
+ where(namespace_id: namespace_id).where(action_column => nil).exists?
+ end
+
def column_name(action)
:"#{action}_at"
end
diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb
index 241bd8a01ca..6d44c0a61ea 100644
--- a/app/services/onboarding_progress_service.rb
+++ b/app/services/onboarding_progress_service.rb
@@ -1,6 +1,24 @@
# frozen_string_literal: true
class OnboardingProgressService
+ class Async
+ attr_reader :namespace_id
+
+ def initialize(namespace_id)
+ @namespace_id = namespace_id
+ end
+
+ def execute(action:)
+ return unless OnboardingProgress.not_completed?(namespace_id, action)
+
+ Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action)
+ end
+ end
+
+ def self.async(namespace_id)
+ Async.new(namespace_id)
+ end
+
def initialize(namespace)
@namespace = namespace&.root_ancestor
end
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index dd66e00b813..3fe9e1203ec 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -8,7 +8,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
-.js-projects-issues-root{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
+.js-jira-issues-import-status{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
is_jira_configured: @project.jira_service.present?.to_s,
issues_path: project_issues_path(@project),
project_path: @project.full_path } }
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 3b455b972f9..bded69e7f47 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1848,6 +1848,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: namespaces_onboarding_progress
+ :feature_category: :product_analytics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: namespaces_onboarding_user_added
:feature_category: :users
:has_external_dependencies:
diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb
new file mode 100644
index 00000000000..9cb5a23cf31
--- /dev/null
+++ b/app/workers/namespaces/onboarding_progress_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class OnboardingProgressWorker
+ include ApplicationWorker
+
+ feature_category :product_analytics
+ urgency :low
+
+ deduplicate :until_executed
+ idempotent!
+
+ def perform(namespace_id, action)
+ namespace = Namespace.find_by_id(namespace_id)
+ return unless namespace && action
+
+ OnboardingProgressService.new(namespace).execute(action: action.to_sym)
+ end
+ end
+end
diff --git a/changelogs/unreleased/321514-fix-clipboard-buttons.yml b/changelogs/unreleased/321514-fix-clipboard-buttons.yml
new file mode 100644
index 00000000000..b7a27104c7a
--- /dev/null
+++ b/changelogs/unreleased/321514-fix-clipboard-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Fix copy to clipboard tooltip button
+merge_request: 54472
+author:
+type: fixed
diff --git a/changelogs/unreleased/321745-remove-temporary-index-idx_on_issues_where_service_desk_reply_to_i.yml b/changelogs/unreleased/321745-remove-temporary-index-idx_on_issues_where_service_desk_reply_to_i.yml
new file mode 100644
index 00000000000..ae68dcc1824
--- /dev/null
+++ b/changelogs/unreleased/321745-remove-temporary-index-idx_on_issues_where_service_desk_reply_to_i.yml
@@ -0,0 +1,5 @@
+---
+title: Remove temporary index on issues
+merge_request: 54387
+author: Lee Tickett @leetickett
+type: other
diff --git a/changelogs/unreleased/cmiskell-upgrade-sidekiq-reliable-fetch.yml b/changelogs/unreleased/cmiskell-upgrade-sidekiq-reliable-fetch.yml
new file mode 100644
index 00000000000..ec2bc728398
--- /dev/null
+++ b/changelogs/unreleased/cmiskell-upgrade-sidekiq-reliable-fetch.yml
@@ -0,0 +1,6 @@
+---
+title: Upgrade gitlab-sidekiq-fetcher for correctly detecting interrupted jobs when
+ Sidekiq pods are restarted
+merge_request: 54881
+author:
+type: fixed
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 76eabcaac84..e7485540dc4 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -214,6 +214,8 @@
- 1
- - namespaces_onboarding_pipeline_created
- 1
+- - namespaces_onboarding_progress
+ - 1
- - namespaces_onboarding_user_added
- 1
- - new_epic
diff --git a/data/whats_new/202102180001_13_09.yml b/data/whats_new/202102180001_13_09.yml
index 98ce478cdb2..d2a48048d8c 100644
--- a/data/whats_new/202102180001_13_09.yml
+++ b/data/whats_new/202102180001_13_09.yml
@@ -21,7 +21,7 @@
gitlab-com: true
packages: [Free, Premium, Ultimate]
url: https://docs.gitlab.com/runner/executors/docker_machine.html#using-gpus-on-google-compute-engine
- image_url: https://about.gitlabcom/images/ci/gitlab-ci-cd-logo_2x.png
+ image_url: https://about.gitlab.com/images/ci/gitlab-ci-cd-logo_2x.png
published_at: 2021-02-22
release: 13.9
- title: "Vault JWT (JSON Web Token) supports GitLab environments"
diff --git a/db/fixtures/development/30_composer_packages.rb b/db/fixtures/development/30_composer_packages.rb
index fa8c648de9e..a30d838ad8c 100644
--- a/db/fixtures/development/30_composer_packages.rb
+++ b/db/fixtures/development/30_composer_packages.rb
@@ -110,9 +110,11 @@ Gitlab::Seeder.quiet do
next
end
- ::Packages::Composer::CreatePackageService
- .new(project, project.owner, params)
- .execute
+ Sidekiq::Worker.skipping_transaction_check do
+ ::Packages::Composer::CreatePackageService
+ .new(project, project.owner, params)
+ .execute
+ end
puts "version #{version.inspect} created!"
end
diff --git a/db/migrate/20210216223335_remove_index_on_issues_where_service_desk_reply_to_is_not_null.rb b/db/migrate/20210216223335_remove_index_on_issues_where_service_desk_reply_to_is_not_null.rb
new file mode 100644
index 00000000000..5224b6f7031
--- /dev/null
+++ b/db/migrate/20210216223335_remove_index_on_issues_where_service_desk_reply_to_is_not_null.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class RemoveIndexOnIssuesWhereServiceDeskReplyToIsNotNull < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ INDEX_TABLE = :issues
+ INDEX_NAME = 'idx_on_issues_where_service_desk_reply_to_is_not_null'
+
+ def up
+ Gitlab::BackgroundMigration.steal('PopulateIssueEmailParticipants')
+ remove_concurrent_index_by_name INDEX_TABLE, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index(INDEX_TABLE, [:id], name: INDEX_NAME, where: 'service_desk_reply_to IS NOT NULL')
+ end
+end
diff --git a/db/schema_migrations/20210216223335 b/db/schema_migrations/20210216223335
new file mode 100644
index 00000000000..a086aaa8c91
--- /dev/null
+++ b/db/schema_migrations/20210216223335
@@ -0,0 +1 @@
+52bf190bdb219366c790a5b7c081bfb383543498780cc95a25eafcecea036426 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 0ce27a5ca70..811f80ea9e9 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -21355,8 +21355,6 @@ CREATE INDEX idx_mr_cc_diff_files_on_mr_cc_id_and_sha ON merge_request_context_c
CREATE UNIQUE INDEX idx_on_compliance_management_frameworks_namespace_id_name ON compliance_management_frameworks USING btree (namespace_id, name);
-CREATE INDEX idx_on_issues_where_service_desk_reply_to_is_not_null ON issues USING btree (id) WHERE (service_desk_reply_to IS NOT NULL);
-
CREATE INDEX idx_packages_build_infos_on_package_id ON packages_build_infos USING btree (package_id);
CREATE INDEX idx_packages_debian_group_component_files_on_architecture_id ON packages_debian_group_component_files USING btree (architecture_id);
diff --git a/doc/development/fe_guide/style/scss.md b/doc/development/fe_guide/style/scss.md
index a39cc1305b7..c0817626360 100644
--- a/doc/development/fe_guide/style/scss.md
+++ b/doc/development/fe_guide/style/scss.md
@@ -99,7 +99,7 @@ ul {
// Best
// prefer an existing utility class over adding existing styles
-```0
+```
Class names are also preferable to IDs. Rules that use IDs
are not-reusable, as there can only be one affected element on
diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md
index 0cdfa3e68d7..792b4a1a27f 100644
--- a/doc/development/feature_flags/development.md
+++ b/doc/development/feature_flags/development.md
@@ -71,7 +71,8 @@ push_frontend_feature_flag(:my_ops_flag, project, type: :ops)
`experiment` feature flags are used for A/B testing on GitLab.com.
An `experiment` feature flag should conform to the same standards as a `development` feature flag,
-although the interface has some differences. More information can be found in the [experiment guide](../experiment_guide/index.md).
+although the interface has some differences. An experiment feature flag should have a rollout issue,
+ideally created using the [Experiment Tracking template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/experiment_tracking_template.md). More information can be found in the [experiment guide](../experiment_guide/index.md).
## Feature flag definition and validation
diff --git a/doc/development/usage_ping.md b/doc/development/usage_ping.md
index 130da059583..fc807b4a2f8 100644
--- a/doc/development/usage_ping.md
+++ b/doc/development/usage_ping.md
@@ -753,7 +753,7 @@ alt_usage_data(999)
### Adding counters to build new metrics
When adding the results of two counters, use the `add` usage data method that
-handles fallback values and exceptions. It also generates a valid SQL export.
+handles fallback values and exceptions. It also generates a valid [SQL export](#exporting-usage-ping-sql-queries-and-definitions).
Example usage:
diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md
index 7a9ed9d435d..6762becec2d 100644
--- a/doc/security/two_factor_authentication.md
+++ b/doc/security/two_factor_authentication.md
@@ -129,8 +129,15 @@ verification can be done via a GitLab Shell command:
ssh git@<hostname> 2fa_verify
```
-Once the OTP is verified, Git over SSH operations can be used for 15 minutes
-with the associated SSH key.
+Once the OTP is verified, Git over SSH operations can be used for a session duration of
+15 minutes (default) with the associated SSH key.
+
+### Security limitation
+
+2FA does not protect users with compromised *private* SSH keys.
+
+Once an OTP is verified, anyone can run Git over SSH with that private SSH key for
+the configured [session duration](../user/admin_area/settings/account_and_limit_settings.md#customize-session-duration-for-git-operations-when-2fa-is-enabled).
### Enable or disable Two-factor Authentication (2FA) for Git operations
diff --git a/doc/user/admin_area/settings/account_and_limit_settings.md b/doc/user/admin_area/settings/account_and_limit_settings.md
index 2b230f9fb6e..0f391118215 100644
--- a/doc/user/admin_area/settings/account_and_limit_settings.md
+++ b/doc/user/admin_area/settings/account_and_limit_settings.md
@@ -191,6 +191,8 @@ You can prevent the use of expired SSH keys with the following steps:
1. Expand the **Account and limit** section.
1. Select the **Enforce SSH key expiration** checkbox.
+Enforcing SSH key expiration immediately disables all expired SSH keys.
+
For more information, see the following issue on [SSH key expiration](https://gitlab.com/gitlab-org/gitlab/-/issues/320970).
## Optional non-enforcement of Personal Access Token expiration **(ULTIMATE SELF)**
diff --git a/lib/gitlab/ci/variables/collection.rb b/lib/gitlab/ci/variables/collection.rb
index f7bbb58df7e..35ece398000 100644
--- a/lib/gitlab/ci/variables/collection.rb
+++ b/lib/gitlab/ci/variables/collection.rb
@@ -6,8 +6,11 @@ module Gitlab
class Collection
include Enumerable
- def initialize(variables = [])
+ attr_reader :errors
+
+ def initialize(variables = [], errors = nil)
@variables = []
+ @errors = errors
variables.each { |variable| self.append(variable) }
end
@@ -42,6 +45,11 @@ module Gitlab
.map { |env| [env.fetch(:key), env.fetch(:value)] }
.to_h.with_indifferent_access
end
+
+ # Returns a sorted Collection object, and sets errors property in case of an error
+ def sorted_collection(project)
+ Sort.new(self, project).collection
+ end
end
end
end
diff --git a/lib/gitlab/ci/variables/collection/sorted.rb b/lib/gitlab/ci/variables/collection/sort.rb
index efa9e3bdfc4..9f28b1bf504 100644
--- a/lib/gitlab/ci/variables/collection/sorted.rb
+++ b/lib/gitlab/ci/variables/collection/sort.rb
@@ -4,7 +4,7 @@ module Gitlab
module Ci
module Variables
class Collection
- class Sorted
+ class Sort
include TSort
include Gitlab::Utils::StrongMemoize
@@ -35,11 +35,12 @@ module Gitlab
end
end
- # sort sorts an array of variables, ignoring unknown variable references.
- # If a circular variable reference is found, the original array is returned
- def sort
+ # collection sorts a collection of variables, ignoring unknown variable references.
+ # If a circular variable reference is found, a new collection with the original array and an error is returned
+ def collection
return @collection if Feature.disabled?(:variable_inside_variable, @project)
- return @collection if errors
+
+ return Gitlab::Ci::Variables::Collection.new(@collection, errors) if errors
Gitlab::Ci::Variables::Collection.new(tsort)
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f0090b824cf..c79746a9785 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1305,6 +1305,9 @@ msgstr ""
msgid "A job artifact is an archive of files and directories saved by a job when it finishes."
msgstr ""
+msgid "A label list displays all issues with the selected label."
+msgstr ""
+
msgid "A limit of %{ci_project_subscriptions_limit} subscriptions to or from a project applies."
msgstr ""
@@ -4814,6 +4817,9 @@ msgstr ""
msgid "Boards|An error occurred while fetching issues. Please reload the page."
msgstr ""
+msgid "Boards|An error occurred while fetching labels. Please reload the page."
+msgstr ""
+
msgid "Boards|An error occurred while fetching the board epics. Please reload the page."
msgstr ""
@@ -20058,6 +20064,9 @@ msgstr ""
msgid "New label"
msgstr ""
+msgid "New label list"
+msgstr ""
+
msgid "New merge request"
msgstr ""
@@ -20283,6 +20292,9 @@ msgstr ""
msgid "No label"
msgstr ""
+msgid "No label selected"
+msgstr ""
+
msgid "No labels with such name or description"
msgstr ""
@@ -26026,6 +26038,9 @@ msgstr ""
msgid "Search forks"
msgstr ""
+msgid "Search labels"
+msgstr ""
+
msgid "Search merge requests"
msgstr ""
@@ -26746,6 +26761,9 @@ msgstr ""
msgid "Select user"
msgstr ""
+msgid "Selected"
+msgstr ""
+
msgid "Selected commits"
msgstr ""
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_ssh_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_ssh_spec.rb
index c56e6d1267c..9eeb762e548 100644
--- a/qa/qa/specs/features/browser_ui/3_create/repository/push_over_ssh_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_over_ssh_spec.rb
@@ -41,8 +41,7 @@ module QA
end
end
- it 'pushes multiple branches and tags together', :smoke, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1679
- ' do
+ it 'pushes multiple branches and tags together', :smoke, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1679' do
branches = []
tags = []
Git::Repository.perform do |repository|
diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb
index d21f602f90c..4eede594bb9 100644
--- a/spec/controllers/repositories/git_http_controller_spec.rb
+++ b/spec/controllers/repositories/git_http_controller_spec.rb
@@ -54,14 +54,17 @@ RSpec.describe Repositories::GitHttpController do
}.from(0).to(1)
end
- it_behaves_like 'records an onboarding progress action', :git_read do
- let(:namespace) { project.namespace }
-
- subject { send_request }
+ describe 'recording the onboarding progress', :sidekiq_inline do
+ let_it_be(:namespace) { project.namespace }
before do
- stub_feature_flags(disable_git_http_fetch_writes: false)
+ OnboardingProgress.onboard(namespace)
+ send_request
end
+
+ subject { OnboardingProgress.completed?(namespace, :git_pull) }
+
+ it { is_expected.to be(true) }
end
context 'when disable_git_http_fetch_writes is enabled' do
@@ -75,12 +78,6 @@ RSpec.describe Repositories::GitHttpController do
send_request
end
-
- it 'does not record onboarding progress' do
- expect(OnboardingProgressService).not_to receive(:new)
-
- send_request
- end
end
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index 2d6b669f28b..35fda25d70f 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -13,8 +13,6 @@ RSpec.describe 'Issue Boards', :js do
let_it_be(:user2) { create(:user) }
before do
- stub_feature_flags(board_new_list: false)
-
project.add_maintainer(user)
project.add_maintainer(user2)
@@ -68,6 +66,8 @@ RSpec.describe 'Issue Boards', :js do
let_it_be(:issue10) { create(:labeled_issue, project: project, title: 'issue +', description: 'A+ great issue', labels: [a_plus]) }
before do
+ stub_feature_flags(board_new_list: false)
+
visit project_board_path(project, board)
wait_for_requests
@@ -168,19 +168,6 @@ RSpec.describe 'Issue Boards', :js do
expect(page).to have_selector('.board', count: 3)
end
- it 'removes checkmark in new list dropdown after deleting' do
- click_button 'Add list'
- wait_for_requests
-
- find('.js-new-board-list').click
-
- remove_list
-
- wait_for_requests
-
- expect(page).to have_selector('.board', count: 3)
- end
-
it 'infinite scrolls list' do
create_list(:labeled_issue, 50, project: project, labels: [planning])
@@ -321,6 +308,7 @@ RSpec.describe 'Issue Boards', :js do
context 'new list' do
it 'shows all labels in new list dropdown' do
click_button 'Add list'
+
wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
diff --git a/spec/features/boards/user_adds_lists_to_board_spec.rb b/spec/features/boards/user_adds_lists_to_board_spec.rb
new file mode 100644
index 00000000000..b9945207bb2
--- /dev/null
+++ b/spec/features/boards/user_adds_lists_to_board_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User adds lists', :js do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:group) { create(:group, :nested) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:group_board) { create(:board, group: group) }
+ let_it_be(:project_board) { create(:board, project: project) }
+ let_it_be(:user) { create(:user) }
+
+ let_it_be(:milestone) { create(:milestone, project: project) }
+
+ let_it_be(:group_label) { create(:group_label, group: group) }
+ let_it_be(:project_label) { create(:label, project: project) }
+ let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) }
+ let_it_be(:project_backlog_list) { create(:backlog_list, board: project_board) }
+
+ let_it_be(:issue) { create(:labeled_issue, project: project, labels: [group_label, project_label]) }
+
+ before_all do
+ project.add_maintainer(user)
+ group.add_owner(user)
+ end
+
+ where(:board_type, :graphql_board_lists_enabled, :board_new_list_enabled) do
+ :project | true | true
+ :project | false | true
+ :project | true | false
+ :project | false | false
+ :group | true | true
+ :group | false | true
+ :group | true | false
+ :group | false | false
+ end
+
+ with_them do
+ before do
+ sign_in(user)
+
+ set_cookie('sidebar_collapsed', 'true')
+
+ stub_feature_flags(
+ graphql_board_lists: graphql_board_lists_enabled,
+ board_new_list: board_new_list_enabled
+ )
+
+ if board_type == :project
+ visit project_board_path(project, project_board)
+ elsif board_type == :group
+ visit group_board_path(group, group_board)
+ end
+
+ wait_for_all_requests
+ end
+
+ it 'creates new column for label containing labeled issue' do
+ click_button button_text(board_new_list_enabled)
+ wait_for_all_requests
+
+ select_label(board_new_list_enabled, group_label)
+
+ wait_for_all_requests
+
+ expect(page).to have_selector('.board', text: group_label.title)
+ expect(find('.board:nth-child(2) .board-card')).to have_content(issue.title)
+ end
+ end
+
+ def select_label(board_new_list_enabled, label)
+ if board_new_list_enabled
+ page.within('.board-add-new-list') do
+ find('label', text: label.title).click
+ click_button 'Add'
+ end
+ else
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link label.title
+ end
+ end
+ end
+
+ def button_text(board_new_list_enabled)
+ if board_new_list_enabled
+ 'Create list'
+ else
+ 'Add list'
+ end
+ end
+end
diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js
new file mode 100644
index 00000000000..84b6d7abb1e
--- /dev/null
+++ b/spec/frontend/boards/components/board_add_new_column_spec.js
@@ -0,0 +1,151 @@
+import { GlSearchBoxByType } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue';
+import defaultState from '~/boards/stores/state';
+import { mockLabelList } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('Board card layout', () => {
+ let wrapper;
+ let shouldUseGraphQL;
+
+ const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
+ return new Vuex.Store({
+ state: {
+ ...defaultState,
+ ...state,
+ },
+ actions,
+ getters,
+ });
+ };
+
+ const mountComponent = ({
+ selectedLabelId,
+ labels = [],
+ getListByLabelId = jest.fn(),
+ actions = {},
+ } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(BoardAddNewColumn, {
+ stubs: {
+ GlFormGroup: true,
+ },
+ data() {
+ return {
+ selectedLabelId,
+ };
+ },
+ store: createStore({
+ actions: {
+ fetchLabels: jest.fn(),
+ setAddColumnFormVisibility: jest.fn(),
+ ...actions,
+ },
+ getters: {
+ shouldUseGraphQL: () => shouldUseGraphQL,
+ getListByLabelId: () => getListByLabelId,
+ },
+ state: {
+ labels,
+ labelsLoading: false,
+ },
+ }),
+ provide: {
+ scopedLabelsAvailable: true,
+ },
+ }),
+ );
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text();
+ const findSearchInput = () => wrapper.find(GlSearchBoxByType);
+ const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn');
+ const submitButton = () => wrapper.findByTestId('addNewColumnButton');
+
+ beforeEach(() => {
+ shouldUseGraphQL = true;
+ });
+
+ it('shows form title & search input', () => {
+ mountComponent();
+
+ expect(formTitle()).toEqual(BoardAddNewColumn.i18n.newLabelList);
+ expect(findSearchInput().exists()).toBe(true);
+ });
+
+ it('clicking cancel hides the form', () => {
+ const setAddColumnFormVisibility = jest.fn();
+ mountComponent({
+ actions: {
+ setAddColumnFormVisibility,
+ },
+ });
+
+ cancelButton().vm.$emit('click');
+
+ expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false);
+ });
+
+ describe('Add list button', () => {
+ it('is disabled if no item is selected', () => {
+ mountComponent();
+
+ expect(submitButton().props('disabled')).toBe(true);
+ });
+
+ it('adds a new list on click', async () => {
+ const labelId = mockLabelList.label.id;
+ const highlightList = jest.fn();
+ const createList = jest.fn();
+
+ mountComponent({
+ labels: [mockLabelList.label],
+ selectedLabelId: labelId,
+ actions: {
+ createList,
+ highlightList,
+ },
+ });
+
+ await nextTick();
+
+ submitButton().vm.$emit('click');
+
+ expect(highlightList).not.toHaveBeenCalled();
+ expect(createList).toHaveBeenCalledWith(expect.anything(), { labelId });
+ });
+
+ it('highlights existing list if trying to re-add', async () => {
+ const getListByLabelId = jest.fn().mockReturnValue(mockLabelList);
+ const highlightList = jest.fn();
+ const createList = jest.fn();
+
+ mountComponent({
+ labels: [mockLabelList.label],
+ selectedLabelId: mockLabelList.label.id,
+ getListByLabelId,
+ actions: {
+ createList,
+ highlightList,
+ },
+ });
+
+ await nextTick();
+
+ submitButton().vm.$emit('click');
+
+ expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id);
+ expect(createList).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 80d98c5eb6b..c22942774e8 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -327,11 +327,15 @@ describe('fetchLabels', () => {
};
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
- await testAction({
- action: actions.fetchLabels,
- state: { boardType: 'group' },
- expectedMutations: [{ type: types.RECEIVE_LABELS_SUCCESS, payload: labels }],
- });
+ const commit = jest.fn();
+ const getters = {
+ shouldUseGraphQL: () => true,
+ };
+ const state = { boardType: 'group' };
+
+ await actions.fetchLabels({ getters, state, commit });
+
+ expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels);
});
});
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 2d7b80a997a..3a94bd9160f 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -109,11 +109,31 @@ describe('Board Store Mutations', () => {
});
});
+ describe('RECEIVE_LABELS_REQUEST', () => {
+ it('sets labelsLoading on state', () => {
+ mutations.RECEIVE_LABELS_REQUEST(state);
+
+ expect(state.labelsLoading).toEqual(true);
+ });
+ });
+
describe('RECEIVE_LABELS_SUCCESS', () => {
it('sets labels on state', () => {
mutations.RECEIVE_LABELS_SUCCESS(state, labels);
expect(state.labels).toEqual(labels);
+ expect(state.labelsLoading).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_LABELS_FAILURE', () => {
+ it('sets error message', () => {
+ mutations.RECEIVE_LABELS_FAILURE(state);
+
+ expect(state.error).toEqual(
+ 'An error occurred while fetching labels. Please reload the page.',
+ );
+ expect(state.labelsLoading).toEqual(false);
});
});
diff --git a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
index eecb092a330..0c96b95a61f 100644
--- a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js
+++ b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
@@ -1,9 +1,9 @@
import { GlAlert, GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
-import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue';
+import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue';
-describe('JiraIssuesListRoot', () => {
+describe('JiraIssuesImportStatus', () => {
const issuesPath = 'gitlab-org/gitlab-test/-/issues';
const label = {
color: '#333',
@@ -19,7 +19,7 @@ describe('JiraIssuesListRoot', () => {
shouldShowFinishedAlert = false,
shouldShowInProgressAlert = false,
} = {}) =>
- shallowMount(JiraIssuesListRoot, {
+ shallowMount(JiraIssuesImportStatus, {
propsData: {
canEdit: true,
isJiraConfigured: true,
diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js
index 5b2fdf1f02b..7fd273f1b58 100644
--- a/spec/frontend/lib/utils/unit_format/index_spec.js
+++ b/spec/frontend/lib/utils/unit_format/index_spec.js
@@ -1,157 +1,213 @@
-import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
+import {
+ number,
+ percent,
+ percentHundred,
+ seconds,
+ milliseconds,
+ decimalBytes,
+ kilobytes,
+ megabytes,
+ gigabytes,
+ terabytes,
+ petabytes,
+ bytes,
+ kibibytes,
+ mebibytes,
+ gibibytes,
+ tebibytes,
+ pebibytes,
+ engineering,
+ getFormatter,
+ SUPPORTED_FORMATS,
+} from '~/lib/utils/unit_format';
describe('unit_format', () => {
- describe('when a supported format is provided, the returned function formats', () => {
- it('numbers, by default', () => {
- expect(getFormatter()(1)).toBe('1');
- });
-
- it('numbers', () => {
- const formatNumber = getFormatter(SUPPORTED_FORMATS.number);
-
- expect(formatNumber(1)).toBe('1');
- expect(formatNumber(100)).toBe('100');
- expect(formatNumber(1000)).toBe('1,000');
- expect(formatNumber(10000)).toBe('10,000');
- expect(formatNumber(1000000)).toBe('1,000,000');
- });
-
- it('percent', () => {
- const formatPercent = getFormatter(SUPPORTED_FORMATS.percent);
+ it('engineering', () => {
+ expect(engineering(1)).toBe('1');
+ expect(engineering(100)).toBe('100');
+ expect(engineering(1000)).toBe('1k');
+ expect(engineering(10_000)).toBe('10k');
+ expect(engineering(1_000_000)).toBe('1M');
+
+ expect(engineering(10 ** 9)).toBe('1G');
+ });
- expect(formatPercent(1)).toBe('100%');
- expect(formatPercent(1, 2)).toBe('100.00%');
+ it('number', () => {
+ expect(number(1)).toBe('1');
+ expect(number(100)).toBe('100');
+ expect(number(1000)).toBe('1,000');
+ expect(number(10_000)).toBe('10,000');
+ expect(number(1_000_000)).toBe('1,000,000');
- expect(formatPercent(0.1)).toBe('10%');
- expect(formatPercent(0.5)).toBe('50%');
+ expect(number(10 ** 9)).toBe('1,000,000,000');
+ });
- expect(formatPercent(0.888888)).toBe('89%');
- expect(formatPercent(0.888888, 2)).toBe('88.89%');
- expect(formatPercent(0.888888, 5)).toBe('88.88880%');
+ it('percent', () => {
+ expect(percent(1)).toBe('100%');
+ expect(percent(1, 2)).toBe('100.00%');
- expect(formatPercent(2)).toBe('200%');
- expect(formatPercent(10)).toBe('1,000%');
- });
+ expect(percent(0.1)).toBe('10%');
+ expect(percent(0.5)).toBe('50%');
- it('percentunit', () => {
- const formatPercentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
+ expect(percent(0.888888)).toBe('89%');
+ expect(percent(0.888888, 2)).toBe('88.89%');
+ expect(percent(0.888888, 5)).toBe('88.88880%');
- expect(formatPercentHundred(1)).toBe('1%');
- expect(formatPercentHundred(1, 2)).toBe('1.00%');
-
- expect(formatPercentHundred(88.8888)).toBe('89%');
- expect(formatPercentHundred(88.8888, 2)).toBe('88.89%');
- expect(formatPercentHundred(88.8888, 5)).toBe('88.88880%');
+ expect(percent(2)).toBe('200%');
+ expect(percent(10)).toBe('1,000%');
+ });
- expect(formatPercentHundred(100)).toBe('100%');
- expect(formatPercentHundred(100, 2)).toBe('100.00%');
+ it('percentHundred', () => {
+ expect(percentHundred(1)).toBe('1%');
+ expect(percentHundred(1, 2)).toBe('1.00%');
- expect(formatPercentHundred(200)).toBe('200%');
- expect(formatPercentHundred(1000)).toBe('1,000%');
- });
+ expect(percentHundred(88.8888)).toBe('89%');
+ expect(percentHundred(88.8888, 2)).toBe('88.89%');
+ expect(percentHundred(88.8888, 5)).toBe('88.88880%');
- it('seconds', () => {
- expect(getFormatter(SUPPORTED_FORMATS.seconds)(1)).toBe('1s');
- });
+ expect(percentHundred(100)).toBe('100%');
+ expect(percentHundred(100, 2)).toBe('100.00%');
- it('milliseconds', () => {
- const formatMilliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
+ expect(percentHundred(200)).toBe('200%');
+ expect(percentHundred(1000)).toBe('1,000%');
+ });
- expect(formatMilliseconds(1)).toBe('1ms');
- expect(formatMilliseconds(100)).toBe('100ms');
- expect(formatMilliseconds(1000)).toBe('1,000ms');
- expect(formatMilliseconds(10000)).toBe('10,000ms');
- expect(formatMilliseconds(1000000)).toBe('1,000,000ms');
- });
+ it('seconds', () => {
+ expect(seconds(1)).toBe('1s');
+ });
- it('decimalBytes', () => {
- const formatDecimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
-
- expect(formatDecimalBytes(1)).toBe('1B');
- expect(formatDecimalBytes(1, 1)).toBe('1.0B');
-
- expect(formatDecimalBytes(10)).toBe('10B');
- expect(formatDecimalBytes(10 ** 2)).toBe('100B');
- expect(formatDecimalBytes(10 ** 3)).toBe('1kB');
- expect(formatDecimalBytes(10 ** 4)).toBe('10kB');
- expect(formatDecimalBytes(10 ** 5)).toBe('100kB');
- expect(formatDecimalBytes(10 ** 6)).toBe('1MB');
- expect(formatDecimalBytes(10 ** 7)).toBe('10MB');
- expect(formatDecimalBytes(10 ** 8)).toBe('100MB');
- expect(formatDecimalBytes(10 ** 9)).toBe('1GB');
- expect(formatDecimalBytes(10 ** 10)).toBe('10GB');
- expect(formatDecimalBytes(10 ** 11)).toBe('100GB');
- });
+ it('milliseconds', () => {
+ expect(milliseconds(1)).toBe('1ms');
+ expect(milliseconds(100)).toBe('100ms');
+ expect(milliseconds(1000)).toBe('1,000ms');
+ expect(milliseconds(10_000)).toBe('10,000ms');
+ expect(milliseconds(1_000_000)).toBe('1,000,000ms');
+ });
- it('kilobytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1)).toBe('1kB');
- expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1, 1)).toBe('1.0kB');
- });
+ it('decimalBytes', () => {
+ expect(decimalBytes(1)).toBe('1B');
+ expect(decimalBytes(1, 1)).toBe('1.0B');
+
+ expect(decimalBytes(10)).toBe('10B');
+ expect(decimalBytes(10 ** 2)).toBe('100B');
+ expect(decimalBytes(10 ** 3)).toBe('1kB');
+ expect(decimalBytes(10 ** 4)).toBe('10kB');
+ expect(decimalBytes(10 ** 5)).toBe('100kB');
+ expect(decimalBytes(10 ** 6)).toBe('1MB');
+ expect(decimalBytes(10 ** 7)).toBe('10MB');
+ expect(decimalBytes(10 ** 8)).toBe('100MB');
+ expect(decimalBytes(10 ** 9)).toBe('1GB');
+ expect(decimalBytes(10 ** 10)).toBe('10GB');
+ expect(decimalBytes(10 ** 11)).toBe('100GB');
+ });
- it('megabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1)).toBe('1MB');
- expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1, 1)).toBe('1.0MB');
- });
+ it('kilobytes', () => {
+ expect(kilobytes(1)).toBe('1kB');
+ expect(kilobytes(1, 1)).toBe('1.0kB');
+ });
- it('gigabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1)).toBe('1GB');
- expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1, 1)).toBe('1.0GB');
- });
+ it('megabytes', () => {
+ expect(megabytes(1)).toBe('1MB');
+ expect(megabytes(1, 1)).toBe('1.0MB');
+ });
- it('terabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1)).toBe('1TB');
- expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1, 1)).toBe('1.0TB');
- });
+ it('gigabytes', () => {
+ expect(gigabytes(1)).toBe('1GB');
+ expect(gigabytes(1, 1)).toBe('1.0GB');
+ });
- it('petabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1)).toBe('1PB');
- expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1, 1)).toBe('1.0PB');
- });
+ it('terabytes', () => {
+ expect(terabytes(1)).toBe('1TB');
+ expect(terabytes(1, 1)).toBe('1.0TB');
+ });
- it('bytes', () => {
- const formatBytes = getFormatter(SUPPORTED_FORMATS.bytes);
+ it('petabytes', () => {
+ expect(petabytes(1)).toBe('1PB');
+ expect(petabytes(1, 1)).toBe('1.0PB');
+ });
- expect(formatBytes(1)).toBe('1B');
- expect(formatBytes(1, 1)).toBe('1.0B');
+ it('bytes', () => {
+ expect(bytes(1)).toBe('1B');
+ expect(bytes(1, 1)).toBe('1.0B');
- expect(formatBytes(10)).toBe('10B');
- expect(formatBytes(100)).toBe('100B');
- expect(formatBytes(1000)).toBe('1,000B');
+ expect(bytes(10)).toBe('10B');
+ expect(bytes(100)).toBe('100B');
+ expect(bytes(1000)).toBe('1,000B');
- expect(formatBytes(1 * 1024)).toBe('1KiB');
- expect(formatBytes(1 * 1024 ** 2)).toBe('1MiB');
- expect(formatBytes(1 * 1024 ** 3)).toBe('1GiB');
- });
+ expect(bytes(1 * 1024)).toBe('1KiB');
+ expect(bytes(1 * 1024 ** 2)).toBe('1MiB');
+ expect(bytes(1 * 1024 ** 3)).toBe('1GiB');
+ });
- it('kibibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1)).toBe('1KiB');
- expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1, 1)).toBe('1.0KiB');
- });
+ it('kibibytes', () => {
+ expect(kibibytes(1)).toBe('1KiB');
+ expect(kibibytes(1, 1)).toBe('1.0KiB');
+ });
- it('mebibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1)).toBe('1MiB');
- expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1, 1)).toBe('1.0MiB');
- });
+ it('mebibytes', () => {
+ expect(mebibytes(1)).toBe('1MiB');
+ expect(mebibytes(1, 1)).toBe('1.0MiB');
+ });
- it('gibibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1)).toBe('1GiB');
- expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1, 1)).toBe('1.0GiB');
- });
+ it('gibibytes', () => {
+ expect(gibibytes(1)).toBe('1GiB');
+ expect(gibibytes(1, 1)).toBe('1.0GiB');
+ });
- it('tebibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1)).toBe('1TiB');
- expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1, 1)).toBe('1.0TiB');
- });
+ it('tebibytes', () => {
+ expect(tebibytes(1)).toBe('1TiB');
+ expect(tebibytes(1, 1)).toBe('1.0TiB');
+ });
- it('pebibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1)).toBe('1PiB');
- expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1, 1)).toBe('1.0PiB');
- });
+ it('pebibytes', () => {
+ expect(pebibytes(1)).toBe('1PiB');
+ expect(pebibytes(1, 1)).toBe('1.0PiB');
});
- describe('when get formatter format is incorrect', () => {
- it('formatter fails', () => {
- expect(() => getFormatter('not-supported')(1)).toThrow();
+ describe('getFormatter', () => {
+ it.each([
+ [1],
+ [10],
+ [200],
+ [100],
+ [1000],
+ [10_000],
+ [100_000],
+ [1_000_000],
+ [10 ** 6],
+ [10 ** 9],
+ [0.1],
+ [0.5],
+ [0.888888],
+ ])('formatting functions yield the same result as getFormatter for %d', (value) => {
+ expect(number(value)).toBe(getFormatter(SUPPORTED_FORMATS.number)(value));
+ expect(percent(value)).toBe(getFormatter(SUPPORTED_FORMATS.percent)(value));
+ expect(percentHundred(value)).toBe(getFormatter(SUPPORTED_FORMATS.percentHundred)(value));
+
+ expect(seconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.seconds)(value));
+ expect(milliseconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.milliseconds)(value));
+
+ expect(decimalBytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.decimalBytes)(value));
+ expect(kilobytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kilobytes)(value));
+ expect(megabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.megabytes)(value));
+ expect(gigabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gigabytes)(value));
+ expect(terabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.terabytes)(value));
+ expect(petabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.petabytes)(value));
+
+ expect(bytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.bytes)(value));
+ expect(kibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kibibytes)(value));
+ expect(mebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.mebibytes)(value));
+ expect(gibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gibibytes)(value));
+ expect(tebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.tebibytes)(value));
+ expect(pebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.pebibytes)(value));
+
+ expect(engineering(value)).toBe(getFormatter(SUPPORTED_FORMATS.engineering)(value));
+ });
+
+ describe('when get formatter format is incorrect', () => {
+ it('formatter fails', () => {
+ expect(() => getFormatter('not-supported')(1)).toThrow();
+ });
});
});
});
diff --git a/spec/frontend/locale/index_spec.js b/spec/frontend/locale/index_spec.js
index d65d7c195b2..a08be502735 100644
--- a/spec/frontend/locale/index_spec.js
+++ b/spec/frontend/locale/index_spec.js
@@ -1,5 +1,5 @@
import { setLanguage } from 'helpers/locale_helper';
-import { createDateTimeFormat, languageCode } from '~/locale';
+import { createDateTimeFormat, formatNumber, languageCode } from '~/locale';
describe('locale', () => {
afterEach(() => setLanguage(null));
@@ -27,4 +27,68 @@ describe('locale', () => {
expect(dateFormat.format(new Date(2015, 6, 3))).toBe('July 3, 2015');
});
});
+
+ describe('formatNumber', () => {
+ it('formats numbers', () => {
+ expect(formatNumber(1)).toBe('1');
+ expect(formatNumber(12345)).toBe('12,345');
+ });
+
+ it('formats bigint numbers', () => {
+ expect(formatNumber(123456789123456789n)).toBe('123,456,789,123,456,789');
+ });
+
+ it('formats numbers with options', () => {
+ expect(formatNumber(1, { style: 'percent' })).toBe('100%');
+ expect(formatNumber(1, { style: 'currency', currency: 'USD' })).toBe('$1.00');
+ });
+
+ it('formats localized numbers', () => {
+ expect(formatNumber(12345, {}, 'es')).toBe('12.345');
+ });
+
+ it('formats NaN', () => {
+ expect(formatNumber(NaN)).toBe('NaN');
+ });
+
+ it('formats infinity', () => {
+ expect(formatNumber(Number.POSITIVE_INFINITY)).toBe('∞');
+ });
+
+ it('formats negative infinity', () => {
+ expect(formatNumber(Number.NEGATIVE_INFINITY)).toBe('-∞');
+ });
+
+ it('formats EPSILON', () => {
+ expect(formatNumber(Number.EPSILON)).toBe('0');
+ });
+
+ describe('non-number values should pass through', () => {
+ it('undefined', () => {
+ expect(formatNumber(undefined)).toBe(undefined);
+ });
+
+ it('null', () => {
+ expect(formatNumber(null)).toBe(null);
+ });
+
+ it('arrays', () => {
+ expect(formatNumber([])).toEqual([]);
+ });
+
+ it('objects', () => {
+ expect(formatNumber({ a: 'b' })).toEqual({ a: 'b' });
+ });
+ });
+
+ describe('when in a different locale', () => {
+ beforeEach(() => {
+ setLanguage('es');
+ });
+
+ it('formats localized numbers', () => {
+ expect(formatNumber(12345)).toBe('12.345');
+ });
+ });
+ });
});
diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js
index e21626456e2..c44918ceaf3 100644
--- a/spec/frontend/tooltips/components/tooltips_spec.js
+++ b/spec/frontend/tooltips/components/tooltips_spec.js
@@ -217,4 +217,14 @@ describe('tooltips/components/tooltips.vue', () => {
wrapper.destroy();
expect(observersCount()).toBe(0);
});
+
+ it('exposes hidden event', async () => {
+ buildWrapper();
+ wrapper.vm.addTooltips([createTooltipTarget()]);
+
+ await wrapper.vm.$nextTick();
+
+ wrapper.findComponent(GlTooltip).vm.$emit('hidden');
+ expect(wrapper.emitted('hidden')).toHaveLength(1);
+ });
});
diff --git a/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb
index 8a93d43320a..6420798d6f5 100644
--- a/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
+RSpec.describe Gitlab::Ci::Variables::Collection::Sort do
describe '#initialize with non-Collection value' do
let_it_be(:project_with_flag_disabled) { create(:project) }
let_it_be(:project_with_flag_enabled) { create(:project) }
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
end
context 'when FF :variable_inside_variable is disabled' do
- subject { Gitlab::Ci::Variables::Collection::Sorted.new([], project_with_flag_disabled) }
+ subject { Gitlab::Ci::Variables::Collection::Sort.new([], project_with_flag_disabled) }
it 'raises ArgumentError' do
expect { subject }.to raise_error(ArgumentError, /Collection object was expected/)
@@ -20,7 +20,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
end
context 'when FF :variable_inside_variable is enabled' do
- subject { Gitlab::Ci::Variables::Collection::Sorted.new([], project_with_flag_enabled) }
+ subject { Gitlab::Ci::Variables::Collection::Sort.new([], project_with_flag_enabled) }
it 'raises ArgumentError' do
expect { subject }.to raise_error(ArgumentError, /Collection object was expected/)
@@ -83,7 +83,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
with_them do
let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(collection, project_with_flag_disabled) }
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection, project_with_flag_disabled) }
it 'does not report error' do
expect(subject.errors).to eq(nil)
@@ -135,7 +135,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
with_them do
let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(collection, project_with_flag_enabled) }
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection, project_with_flag_enabled) }
it 'errors matches expected validation result' do
expect(subject.errors).to eq(validation_result)
@@ -149,7 +149,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
end
end
- describe '#sort' do
+ describe '#collection' do
context 'when FF :variable_inside_variable is disabled' do
before do
stub_feature_flags(variable_inside_variable: false)
@@ -202,7 +202,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
let_it_be(:project) { create(:project) }
let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(collection, project).sort }
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection, project).collection }
it 'does not expand variables' do
is_expected.to be(collection)
@@ -280,7 +280,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
let_it_be(:project) { create(:project) }
let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(collection, project).sort }
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection, project).collection }
it 'returns correctly sorted variables' do
expect(subject.map { |var| var[:key] }).to eq(result)
diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb
index ac84313ad9f..670d836b4e8 100644
--- a/spec/lib/gitlab/ci/variables/collection_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection_spec.rb
@@ -121,4 +121,49 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
expect(collection.to_hash).not_to include(TEST1: 'test-1')
end
end
+
+ describe '#sorted_collection' do
+ let!(:project) { create(:project) }
+
+ subject { collection.sorted_collection(project) }
+
+ context 'when FF :variable_inside_variable is disabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: false)
+ end
+
+ let(:collection) do
+ described_class.new
+ .append(key: 'A', value: 'test-$B')
+ .append(key: 'B', value: 'test-$C')
+ .append(key: 'C', value: 'test')
+ end
+
+ it { is_expected.to be(collection) }
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: [project])
+ end
+
+ let(:collection) do
+ described_class.new
+ .append(key: 'A', value: 'test-$B')
+ .append(key: 'B', value: 'test-$C')
+ .append(key: 'C', value: 'test')
+ end
+
+ it { is_expected.to be_a(Gitlab::Ci::Variables::Collection) }
+
+ it 'returns sorted collection' do
+ expect(subject.to_a).to eq(
+ [
+ { key: 'C', value: 'test', masked: false, public: true },
+ { key: 'B', value: 'test-$C', masked: false, public: true },
+ { key: 'A', value: 'test-$B', masked: false, public: true }
+ ])
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/bulk_update_spec.rb b/spec/lib/gitlab/database/bulk_update_spec.rb
index f2a7d6e69d8..dbafada26ca 100644
--- a/spec/lib/gitlab/database/bulk_update_spec.rb
+++ b/spec/lib/gitlab/database/bulk_update_spec.rb
@@ -13,8 +13,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do
i_a, i_b = create_list(:issue, 2)
{
- i_a => { title: 'Issue a' },
- i_b => { title: 'Issue b' }
+ i_a => { title: 'Issue a' },
+ i_b => { title: 'Issue b' }
}
end
@@ -51,7 +51,7 @@ RSpec.describe Gitlab::Database::BulkUpdate do
it 'is possible to update all objects in a single query' do
users = create_list(:user, 3)
- mapping = users.zip(%w(foo bar baz)).to_h do |u, name|
+ mapping = users.zip(%w[foo bar baz]).to_h do |u, name|
[u, { username: name, admin: true }]
end
@@ -61,13 +61,13 @@ RSpec.describe Gitlab::Database::BulkUpdate do
# We have optimistically updated the values
expect(users).to all(be_admin)
- expect(users.map(&:username)).to eq(%w(foo bar baz))
+ expect(users.map(&:username)).to eq(%w[foo bar baz])
users.each(&:reset)
# The values are correct on reset
expect(users).to all(be_admin)
- expect(users.map(&:username)).to eq(%w(foo bar baz))
+ expect(users.map(&:username)).to eq(%w[foo bar baz])
end
it 'is possible to update heterogeneous sets' do
@@ -79,8 +79,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do
mapping = {
mr_a => { title: 'MR a' },
- i_a => { title: 'Issue a' },
- i_b => { title: 'Issue b' }
+ i_a => { title: 'Issue a' },
+ i_b => { title: 'Issue b' }
}
expect do
@@ -99,8 +99,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do
i_a, i_b = create_list(:issue, 2)
mapping = {
- i_a => { title: 'Issue a' },
- i_b => { title: 'Issue b' }
+ i_a => { title: 'Issue a' },
+ i_b => { title: 'Issue b' }
}
described_class.execute(%i[title], mapping)
@@ -113,23 +113,19 @@ RSpec.describe Gitlab::Database::BulkUpdate do
include_examples 'basic functionality'
context 'when prepared statements are configured differently to the normal test environment' do
- # rubocop: disable RSpec/LeakyConstantDeclaration
- # This cop is disabled because you cannot call establish_connection on
- # an anonymous class.
- class ActiveRecordBasePreparedStatementsInverted < ActiveRecord::Base
- def self.abstract_class?
- true # So it gets its own connection
+ before do
+ klass = Class.new(ActiveRecord::Base) do
+ def self.abstract_class?
+ true # So it gets its own connection
+ end
end
- end
- # rubocop: enable RSpec/LeakyConstantDeclaration
- before_all do
+ stub_const('ActiveRecordBasePreparedStatementsInverted', klass)
+
c = ActiveRecord::Base.connection.instance_variable_get(:@config)
inverted = c.merge(prepared_statements: !ActiveRecord::Base.connection.prepared_statements)
ActiveRecordBasePreparedStatementsInverted.establish_connection(inverted)
- end
- before do
allow(ActiveRecord::Base).to receive(:connection_specification_name)
.and_return(ActiveRecordBasePreparedStatementsInverted.connection_specification_name)
end
diff --git a/spec/models/onboarding_progress_spec.rb b/spec/models/onboarding_progress_spec.rb
index 0aa19345a25..8da1bf773e7 100644
--- a/spec/models/onboarding_progress_spec.rb
+++ b/spec/models/onboarding_progress_spec.rb
@@ -182,6 +182,30 @@ RSpec.describe OnboardingProgress do
end
end
+ describe '.not_completed?' do
+ subject { described_class.not_completed?(namespace.id, action) }
+
+ context 'when the namespace has not yet been onboarded' do
+ it { is_expected.to be(false) }
+ end
+
+ context 'when the namespace has been onboarded but not registered the action yet' do
+ before do
+ described_class.onboard(namespace)
+ end
+
+ it { is_expected.to be(true) }
+
+ context 'when the action has been registered' do
+ before do
+ described_class.register(namespace, action)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+ end
+
describe '.column_name' do
subject { described_class.column_name(action) }
diff --git a/spec/services/onboarding_progress_service_spec.rb b/spec/services/onboarding_progress_service_spec.rb
index 340face4ae8..abfe960984b 100644
--- a/spec/services/onboarding_progress_service_spec.rb
+++ b/spec/services/onboarding_progress_service_spec.rb
@@ -3,6 +3,47 @@
require 'spec_helper'
RSpec.describe OnboardingProgressService do
+ describe '.async' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:action) { :git_pull }
+
+ subject(:execute_service) { described_class.async(namespace.id).execute(action: action) }
+
+ context 'when not onboarded' do
+ it 'does not schedule a worker' do
+ expect(Namespaces::OnboardingProgressWorker).not_to receive(:perform_async)
+
+ execute_service
+ end
+ end
+
+ context 'when onboarded' do
+ before do
+ OnboardingProgress.onboard(namespace)
+ end
+
+ context 'when action is already completed' do
+ before do
+ OnboardingProgress.register(namespace, action)
+ end
+
+ it 'does not schedule a worker' do
+ expect(Namespaces::OnboardingProgressWorker).not_to receive(:perform_async)
+
+ execute_service
+ end
+ end
+
+ context 'when action is not yet completed' do
+ it 'schedules a worker' do
+ expect(Namespaces::OnboardingProgressWorker).to receive(:perform_async)
+
+ execute_service
+ end
+ end
+ end
+ end
+
describe '#execute' do
let(:namespace) { create(:namespace, parent: root_namespace) }
let(:root_namespace) { nil }
diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
index 7f49d20c83e..9c8006ce4f1 100644
--- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
+++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
@@ -9,6 +9,8 @@ RSpec.shared_examples 'multiple issue boards' do
login_as(user)
+ stub_feature_flags(board_new_list: false)
+
visit boards_path
wait_for_requests
end
diff --git a/spec/workers/namespaces/onboarding_progress_worker_spec.rb b/spec/workers/namespaces/onboarding_progress_worker_spec.rb
new file mode 100644
index 00000000000..76ac078ddcf
--- /dev/null
+++ b/spec/workers/namespaces/onboarding_progress_worker_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespaces::OnboardingProgressWorker, '#perform' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:action) { 'git_pull' }
+
+ it_behaves_like 'records an onboarding progress action', :git_pull do
+ include_examples 'an idempotent worker' do
+ subject { described_class.new.perform(namespace.id, action) }
+ end
+ end
+
+ it_behaves_like 'does not record an onboarding progress action' do
+ subject { described_class.new.perform(namespace.id, nil) }
+ end
+
+ it_behaves_like 'does not record an onboarding progress action' do
+ subject { described_class.new.perform(nil, action) }
+ end
+end