diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-25 18:08:50 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-25 18:08:50 +0300 |
commit | e06d0e779673d745972863302858105aad9032e5 (patch) | |
tree | 0ff35b27a949a164f586613004b4abfe33e7d20e | |
parent | f7dae0cdcb70ecb71c1d65f099e9d96b27a4548c (diff) |
Add latest changes from gitlab-org/gitlab@master
47 files changed, 1199 insertions, 274 deletions
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index a6deb656b37..67046715e9b 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; -import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlLabel, GlTooltip } from '@gitlab/ui'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; import { s__, __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -14,6 +14,7 @@ import IssueCount from './issue_count.vue'; import boardsStore from '../stores/boards_store'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; import { ListType } from '../constants'; +import { isScopedLabel } from '~/lib/utils/common_utils'; export default Vue.extend({ components: { @@ -24,6 +25,7 @@ export default Vue.extend({ GlButtonGroup, IssueCount, GlButton, + GlLabel, GlTooltip, }, directives: { @@ -95,6 +97,9 @@ export default Vue.extend({ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; }, + helpLink() { + return boardsStore.scopedLabels.helpLink; + }, }, watch: { filter: { @@ -145,6 +150,10 @@ export default Vue.extend({ } }, methods: { + showScopedLabels(label) { + return boardsStore.scopedLabels.enabled && isScopedLabel(label); + }, + showNewIssueForm() { this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; }, diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index ba1fe9202fc..9b67126bee2 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Vue from 'vue'; +import { GlLabel } from '@gitlab/ui'; import Flash from '~/flash'; import { sprintf, __ } from '~/locale'; import Sidebar from '~/right_sidebar'; @@ -22,6 +23,7 @@ export default Vue.extend({ components: { AssigneeTitle, Assignees, + GlLabel, SidebarEpicsSelect: () => import('ee_component/sidebar/components/sidebar_item_epics_select.vue'), RemoveBtn, @@ -67,6 +69,9 @@ export default Vue.extend({ selectedLabels() { return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : ''; }, + helpLink() { + return boardsStore.scopedLabels.helpLink; + }, }, watch: { detail: { @@ -147,8 +152,5 @@ export default Vue.extend({ showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, - helpLink() { - return boardsStore.scopedLabels.helpLink; - }, }, }); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index ad8095e1ae3..a70bab013c6 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { isString, mapValues, isNumber, reduce } from 'lodash'; import * as timeago from 'timeago.js'; import dateFormat from 'dateformat'; import { languageCode, s__, __, n__ } from '../../locale'; @@ -79,7 +79,7 @@ export const getDayName = date => * @returns {String} */ export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { - if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { + if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { throw new Error(__('Invalid date')); } return dateFormat(datetime, format); @@ -497,7 +497,7 @@ export const parseSeconds = ( let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE); - return _.mapObject(timePeriodConstraints, minutesPerPeriod => { + return mapValues(timePeriodConstraints, minutesPerPeriod => { if (minutesPerPeriod === 0) { return 0; } @@ -516,7 +516,7 @@ export const parseSeconds = ( * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days' */ export const stringifyTime = (timeObject, fullNameFormat = false) => { - const reducedTime = _.reduce( + const reducedTime = reduce( timeObject, (memo, unitValue, unitName) => { const isNonZero = Boolean(unitValue); @@ -642,7 +642,7 @@ export const dayAfter = date => new Date(newDate(date).setDate(date.getDate() + * @return {String} approximated time */ export const approximateDuration = (seconds = 0) => { - if (!_.isNumber(seconds) || seconds < 0) { + if (!isNumber(seconds) || seconds < 0) { return ''; } diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js index 8f0afa3467d..b1dd562f63a 100644 --- a/app/assets/javascripts/lib/utils/highlight.js +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -1,5 +1,4 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import _ from 'underscore'; import sanitize from 'sanitize-html'; /** @@ -17,11 +16,11 @@ import sanitize from 'sanitize-html'; * @param {String} matchSuffix The string to insert at the end of a match */ export default function highlight(string, match = '', matchPrefix = '<b>', matchSuffix = '</b>') { - if (_.isUndefined(string) || _.isNull(string)) { + if (!string) { return ''; } - if (_.isUndefined(match) || _.isNull(match) || match === '') { + if (!match) { return string; } @@ -34,7 +33,7 @@ export default function highlight(string, match = '', matchPrefix = '<b>', match return sanitizedValue .split('') .map((character, i) => { - if (_.contains(occurrences, i)) { + if (occurrences.includes(i)) { return `${matchPrefix}${character}${matchSuffix}`; } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index a03fedcd7e7..9ed286826cc 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { isString } from 'lodash'; /** * Adds a , to a string composed by numbers, at every 3 chars. @@ -199,7 +199,7 @@ export const splitCamelCase = string => * i.e. "My Group / My Subgroup / My Project" */ export const truncateNamespace = (string = '') => { - if (_.isNull(string) || !_.isString(string)) { + if (string === null || !isString(string)) { return ''; } diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js new file mode 100644 index 00000000000..432a9254558 --- /dev/null +++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js @@ -0,0 +1,119 @@ +/** + * Formats a number as 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 + * 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 + */ +function formatNumber( + value, + { fractionDigits = undefined, valueFactor = 1, style = undefined, maxLength = undefined }, +) { + if (value === null) { + return ''; + } + + const num = value * valueFactor; + const formatted = num.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + style, + }); + + if (maxLength !== undefined && formatted.length > maxLength) { + // 123456 becomes 1.23e+8 + return num.toExponential(2); + } + return formatted; +} + +/** + * Formats a number as a string scaling it up according to units. + * + * While the number is scaled down, the units are scaled up. + * + * @param {Array} List of units of the scale + * @param {Number} unitFactor - Factor of the scale for each + * unit after which the next unit is used scaled. + */ +const scaledFormatter = (units, unitFactor = 1000) => { + if (unitFactor === 0) { + return new RangeError(`unitFactor cannot have the value 0.`); + } + + return (value, fractionDigits) => { + if (value === null) { + return ''; + } + if ( + value === Number.NEGATIVE_INFINITY || + value === Number.POSITIVE_INFINITY || + Number.isNaN(value) + ) { + return value.toLocaleString(undefined); + } + + let num = value; + let scale = 0; + const limit = units.length; + + while (Math.abs(num) >= unitFactor) { + scale += 1; + num /= unitFactor; + + if (scale >= limit) { + return 'NA'; + } + } + + const unit = units[scale]; + + return `${formatNumber(num, { fractionDigits })}${unit}`; + }; +}; + +/** + * 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 })}`; + }; +}; + +/** + * 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}`; + }; +}; + +/** + * Returns a function that formats a number scaled using SI units notation. + */ +export const scaledSIFormatter = (unit = '', prefixOffset = 0) => { + const fractional = ['y', 'z', 'a', 'f', 'p', 'n', 'µ', 'm']; + const multiplicative = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; + const symbols = [...fractional, '', ...multiplicative]; + + const units = symbols.slice(fractional.length + prefixOffset).map(prefix => { + return `${prefix}${unit}`; + }); + + if (!units.length) { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + throw new RangeError('The unit cannot be converted, please try a different scale'); + } + + return scaledFormatter(units); +}; diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js new file mode 100644 index 00000000000..daf70ebb5d7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -0,0 +1,167 @@ +import { s__ } from '~/locale'; + +import { suffixFormatter, scaledSIFormatter, numberFormatter } from './formatter_factory'; + +/** + * Supported formats + */ +export const SUPPORTED_FORMATS = { + // Number + number: 'number', + percent: 'percent', + percentHundred: 'percentHundred', + + // Duration + seconds: 'seconds', + miliseconds: 'miliseconds', + + // Digital + bytes: 'bytes', + kilobytes: 'kilobytes', + megabytes: 'megabytes', + gigabytes: 'gigabytes', + terabytes: 'terabytes', + petabytes: 'petabytes', +}; + +/** + * Returns a function that formats number to different units + * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number. + * + * + */ +export const getFormatter = (format = SUPPORTED_FORMATS.number) => { + // 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 lenght of formatted number + * if lenght 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 lenght of formatted number + * if lenght 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 lenght of formatted number + * if lenght 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 lenght of formatted number + * if lenght is exceeded, exponential format is used. + */ + return suffixFormatter(s__('Units|s')); + } + if (format === SUPPORTED_FORMATS.miliseconds) { + /** + * Formats a number of miliseconds 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 lenght of formatted number + * if lenght is exceeded, exponential format is used. + */ + return suffixFormatter(s__('Units|ms')); + } + + // Digital + 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 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); + } + // Fail so client library addresses issue + throw TypeError(`${format} is not a valid number format`); +}; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 8abb16f58ca..1c39fb072d9 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -4,7 +4,7 @@ import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; -import { roundOffFloat } from '~/lib/utils/common_utils'; +import { getFormatter } from '~/lib/utils/unit_format'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; import { @@ -37,6 +37,8 @@ const events = { datazoom: 'datazoom', }; +const yValFormatter = getFormatter('number'); + export default { components: { GlAreaChart, @@ -171,7 +173,7 @@ export default { boundaryGap: [0.1, 0.1], scale: true, axisLabel: { - formatter: num => roundOffFloat(num, 3).toString(), + formatter: num => yValFormatter(num, 3), }, ...yAxis, }; @@ -313,7 +315,8 @@ export default { this.tooltip.commitUrl = deploy.commitUrl; } else { const { seriesName, color, dataIndex } = dataPoint; - const value = yVal.toFixed(3); + const value = yValFormatter(yVal, 3); + this.tooltip.content.push({ name: seriesName, dataIndex, diff --git a/app/assets/javascripts/notes/components/discussion_resolve_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_button.vue index 2b29d710236..77f6f1e51c5 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_button.vue @@ -1,6 +1,11 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; + export default { name: 'ResolveDiscussionButton', + components: { + GlLoadingIcon, + }, props: { isResolving: { type: Boolean, @@ -17,12 +22,7 @@ export default { <template> <button ref="button" type="button" class="btn btn-default ml-sm-2" @click="$emit('onClick')"> - <i - v-if="isResolving" - ref="isResolvingIcon" - aria-hidden="true" - class="fa fa-spinner fa-spin" - ></i> + <gl-loading-icon v-if="isResolving" ref="isResolvingIcon" inline /> {{ buttonTitle }} </button> </template> diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index ac8437c23ca..f8c46a4495e 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -13,16 +13,14 @@ .page-title, .modal-title { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + .modal-title-with-label span { vertical-align: middle; display: inline-block; } - - .color-label { - font-size: $gl-font-size; - padding: $gl-vert-padding $label-padding-modal; - vertical-align: middle; - } } .modal-title { diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss index 5e05311041c..b7a99d421c9 100644 --- a/app/assets/stylesheets/framework/spinner.scss +++ b/app/assets/stylesheets/framework/spinner.scss @@ -51,7 +51,8 @@ } .btn { - .spinner { + .spinner, + .gl-spinner { vertical-align: text-bottom; } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 43636f65eb8..fd56f655c0a 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -86,14 +86,19 @@ } .issuable-show-labels { - a { + .gl-label { margin-bottom: 5px; margin-right: 5px; + } + + a { display: inline-block; .color-label { padding: 4px $grid-size; border-radius: $label-border-radius; + margin-right: 4px; + margin-bottom: 4px; } &:hover .color-label { @@ -159,9 +164,25 @@ .avatar { border-color: rgba($gray-normal, 0.2); } + } + } + a.gl-label-icon { + color: $gray-500; + } + + .gl-label .gl-label-link:hover { + text-decoration: none; + color: inherit; + + .gl-label-text:last-of-type { + text-decoration: underline; } + } + .gl-label .gl-label-icon:hover { + text-decoration: none; + color: $gray-500; } .btn-link { @@ -800,11 +821,23 @@ a { color: $gl-text-color; + } - .fa { - color: $gl-text-color-secondary; + .gl-label-link { + color: inherit; + + &:hover { + text-decoration: none; + + .gl-label-text:last-of-type { + text-decoration: underline; + } } } + + .gl-label-icon { + color: $gray-500; + } } @media(max-width: map-get($grid-breakpoints, lg)-1) { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 7d5e185834b..91ac150f6e2 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -127,6 +127,11 @@ .color-label { padding: $gl-padding-4 $grid-size; } + + .prepend-description-left { + vertical-align: top; + line-height: 24px; + } } .prioritized-labels { @@ -305,10 +310,13 @@ width: 150px; flex-shrink: 0; - .badge { - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; + .scoped-label-wrapper, + .gl-label { + line-height: $gl-line-height; + } + + .gl-label-scoped .gl-label-text:last-of-type { + padding-right: 22px; } } @@ -445,10 +453,19 @@ } } +.gl-label-scoped { + box-shadow: 0 0 0 2px currentColor inset; + + &.gl-label-sm { + box-shadow: 0 0 0 1px inset; + } +} + // Label inside title of Delete Label Modal .modal-header .page-title { .scoped-label-wrapper { - .scoped-label { + .scoped-label, + .gl-label-icon { line-height: 20px; } diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index b399662997c..cd1154b88a5 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -59,9 +59,19 @@ $status-box-line-height: 26px; } .issuable-row { - span a { - color: $gl-text-color; - word-wrap: break-word; + span { + a { + color: $gl-text-color; + word-wrap: break-word; + } + + .gl-label-link { + color: inherit; + } + + .gl-label-icon { + color: $gray-500; + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 347addcec37..aaecbd6ff00 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -283,7 +283,7 @@ $note-form-margin-left: 72px; text-transform: lowercase; } - a { + a:not(.gl-link) { color: $blue-600; } @@ -671,6 +671,16 @@ $note-form-margin-left: 72px; a:hover { text-decoration: underline; } + + .gl-label-link:hover, + .gl-label-icon:hover { + text-decoration: none; + color: inherit; + + .gl-label-text:last-of-type { + text-decoration: underline; + } + } } /** diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 91127df318e..97232def91c 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -36,37 +36,42 @@ module LabelsHelper # link_to_label(label) { "My Custom Label Text" } # # Returns a String - def link_to_label(label, type: :issue, tooltip: true, css_class: nil, &block) + def link_to_label(label, type: :issue, tooltip: true, small: false, &block) link = label.filter_path(type: type) if block_given? - link_to link, class: css_class, &block + link_to link, &block else - render_label(label, tooltip: tooltip, link: link, css: css_class) + render_label(label, link: link, tooltip: tooltip, small: small) end end - def render_label(label, tooltip: true, link: nil, css: nil, dataset: nil) - # if scoped label is used then EE wraps label tag with scoped label - # doc link - html = render_colored_label(label, tooltip: tooltip) - html = link_to(html, link, class: css, data: dataset) if link + def render_label(label, link: nil, tooltip: true, dataset: nil, small: false) + html = render_colored_label(label) - html + if link + title = label_tooltip_title(label) if tooltip + html = render_label_link(html, link: link, title: title, dataset: dataset) + end + + wrap_label_html(html, small: small, label: label) end - def render_colored_label(label, label_suffix: '', tooltip: true, title: nil) - text_color = text_color_for_bg(label.color) - title ||= tooltip ? label_tooltip_title(label) : label.name + def render_colored_label(label, suffix: '') + render_label_text( + label.name, + suffix: suffix, + css_class: text_color_class_for_bg(label.color), + bg_color: label.color + ) + end - # Intentionally not using content_tag here so that this method can be called - # by LabelReferenceFilter - span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) + - %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) + - %(title="#{ERB::Util.html_escape_once(title)}" data-container="body">) + - %(#{ERB::Util.html_escape_once(label.name)}#{label_suffix}</span>) + # We need the `label` argument here for EE + def wrap_label_html(label_html, small:, label:) + wrapper_classes = %w(gl-label) + wrapper_classes << 'gl-label-sm' if small - span.html_safe + %(<span class="#{wrapper_classes.join(' ')}">#{label_html}</span>).html_safe end def label_tooltip_title(label) @@ -109,6 +114,20 @@ module LabelsHelper end end + def text_color_class_for_bg(bg_color) + if bg_color.length == 4 + r, g, b = bg_color[1, 4].scan(/./).map { |v| (v * 2).hex } + else + r, g, b = bg_color[1, 7].scan(/.{2}/).map(&:hex) + end + + if (r + g + b) > 500 + 'gl-label-text-dark' + else + 'gl-label-text-light' + end + end + def text_color_for_bg(bg_color) if bg_color.length == 4 r, g, b = bg_color[1, 4].scan(/./).map { |v| (v * 2).hex } @@ -246,6 +265,31 @@ module LabelsHelper def issuable_types ['issues', 'merge requests'] end + + private + + def render_label_link(label_html, link:, title:, dataset:) + classes = %w(gl-link gl-label-link) + dataset ||= {} + + if title.present? + classes << 'has-tooltip' + dataset.merge!(html: true, title: title) + end + + link_to(label_html, link, class: classes.join(' '), data: dataset) + end + + def render_label_text(name, suffix: '', css_class: nil, bg_color: nil) + <<~HTML.chomp.html_safe + <span + class="gl-label-text #{css_class}" + data-container="body" + data-html="true" + #{"style=\"background-color: #{bg_color}\"" if bg_color} + >#{ERB::Util.html_escape_once(name)}#{suffix}</span> + HTML + end end LabelsHelper.prepend_if_ee('EE::LabelsHelper') diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 7e76d324bdc..6e890de924e 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -11,15 +11,15 @@ module Clusters self.table_name = 'clusters' APPLICATIONS = { - Applications::Helm.application_name => Applications::Helm, - Applications::Ingress.application_name => Applications::Ingress, - Applications::CertManager.application_name => Applications::CertManager, - Applications::Crossplane.application_name => Applications::Crossplane, - Applications::Prometheus.application_name => Applications::Prometheus, - Applications::Runner.application_name => Applications::Runner, - Applications::Jupyter.application_name => Applications::Jupyter, - Applications::Knative.application_name => Applications::Knative, - Applications::ElasticStack.application_name => Applications::ElasticStack + Clusters::Applications::Helm.application_name => Clusters::Applications::Helm, + Clusters::Applications::Ingress.application_name => Clusters::Applications::Ingress, + Clusters::Applications::CertManager.application_name => Clusters::Applications::CertManager, + Clusters::Applications::Crossplane.application_name => Clusters::Applications::Crossplane, + Clusters::Applications::Prometheus.application_name => Clusters::Applications::Prometheus, + Clusters::Applications::Runner.application_name => Clusters::Applications::Runner, + Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, + Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, + Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index c53e19c922a..67cf212691f 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -101,7 +101,7 @@ module Issuable def create_milestone_note if milestone_changes_tracking_enabled? # Creates a synthetic note - ResourceEvents::ChangeMilestoneService.new(resource: issuable, user: current_user).execute + ResourceEvents::ChangeMilestoneService.new(issuable, current_user).execute else SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone) end diff --git a/app/services/resource_events/change_milestone_service.rb b/app/services/resource_events/change_milestone_service.rb index dd637bcc765..ea196822f74 100644 --- a/app/services/resource_events/change_milestone_service.rb +++ b/app/services/resource_events/change_milestone_service.rb @@ -4,7 +4,7 @@ module ResourceEvents class ChangeMilestoneService attr_reader :resource, :user, :event_created_at, :milestone - def initialize(resource:, user:, created_at: Time.now) + def initialize(resource, user, created_at: Time.now) @resource = resource @user = user @event_created_at = created_at diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index c8ab47888d0..a6c6b77c9dd 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -38,7 +38,7 @@ - if issue.labels.any? - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| - = link_to_label(label, css_class: 'label-link') + = link_to_label(label, small: true) = render_if_exists "projects/issues/issue_weight", issue: issue diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 36f19ee6175..744dca1c462 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -35,7 +35,7 @@ - if merge_request.labels.any? - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label| - = link_to_label(label, type: :merge_request, css_class: 'label-link') + = link_to_label(label, type: :merge_request, small: true) .issuable-meta %ul.controls.d-flex.align-items-end diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml index f37dd2cdf02..c6629cd33a5 100644 --- a/app/views/shared/_delete_label_modal.html.haml +++ b/app/views/shared/_delete_label_modal.html.haml @@ -2,7 +2,7 @@ .modal-dialog .modal-content .modal-header - %h3.page-title Delete #{render_label(label, tooltip: false)} ? + %h3.page-title Delete label: #{label.name} ? %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 3db96db73ce..e42d8650708 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -29,11 +29,14 @@ ":title" => '(list.assignee && list.assignee.username || "")' } @{{ list.assignee.username }} - %span.has-tooltip.badge.color-label.title.d-inline-block.mw-100.text-truncate.align-middle{ "v-if": "list.type === \"label\"", - ":title" => '(list.label ? list.label.description : "")', - data: { container: "body", placement: "bottom" }, - ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" } - {{ list.title }} + %gl-label{ "v-if" => " list.type === \"label\"", + ":background-color" => "list.label.color", + ":title" => "list.label.title", + ":description" => "list.label.description", + "tooltipPlacement" => "bottom", + ":size" => '(!list.isExpanded ? "sm" : "")', + ":scoped" => "showScopedLabels(list.label)", + ":scoped-labels-documentation-link" => "helpLink" } - if can?(current_user, :admin_list, current_board_parent) %board-delete{ "inline-template" => true, diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index c50826a7cda..a1088dc5222 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -8,15 +8,12 @@ %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } = _("None") %span{ "v-for" => "label in issue.labels" } - %span.d-inline-block.position-relative.scoped-label-wrapper{ "v-if" => "showScopedLabels(label)" } - %a{ href: '#' } - %span.badge.color-label.label{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } - {{ label.title }} - %a.label.scoped-label{ ":href" => "helpLink()" } - %i.fa.fa-question-circle{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } - %a{ href: "#", "v-else" => true } - .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } - {{ label.title }} + %gl-label{ ":key" => "label.id", + ":background-color" => "label.color", + ":title" => "label.title", + ":description" => "label.description", + ":scoped" => "showScopedLabels(label)", + ":scoped-labels-documentation-link" => "helpLink" } - if can_admin_issue? .selectbox diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index ae3ab2adfd0..965c72b82ba 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -21,7 +21,7 @@ %span.issuable-number= issuable.to_reference - labels.each do |label| - = render_label(label.present(issuable_subject: project), link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' })) + = render_label(label.present(issuable_subject: project), link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }), small: true) %span.assignee-icon - assignees.each do |assignee| diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index ecab037e378..4c930b90ce7 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -3,11 +3,9 @@ - options = { milestone_title: @milestone.title, label_name: label.title } %li.no-border - %span.label-row - %span.label-name - = render_label(label, tooltip: false, link: milestones_label_path(options)) - %span.prepend-description-left - = markdown_field(label, :description) + = render_label(label, tooltip: false, link: milestones_label_path(options)) + %span.prepend-description-left + = markdown_field(label, :description) .float-right.d-none.d-lg-block.d-xl-block = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do diff --git a/changelogs/unreleased/196648-replace-_-with-lodash.yml b/changelogs/unreleased/196648-replace-_-with-lodash.yml new file mode 100644 index 00000000000..397e2ded94d --- /dev/null +++ b/changelogs/unreleased/196648-replace-_-with-lodash.yml @@ -0,0 +1,5 @@ +--- +title: Replaced underscore with lodash for app/assets/javascripts/lib +merge_request: 25042 +author: Shubham Pandey +type: other diff --git a/changelogs/unreleased/38144-replace-labels-in-haml-with-gitlab-ui-css.yml b/changelogs/unreleased/38144-replace-labels-in-haml-with-gitlab-ui-css.yml new file mode 100644 index 00000000000..fcb22a03e9f --- /dev/null +++ b/changelogs/unreleased/38144-replace-labels-in-haml-with-gitlab-ui-css.yml @@ -0,0 +1,5 @@ +--- +title: New styles for scoped labels +merge_request: 21377 +author: +type: changed diff --git a/changelogs/unreleased/Resolve-Migrate--fa-spinner-app-assets-javascripts-notes-components-discu.yml b/changelogs/unreleased/Resolve-Migrate--fa-spinner-app-assets-javascripts-notes-components-discu.yml new file mode 100644 index 00000000000..1506f672ed2 --- /dev/null +++ b/changelogs/unreleased/Resolve-Migrate--fa-spinner-app-assets-javascripts-notes-components-discu.yml @@ -0,0 +1,5 @@ +--- +title: Migrate .fa-spinner to .spinner for app/assets/javascripts/notes/components/discussion_resolve_button.vue +merge_request: 25055 +author: nuwe1 +type: other diff --git a/lib/banzai/filter/label_reference_filter.rb b/lib/banzai/filter/label_reference_filter.rb index 609ea8fb5ca..60ffb178393 100644 --- a/lib/banzai/filter/label_reference_filter.rb +++ b/lib/banzai/filter/label_reference_filter.rb @@ -93,23 +93,26 @@ module Banzai end presenter = object.present(issuable_subject: parent) - LabelsHelper.render_colored_label(presenter, label_suffix: label_suffix, title: tooltip_title(presenter)) + LabelsHelper.render_colored_label(presenter, suffix: label_suffix) end - def tooltip_title(label) - nil + def wrap_link(link, label) + presenter = label.present(issuable_subject: project || group) + LabelsHelper.wrap_label_html(link, small: true, label: presenter) end def full_path_ref?(matches) matches[:namespace] && matches[:project] end + def reference_class(type, tooltip: true) + super + ' gl-link gl-label-link' + end + def object_link_title(object, matches) - # use title of wrapped element instead - nil + presenter = object.present(issuable_subject: project || group) + LabelsHelper.label_tooltip_title(presenter) end end end end - -Banzai::Filter::LabelReferenceFilter.prepend_if_ee('EE::Banzai::Filter::LabelReferenceFilter') diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index b3ce9200b49..38bbed3cf72 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -37,7 +37,8 @@ module Banzai attributes[:reference_type] ||= self.class.reference_type attributes[:container] ||= 'body' - attributes[:placement] ||= 'bottom' + attributes[:placement] ||= 'top' + attributes[:html] ||= 'true' attributes.delete(:original) if context[:no_original_data] attributes.map do |key, value| %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}") diff --git a/lib/gitlab/markdown_cache.rb b/lib/gitlab/markdown_cache.rb index 3dfaec48311..473c7203ead 100644 --- a/lib/gitlab/markdown_cache.rb +++ b/lib/gitlab/markdown_cache.rb @@ -3,7 +3,7 @@ module Gitlab module MarkdownCache # Increment this number every time the renderer changes its output - CACHE_COMMONMARK_VERSION = 18 + CACHE_COMMONMARK_VERSION = 19 CACHE_COMMONMARK_VERSION_START = 10 BaseError = Class.new(StandardError) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4c90b213539..04fd7d3cc1a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5848,6 +5848,9 @@ msgstr "" msgid "CustomCycleAnalytics|Select stop event" msgstr "" +msgid "CustomCycleAnalytics|Stage name already exists" +msgstr "" + msgid "CustomCycleAnalytics|Start event" msgstr "" @@ -18564,9 +18567,6 @@ msgstr "" msgid "Subscription deletion failed." msgstr "" -msgid "Subscription successfully applied to \"%{group_name}\"" -msgstr "" - msgid "Subscription successfully created." msgstr "" @@ -20756,6 +20756,12 @@ msgstr "" msgid "Uninstalling" msgstr "" +msgid "Units|ms" +msgstr "" + +msgid "Units|s" +msgstr "" + msgid "Unknown" msgstr "" diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index c7edb574f19..e54c11f657d 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -277,7 +277,7 @@ describe 'Issue Boards', :js do wait_for_requests page.within('.value') do - expect(page).to have_selector('.badge', count: 2) + expect(page).to have_selector('.gl-label-text', count: 2) expect(page).to have_content(development.title) expect(page).to have_content(stretch.title) end @@ -299,7 +299,7 @@ describe 'Issue Boards', :js do find('.dropdown-menu-close-icon').click page.within('.value') do - expect(page).to have_selector('.badge', count: 3) + expect(page).to have_selector('.gl-label-text', count: 3) expect(page).to have_content(bug.title) end end @@ -328,7 +328,7 @@ describe 'Issue Boards', :js do find('.dropdown-menu-close-icon').click page.within('.value') do - expect(page).to have_selector('.badge', count: 4) + expect(page).to have_selector('.gl-label-text', count: 4) expect(page).to have_content(bug.title) expect(page).to have_content(regression.title) end @@ -357,13 +357,13 @@ describe 'Issue Boards', :js do find('.dropdown-menu-close-icon').click page.within('.value') do - expect(page).to have_selector('.badge', count: 1) + expect(page).to have_selector('.gl-label-text', count: 1) expect(page).not_to have_content(stretch.title) end end # 'Development' label does not show since the card is in a 'Development' list label - expect(card).to have_selector('.badge', count: 0) + expect(card).to have_selector('.gl-label-text', count: 0) expect(card).not_to have_content(stretch.title) end diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb deleted file mode 100644 index 881cad1864b..00000000000 --- a/spec/features/container_registry_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe 'Container Registry', :js do - let(:user) { create(:user) } - let(:project) { create(:project) } - - let(:container_repository) do - create(:container_repository, name: 'my/image') - end - - before do - sign_in(user) - project.add_developer(user) - stub_container_registry_config(enabled: true) - stub_container_registry_tags(repository: :any, tags: []) - stub_feature_flags(vue_container_registry_explorer: false) - end - - it 'has a page title set' do - visit_container_registry - expect(page).to have_title(_('Container Registry')) - end - - context 'when there are no image repositories' do - it 'user visits container registry main page' do - visit_container_registry - - expect(page).to have_content 'no container images' - end - end - - context 'when there are image repositories' do - before do - stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) - project.container_repositories << container_repository - end - - it 'user wants to see multi-level container repository' do - visit_container_registry - - expect(page).to have_content('my/image') - end - - it 'user removes entire container repository', :sidekiq_might_not_need_inline do - visit_container_registry - - expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) - - click_on(class: 'js-remove-repo') - expect(find('.modal .modal-title')).to have_content 'Remove repository' - find('.modal .modal-footer .btn-danger').click - end - - it 'user removes a specific tag from container repository' do - visit_container_registry - - find('.js-toggle-repo').click - wait_for_requests - - service = double('service') - expect(service).to receive(:execute).with(container_repository) { { status: :success } } - expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } - - click_on(class: 'js-delete-registry-row', visible: false) - expect(find('.modal .modal-title')).to have_content 'Remove tag' - find('.modal .modal-footer .btn-danger').click - end - end - - def visit_container_registry - visit project_container_registry_index_path(project) - end -end diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb new file mode 100644 index 00000000000..7e3c1728f3c --- /dev/null +++ b/spec/features/groups/container_registry_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Container Registry', :js do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + + let(:container_repository) do + create(:container_repository, name: 'my/image') + end + + before do + group.add_owner(user) + sign_in(user) + stub_container_registry_config(enabled: true) + stub_container_registry_tags(repository: :any, tags: []) + end + + it 'has a page title set' do + visit_container_registry + + expect(page).to have_title _('Container Registry') + end + + context 'when there are no image repositories' do + it 'list page has no container title' do + visit_container_registry + + expect(page).to have_content _('There are no container images available in this group') + end + end + + context 'when there are image repositories' do + before do + stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) + project.container_repositories << container_repository + end + + it 'list page has a list of images' do + visit_container_registry + + expect(page).to have_content 'my/image' + end + + it 'image repository delete is disabled' do + visit_container_registry + + delete_btn = find('[title="Remove repository"]') + expect(delete_btn).to be_disabled + end + + it 'navigates to repo details' do + visit_container_registry_details('my/image') + + expect(page).to have_content 'latest' + end + + describe 'image repo details' do + before do + visit_container_registry_details 'my/image' + end + + it 'shows the details breadcrumb' do + expect(find('.breadcrumbs')).to have_link 'my/image' + end + + it 'shows the image title' do + expect(page).to have_content 'my/image tags' + end + + it 'user removes a specific tag from container repository' do + service = double('service') + expect(service).to receive(:execute).with(container_repository) { { status: :success } } + expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } + + click_on(class: 'js-delete-registry') + expect(find('.modal .modal-title')).to have_content _('Remove tag') + find('.modal .modal-footer .btn-danger').click + end + end + end + + def visit_container_registry + visit group_container_registries_path(group) + end + + def visit_container_registry_details(name) + visit_container_registry + click_link(name) + end +end diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index bcc05d313ad..7014a51ccdc 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -41,10 +41,10 @@ describe 'issuable list' do visit_issuable_list(issuable_type) - expect(all('.label-link')[0].text).to have_content('B') - expect(all('.label-link')[1].text).to have_content('X') - expect(all('.label-link')[2].text).to have_content('a') - expect(all('.label-link')[3].text).to have_content('z') + expect(all('.gl-label-text')[0].text).to have_content('B') + expect(all('.gl-label-text')[1].text).to have_content('X') + expect(all('.gl-label-text')[2].text).to have_content('a') + expect(all('.gl-label-text')[3].text).to have_content('z') end end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index ee5773f1484..a518831ea2b 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -332,7 +332,7 @@ describe 'Filter issues', :js do context 'issue label clicked' do it 'filters and displays in search bar' do - find('.issues-list .issue .issuable-main-info .issuable-info a .badge', text: multiple_words_label.title).click + find('.issues-list .issue .issuable-main-info .issuable-info a .gl-label-text', text: multiple_words_label.title).click expect_issues_list_count(1) expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index c1a2e22a0c2..c66d858a019 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -161,9 +161,9 @@ describe 'Labels Hierarchy', :js do find('.btn-success').click expect(page.find('.issue-details h2.title')).to have_content('new created issue') - expect(page).to have_selector('span.badge', text: grandparent_group_label.title) - expect(page).to have_selector('span.badge', text: parent_group_label.title) - expect(page).to have_selector('span.badge', text: project_label_1.title) + expect(page).to have_selector('span.gl-label-text', text: grandparent_group_label.title) + expect(page).to have_selector('span.gl-label-text', text: parent_group_label.title) + expect(page).to have_selector('span.gl-label-text', text: project_label_1.title) end end diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb new file mode 100644 index 00000000000..02b2d03a880 --- /dev/null +++ b/spec/features/projects/container_registry_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Container Registry', :js do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:container_repository) do + create(:container_repository, name: 'my/image') + end + + before do + sign_in(user) + project.add_developer(user) + stub_container_registry_config(enabled: true) + stub_container_registry_tags(repository: :any, tags: []) + end + + describe 'Registry explorer is off' do + before do + stub_feature_flags(vue_container_registry_explorer: false) + end + + it 'has a page title set' do + visit_container_registry + + expect(page).to have_title _('Container Registry') + end + + context 'when there are no image repositories' do + it 'user visits container registry main page' do + visit_container_registry + + expect(page).to have_content _('no container images') + end + end + + context 'when there are image repositories' do + before do + stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) + project.container_repositories << container_repository + end + + it 'user wants to see multi-level container repository' do + visit_container_registry + + expect(page).to have_content 'my/image' + end + + it 'user removes entire container repository', :sidekiq_might_not_need_inline do + visit_container_registry + + expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) + + click_on(class: 'js-remove-repo') + expect(find('.modal .modal-title')).to have_content _('Remove repository') + find('.modal .modal-footer .btn-danger').click + end + + it 'user removes a specific tag from container repository' do + visit_container_registry + + find('.js-toggle-repo').click + wait_for_requests + + service = double('service') + expect(service).to receive(:execute).with(container_repository) { { status: :success } } + expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } + + click_on(class: 'js-delete-registry-row', visible: false) + expect(find('.modal .modal-title')).to have_content _('Remove tag') + find('.modal .modal-footer .btn-danger').click + end + end + end + + describe 'Registry explorer is on' do + it 'has a page title set' do + visit_container_registry + + expect(page).to have_title _('Container Registry') + end + + context 'when there are no image repositories' do + it 'list page has no container title' do + visit_container_registry + + expect(page).to have_content _('There are no container images stored for this project') + end + + it 'list page has quickstart' do + visit_container_registry + + expect(page).to have_content _('Quick Start') + end + end + + context 'when there are image repositories' do + before do + stub_container_registry_tags(repository: %r{my/image}, tags: %w[latest], with_manifest: true) + project.container_repositories << container_repository + end + + it 'list page has a list of images' do + visit_container_registry + + expect(page).to have_content 'my/image' + end + + it 'user removes entire container repository', :sidekiq_might_not_need_inline do + visit_container_registry + + expect_any_instance_of(ContainerRepository).to receive(:delete_tags!).and_return(true) + + find('[title="Remove repository"]').click + expect(find('.modal .modal-title')).to have_content _('Remove repository') + find('.modal .modal-footer .btn-danger').click + end + + it 'navigates to repo details' do + visit_container_registry_details('my/image') + + expect(page).to have_content 'latest' + end + + describe 'image repo details' do + before do + visit_container_registry_details 'my/image' + end + + it 'shows the details breadcrumb' do + expect(find('.breadcrumbs')).to have_link 'my/image' + end + + it 'shows the image title' do + expect(page).to have_content 'my/image tags' + end + + it 'user removes a specific tag from container repository' do + service = double('service') + expect(service).to receive(:execute).with(container_repository) { { status: :success } } + expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } + + click_on(class: 'js-delete-registry') + expect(find('.modal .modal-title')).to have_content _('Remove tag') + find('.modal .modal-footer .btn-danger').click + end + end + end + end + + def visit_container_registry + visit project_container_registry_index_path(project) + end + + def visit_container_registry_details(name) + visit_container_registry + click_link(name) + end +end diff --git a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js new file mode 100644 index 00000000000..071ecde6a6d --- /dev/null +++ b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js @@ -0,0 +1,200 @@ +import { + numberFormatter, + suffixFormatter, + scaledSIFormatter, +} from '~/lib/utils/unit_format/formatter_factory'; + +describe('unit_format/formatter_factory', () => { + describe('numberFormatter', () => { + let formatNumber; + beforeEach(() => { + formatNumber = numberFormatter(); + }); + + it('formats a integer', () => { + expect(formatNumber(1)).toEqual('1'); + expect(formatNumber(100)).toEqual('100'); + expect(formatNumber(1000)).toEqual('1,000'); + expect(formatNumber(10000)).toEqual('10,000'); + expect(formatNumber(1000000)).toEqual('1,000,000'); + }); + + it('formats a floating point number', () => { + expect(formatNumber(0.1)).toEqual('0.1'); + expect(formatNumber(0.1, 0)).toEqual('0'); + expect(formatNumber(0.1, 2)).toEqual('0.10'); + expect(formatNumber(0.1, 3)).toEqual('0.100'); + + expect(formatNumber(12.345)).toEqual('12.345'); + expect(formatNumber(12.345, 2)).toEqual('12.35'); + expect(formatNumber(12.345, 4)).toEqual('12.3450'); + }); + + it('formats a large integer with a length limit', () => { + expect(formatNumber(10 ** 7, undefined)).toEqual('10,000,000'); + expect(formatNumber(10 ** 7, undefined, 9)).toEqual('1.00e+7'); + expect(formatNumber(10 ** 7, undefined, 10)).toEqual('10,000,000'); + }); + }); + + describe('suffixFormatter', () => { + let formatSuffix; + beforeEach(() => { + formatSuffix = suffixFormatter('pop.', undefined); + }); + + it('formats a integer', () => { + expect(formatSuffix(1)).toEqual('1pop.'); + expect(formatSuffix(100)).toEqual('100pop.'); + expect(formatSuffix(1000)).toEqual('1,000pop.'); + expect(formatSuffix(10000)).toEqual('10,000pop.'); + expect(formatSuffix(1000000)).toEqual('1,000,000pop.'); + }); + + it('formats a floating point number', () => { + expect(formatSuffix(0.1)).toEqual('0.1pop.'); + expect(formatSuffix(0.1, 0)).toEqual('0pop.'); + expect(formatSuffix(0.1, 2)).toEqual('0.10pop.'); + expect(formatSuffix(0.1, 3)).toEqual('0.100pop.'); + + expect(formatSuffix(12.345)).toEqual('12.345pop.'); + expect(formatSuffix(12.345, 2)).toEqual('12.35pop.'); + expect(formatSuffix(12.345, 4)).toEqual('12.3450pop.'); + }); + + it('formats a negative integer', () => { + expect(formatSuffix(-1)).toEqual('-1pop.'); + expect(formatSuffix(-100)).toEqual('-100pop.'); + expect(formatSuffix(-1000)).toEqual('-1,000pop.'); + expect(formatSuffix(-10000)).toEqual('-10,000pop.'); + expect(formatSuffix(-1000000)).toEqual('-1,000,000pop.'); + }); + + it('formats a floating point nugative number', () => { + expect(formatSuffix(-0.1)).toEqual('-0.1pop.'); + expect(formatSuffix(-0.1, 0)).toEqual('-0pop.'); + expect(formatSuffix(-0.1, 2)).toEqual('-0.10pop.'); + expect(formatSuffix(-0.1, 3)).toEqual('-0.100pop.'); + + expect(formatSuffix(-12.345)).toEqual('-12.345pop.'); + expect(formatSuffix(-12.345, 2)).toEqual('-12.35pop.'); + expect(formatSuffix(-12.345, 4)).toEqual('-12.3450pop.'); + }); + + it('formats a large integer', () => { + expect(formatSuffix(10 ** 7)).toEqual('10,000,000pop.'); + expect(formatSuffix(10 ** 10)).toEqual('10,000,000,000pop.'); + }); + + it('formats a large integer with a length limit', () => { + expect(formatSuffix(10 ** 7, undefined, 10)).toEqual('1.00e+7pop.'); + expect(formatSuffix(10 ** 10, undefined, 10)).toEqual('1.00e+10pop.'); + }); + }); + + describe('scaledSIFormatter', () => { + describe('scaled format', () => { + let formatScaled; + + beforeEach(() => { + formatScaled = scaledSIFormatter('B'); + }); + + it('formats bytes', () => { + expect(formatScaled(12.345)).toEqual('12.345B'); + expect(formatScaled(12.345, 0)).toEqual('12B'); + expect(formatScaled(12.345, 1)).toEqual('12.3B'); + expect(formatScaled(12.345, 2)).toEqual('12.35B'); + }); + + it('formats bytes in a scale', () => { + expect(formatScaled(1)).toEqual('1B'); + expect(formatScaled(10)).toEqual('10B'); + expect(formatScaled(10 ** 2)).toEqual('100B'); + expect(formatScaled(10 ** 3)).toEqual('1kB'); + expect(formatScaled(10 ** 4)).toEqual('10kB'); + expect(formatScaled(10 ** 5)).toEqual('100kB'); + expect(formatScaled(10 ** 6)).toEqual('1MB'); + expect(formatScaled(10 ** 7)).toEqual('10MB'); + expect(formatScaled(10 ** 8)).toEqual('100MB'); + expect(formatScaled(10 ** 9)).toEqual('1GB'); + expect(formatScaled(10 ** 10)).toEqual('10GB'); + expect(formatScaled(10 ** 11)).toEqual('100GB'); + }); + }); + + describe('scaled format with offset', () => { + let formatScaled; + + beforeEach(() => { + // formats gigabytes + formatScaled = scaledSIFormatter('B', 3); + }); + + it('formats floating point numbers', () => { + expect(formatScaled(12.345)).toEqual('12.345GB'); + expect(formatScaled(12.345, 0)).toEqual('12GB'); + expect(formatScaled(12.345, 1)).toEqual('12.3GB'); + expect(formatScaled(12.345, 2)).toEqual('12.35GB'); + }); + + it('formats large numbers scaled', () => { + expect(formatScaled(1)).toEqual('1GB'); + expect(formatScaled(1, 1)).toEqual('1.0GB'); + expect(formatScaled(10)).toEqual('10GB'); + expect(formatScaled(10 ** 2)).toEqual('100GB'); + expect(formatScaled(10 ** 3)).toEqual('1TB'); + expect(formatScaled(10 ** 4)).toEqual('10TB'); + expect(formatScaled(10 ** 5)).toEqual('100TB'); + expect(formatScaled(10 ** 6)).toEqual('1PB'); + expect(formatScaled(10 ** 7)).toEqual('10PB'); + expect(formatScaled(10 ** 8)).toEqual('100PB'); + expect(formatScaled(10 ** 9)).toEqual('1EB'); + }); + + it('formatting of too large numbers is not suported', () => { + // formatting YB is out of range + expect(() => scaledSIFormatter('B', 9)).toThrow(); + }); + }); + + describe('scaled format with negative offset', () => { + let formatScaled; + + beforeEach(() => { + formatScaled = scaledSIFormatter('g', -1); + }); + + it('formats floating point numbers', () => { + expect(formatScaled(12.345)).toEqual('12.345mg'); + expect(formatScaled(12.345, 0)).toEqual('12mg'); + expect(formatScaled(12.345, 1)).toEqual('12.3mg'); + expect(formatScaled(12.345, 2)).toEqual('12.35mg'); + }); + + it('formats large numbers scaled', () => { + expect(formatScaled(1)).toEqual('1mg'); + expect(formatScaled(1, 1)).toEqual('1.0mg'); + expect(formatScaled(10)).toEqual('10mg'); + expect(formatScaled(10 ** 2)).toEqual('100mg'); + expect(formatScaled(10 ** 3)).toEqual('1g'); + expect(formatScaled(10 ** 4)).toEqual('10g'); + expect(formatScaled(10 ** 5)).toEqual('100g'); + expect(formatScaled(10 ** 6)).toEqual('1kg'); + expect(formatScaled(10 ** 7)).toEqual('10kg'); + expect(formatScaled(10 ** 8)).toEqual('100kg'); + }); + + it('formats negative numbers scaled', () => { + expect(formatScaled(-12.345)).toEqual('-12.345mg'); + expect(formatScaled(-12.345, 0)).toEqual('-12mg'); + expect(formatScaled(-12.345, 1)).toEqual('-12.3mg'); + expect(formatScaled(-12.345, 2)).toEqual('-12.35mg'); + + expect(formatScaled(-10)).toEqual('-10mg'); + expect(formatScaled(-100)).toEqual('-100mg'); + expect(formatScaled(-(10 ** 4))).toEqual('-10g'); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js new file mode 100644 index 00000000000..e0991f2909b --- /dev/null +++ b/spec/frontend/lib/utils/unit_format/index_spec.js @@ -0,0 +1,117 @@ +import { 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)).toEqual('1'); + }); + + it('numbers', () => { + const formatNumber = getFormatter(SUPPORTED_FORMATS.number); + + expect(formatNumber(1)).toEqual('1'); + expect(formatNumber(100)).toEqual('100'); + expect(formatNumber(1000)).toEqual('1,000'); + expect(formatNumber(10000)).toEqual('10,000'); + expect(formatNumber(1000000)).toEqual('1,000,000'); + }); + + it('percent', () => { + const formatPercent = getFormatter(SUPPORTED_FORMATS.percent); + + expect(formatPercent(1)).toEqual('100%'); + expect(formatPercent(1, 2)).toEqual('100.00%'); + + expect(formatPercent(0.1)).toEqual('10%'); + expect(formatPercent(0.5)).toEqual('50%'); + + expect(formatPercent(0.888888)).toEqual('89%'); + expect(formatPercent(0.888888, 2)).toEqual('88.89%'); + expect(formatPercent(0.888888, 5)).toEqual('88.88880%'); + + expect(formatPercent(2)).toEqual('200%'); + expect(formatPercent(10)).toEqual('1,000%'); + }); + + it('percentunit', () => { + const formatPercentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred); + + expect(formatPercentHundred(1)).toEqual('1%'); + expect(formatPercentHundred(1, 2)).toEqual('1.00%'); + + expect(formatPercentHundred(88.8888)).toEqual('89%'); + expect(formatPercentHundred(88.8888, 2)).toEqual('88.89%'); + expect(formatPercentHundred(88.8888, 5)).toEqual('88.88880%'); + + expect(formatPercentHundred(100)).toEqual('100%'); + expect(formatPercentHundred(100, 2)).toEqual('100.00%'); + + expect(formatPercentHundred(200)).toEqual('200%'); + expect(formatPercentHundred(1000)).toEqual('1,000%'); + }); + + it('seconds', () => { + expect(getFormatter(SUPPORTED_FORMATS.seconds)(1)).toEqual('1s'); + }); + + it('miliseconds', () => { + const formatMiliseconds = getFormatter(SUPPORTED_FORMATS.miliseconds); + + expect(formatMiliseconds(1)).toEqual('1ms'); + expect(formatMiliseconds(100)).toEqual('100ms'); + expect(formatMiliseconds(1000)).toEqual('1,000ms'); + expect(formatMiliseconds(10000)).toEqual('10,000ms'); + expect(formatMiliseconds(1000000)).toEqual('1,000,000ms'); + }); + + it('bytes', () => { + const formatBytes = getFormatter(SUPPORTED_FORMATS.bytes); + + expect(formatBytes(1)).toEqual('1B'); + expect(formatBytes(1, 1)).toEqual('1.0B'); + + expect(formatBytes(10)).toEqual('10B'); + expect(formatBytes(10 ** 2)).toEqual('100B'); + expect(formatBytes(10 ** 3)).toEqual('1kB'); + expect(formatBytes(10 ** 4)).toEqual('10kB'); + expect(formatBytes(10 ** 5)).toEqual('100kB'); + expect(formatBytes(10 ** 6)).toEqual('1MB'); + expect(formatBytes(10 ** 7)).toEqual('10MB'); + expect(formatBytes(10 ** 8)).toEqual('100MB'); + expect(formatBytes(10 ** 9)).toEqual('1GB'); + expect(formatBytes(10 ** 10)).toEqual('10GB'); + expect(formatBytes(10 ** 11)).toEqual('100GB'); + }); + + it('kilobytes', () => { + expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1)).toEqual('1kB'); + expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1, 1)).toEqual('1.0kB'); + }); + + it('megabytes', () => { + expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1)).toEqual('1MB'); + expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1, 1)).toEqual('1.0MB'); + }); + + it('gigabytes', () => { + expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1)).toEqual('1GB'); + expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1, 1)).toEqual('1.0GB'); + }); + + it('terabytes', () => { + expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1)).toEqual('1TB'); + expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1, 1)).toEqual('1.0TB'); + }); + + it('petabytes', () => { + expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1)).toEqual('1PB'); + expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1, 1)).toEqual('1.0PB'); + }); + }); + + describe('when get formatter format is incorrect', () => { + it('formatter fails', () => { + expect(() => getFormatter('not-supported')(1)).toThrow(); + }); + }); +}); diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb index f5771405687..322390c3840 100644 --- a/spec/helpers/labels_helper_spec.rb +++ b/spec/helpers/labels_helper_spec.rb @@ -56,7 +56,7 @@ describe LabelsHelper do context 'without subject' do it "uses the label's project" do - expect(link_to_label(label_presenter)).to match %r{<a href="/#{label.project.full_path}/issues\?label_name%5B%5D=#{label.name}">.*</a>} + expect(link_to_label(label_presenter)).to match %r{<a.*href="/#{label.project.full_path}/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m end end @@ -65,7 +65,7 @@ describe LabelsHelper do let(:subject) { build(:project, namespace: namespace, name: 'bar3') } it 'links to project issues page' do - expect(link_to_label(label_presenter)).to match %r{<a href="/foo3/bar3/issues\?label_name%5B%5D=#{label.name}">.*</a>} + expect(link_to_label(label_presenter)).to match %r{<a.*href="/foo3/bar3/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m end end @@ -73,7 +73,7 @@ describe LabelsHelper do let(:subject) { build(:group, name: 'bar') } it 'links to group issues page' do - expect(link_to_label(label_presenter)).to match %r{<a href="/groups/bar/-/issues\?label_name%5B%5D=#{label.name}">.*</a>} + expect(link_to_label(label_presenter)).to match %r{<a.*href="/groups/bar/-/issues\?label_name%5B%5D=#{label.name}".*>.*</a>}m end end @@ -81,7 +81,7 @@ describe LabelsHelper do ['issue', :issue].each do |type| context "set to #{type}" do it 'links to correct page' do - expect(link_to_label(label_presenter, type: type)).to match %r{<a href="/#{label.project.full_path}/#{type.to_s.pluralize}\?label_name%5B%5D=#{label.name}">.*</a>} + expect(link_to_label(label_presenter, type: type)).to match %r{<a.*href="/#{label.project.full_path}/#{type.to_s.pluralize}\?label_name%5B%5D=#{label.name}".*>.*</a>}m end end end @@ -89,7 +89,7 @@ describe LabelsHelper do ['merge_request', :merge_request].each do |type| context "set to #{type}" do it 'links to correct page' do - expect(link_to_label(label_presenter, type: type)).to match %r{<a href="/#{label.project.full_path}/-/#{type.to_s.pluralize}\?label_name%5B%5D=#{label.name}">.*</a>} + expect(link_to_label(label_presenter, type: type)).to match %r{<a.*href="/#{label.project.full_path}/-/#{type.to_s.pluralize}\?label_name%5B%5D=#{label.name}".*>.*</a>}m end end end @@ -113,7 +113,7 @@ describe LabelsHelper do context 'without block' do it 'uses render_colored_label as the link content' do expect(self).to receive(:render_colored_label) - .with(label_presenter, tooltip: true).and_return('Foo') + .with(label_presenter).and_return('Foo') expect(link_to_label(label_presenter)).to match('Foo') end end diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index 3fb36e540b6..c22e20f0e73 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -537,8 +537,10 @@ describe MarkupHelper do it 'does not style a label that can not be accessed by current_user' do project = create(:project, :private) + label = create_and_format_label(project) - expect(create_and_format_label(project)).to eq("<p>#{label_title}</p>") + expect(label).to include("~label_1") + expect(label).not_to match(/span class=.*style=.*/) end end diff --git a/spec/lib/banzai/filter/label_reference_filter_spec.rb b/spec/lib/banzai/filter/label_reference_filter_spec.rb index 82df5064896..5a672de13d7 100644 --- a/spec/lib/banzai/filter/label_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/label_reference_filter_spec.rb @@ -28,7 +28,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'includes default classes' do doc = reference_filter("Label #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label has-tooltip' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label has-tooltip gl-link gl-label-link' end it 'includes a data-project attribute' do @@ -66,12 +66,12 @@ describe Banzai::Filter::LabelReferenceFilter do describe 'label span element' do it 'includes default classes' do doc = reference_filter("Label #{reference}") - expect(doc.css('a span').first.attr('class')).to eq 'badge color-label has-tooltip' + expect(doc.css('a span').first.attr('class')).to include 'gl-label-text' end it 'includes a style attribute' do doc = reference_filter("Label #{reference}") - expect(doc.css('a span').first.attr('style')).to match(/\Abackground-color: #\h{6}; color: #\h{6}\z/) + expect(doc.css('a span').first.attr('style')).to match(/\Abackground-color: #\h{6}\z/) end end @@ -85,7 +85,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\.\))) end it 'ignores invalid label IDs' do @@ -109,7 +109,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}).") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\)\.)) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\)\.)) end it 'ignores invalid label names' do @@ -133,7 +133,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}).") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\)\.)) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\)\.)) end it 'ignores invalid label names' do @@ -158,7 +158,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'does not include trailing punctuation', :aggregate_failures do ['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation| doc = filter("Label #{reference}#{trailing_punctuation}") - expect(doc.to_html).to match(%r(<a.+><span.+>\?g\.fm&</span></a>#{Regexp.escape(trailing_punctuation)})) + expect(doc.to_html).to match(%r(<span.+><a.+><span.+>\?g\.fm&</span></a></span>#{Regexp.escape(trailing_punctuation)})) end end @@ -184,7 +184,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\.\))) end it 'ignores invalid label names' do @@ -208,7 +208,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\.\))) end it 'ignores invalid label names' do @@ -232,7 +232,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+><span.+>g\.fm & references\?</span></a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>g\.fm & references\?</span></a></span>\.\))) end it 'ignores invalid label names' do @@ -320,7 +320,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+>Label</a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+>Label</a></span>\.\))) end it 'includes a data-project attribute' do @@ -358,7 +358,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{group_label.name}</span></a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{group_label.name}</span></a></span>\.\))) end it 'ignores invalid label names' do @@ -381,7 +381,7 @@ describe Banzai::Filter::LabelReferenceFilter do it 'links with adjacent text' do doc = reference_filter("Label (#{reference}.)") - expect(doc.to_html).to match(%r(\(<a.+><span.+>#{group_label.name}</span></a>\.\))) + expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{group_label.name}</span></a></span>\.\))) end it 'ignores invalid label names' do diff --git a/spec/services/resource_events/change_milestone_service_spec.rb b/spec/services/resource_events/change_milestone_service_spec.rb index c6b4f8e1b7e..bc634fadadd 100644 --- a/spec/services/resource_events/change_milestone_service_spec.rb +++ b/spec/services/resource_events/change_milestone_service_spec.rb @@ -3,65 +3,11 @@ require 'spec_helper' describe ResourceEvents::ChangeMilestoneService do - shared_examples 'milestone events creator' do - let_it_be(:user) { create(:user) } - - let_it_be(:milestone) { create(:milestone) } - - context 'when milestone is present' do - before do - resource.milestone = milestone - end - - let(:service) { described_class.new(resource: resource, user: user, created_at: created_at_time) } - - it 'creates the expected event record' do - expect { service.execute }.to change { ResourceMilestoneEvent.count }.from(0).to(1) - - events = ResourceMilestoneEvent.all - - expect(events.size).to eq(1) - expect_event_record(events.first, action: 'add', milestone: milestone, state: 'opened') - end - end - - context 'when milestones is not present' do - before do - resource.milestone = nil - end - - let(:service) { described_class.new(resource: resource, user: user, created_at: created_at_time) } - - it 'creates the expected event records' do - expect { service.execute }.to change { ResourceMilestoneEvent.count }.from(0).to(1) - - expect_event_record(ResourceMilestoneEvent.first, action: 'remove', milestone: nil, state: 'opened') - end - end - - def expect_event_record(event, expected_attrs) - expect(event.action).to eq(expected_attrs[:action]) - expect(event.state).to eq(expected_attrs[:state]) - expect(event.user).to eq(user) - expect(event.issue).to eq(resource) if resource.is_a?(Issue) - expect(event.issue).to be_nil unless resource.is_a?(Issue) - expect(event.merge_request).to eq(resource) if resource.is_a?(MergeRequest) - expect(event.merge_request).to be_nil unless resource.is_a?(MergeRequest) - expect(event.milestone).to eq(expected_attrs[:milestone]) - expect(event.created_at).to eq(created_at_time) - end - end - - let_it_be(:merge_request) { create(:merge_request) } - let_it_be(:issue) { create(:issue) } - - let!(:created_at_time) { Time.utc(2019, 12, 30) } - - it_behaves_like 'milestone events creator' do - let(:resource) { issue } + it_behaves_like 'a milestone events creator' do + let(:resource) { create(:issue) } end - it_behaves_like 'milestone events creator' do - let(:resource) { merge_request } + it_behaves_like 'a milestone events creator' do + let(:resource) { create(:merge_request) } end end diff --git a/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb b/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb new file mode 100644 index 00000000000..77f64e5e8f8 --- /dev/null +++ b/spec/support/shared_examples/services/resource_events/change_milestone_service_shared_examples.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +shared_examples 'a milestone events creator' do + let_it_be(:user) { create(:user) } + + let(:created_at_time) { Time.utc(2019, 12, 30) } + let(:service) { described_class.new(resource, user, created_at: created_at_time) } + + context 'when milestone is present' do + let_it_be(:milestone) { create(:milestone) } + + before do + resource.milestone = milestone + end + + it 'creates the expected event record' do + expect { service.execute }.to change { ResourceMilestoneEvent.count }.by(1) + + expect_event_record(ResourceMilestoneEvent.last, action: 'add', milestone: milestone, state: 'opened') + end + end + + context 'when milestones is not present' do + before do + resource.milestone = nil + end + + it 'creates the expected event records' do + expect { service.execute }.to change { ResourceMilestoneEvent.count }.by(1) + + expect_event_record(ResourceMilestoneEvent.last, action: 'remove', milestone: nil, state: 'opened') + end + end + + def expect_event_record(event, expected_attrs) + expect(event.action).to eq(expected_attrs[:action]) + expect(event.state).to eq(expected_attrs[:state]) + expect(event.user).to eq(user) + expect(event.issue).to eq(resource) if resource.is_a?(Issue) + expect(event.issue).to be_nil unless resource.is_a?(Issue) + expect(event.merge_request).to eq(resource) if resource.is_a?(MergeRequest) + expect(event.merge_request).to be_nil unless resource.is_a?(MergeRequest) + expect(event.milestone).to eq(expected_attrs[:milestone]) + expect(event.created_at).to eq(created_at_time) + end +end |