diff options
Diffstat (limited to 'app/assets')
18 files changed, 565 insertions, 256 deletions
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; |