Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-11-15 15:26:36 +00:00
parent db788ce2ea
commit d4d622d73c
110 changed files with 1128 additions and 532 deletions

View File

@ -1 +1 @@
0c29d111e214eff897572517c13dcf3f524b3c19
c6c944b8fd09e6fa3b9a607ffdea574a87a56d93

View File

@ -75,6 +75,16 @@ export const MAX_DATE_RANGE_TEXT = (maxDateRange) => {
);
};
// Limits the number of decimals we round values to
export const MAX_METRIC_PRECISION = 4;
export const UNITS = {
COUNT: 'COUNT',
DAYS: 'DAYS',
PER_DAY: 'PER_DAY',
PERCENT: 'PERCENT',
};
export const NUMBER_OF_DAYS_SELECTED = (numDays) => {
return n__('1 day selected', '%d days selected', numDays);
};
@ -134,8 +144,17 @@ export const AI_METRICS = {
DUO_CHAT_USAGE_RATE: 'duo_chat_usage_rate',
};
export const METRIC_TOOLTIPS = {
export const VALUE_STREAM_METRIC_DISPLAY_UNITS = {
[UNITS.COUNT]: '',
[UNITS.DAYS]: __('days'),
[UNITS.PER_DAY]: __('/day'),
[UNITS.PERCENT]: '%',
};
export const VALUE_STREAM_METRIC_TILE_METADATA = {
[DORA_METRICS.DEPLOYMENT_FREQUENCY]: {
label: s__('DORA4Metrics|Deployment frequency'),
unit: UNITS.PER_DAY,
description: s__(
'ValueStreamAnalytics|Average number of deployments to production per day. This metric measures how often value is delivered to end users.',
),
@ -144,6 +163,8 @@ export const METRIC_TOOLTIPS = {
docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'deployment-frequency' }),
},
[DORA_METRICS.LEAD_TIME_FOR_CHANGES]: {
label: s__('DORA4Metrics|Lead time for changes'),
unit: UNITS.DAYS,
description: s__(
'ValueStreamAnalytics|The time to successfully deliver a commit into production. This metric reflects the efficiency of CI/CD pipelines.',
),
@ -152,6 +173,8 @@ export const METRIC_TOOLTIPS = {
docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'lead-time-for-changes' }),
},
[DORA_METRICS.TIME_TO_RESTORE_SERVICE]: {
label: s__('DORA4Metrics|Time to restore service'),
unit: UNITS.DAYS,
description: s__(
'ValueStreamAnalytics|The time it takes an organization to recover from a failure in production.',
),
@ -160,22 +183,27 @@ export const METRIC_TOOLTIPS = {
docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'time-to-restore-service' }),
},
[DORA_METRICS.CHANGE_FAILURE_RATE]: {
label: s__('DORA4Metrics|Change failure rate'),
description: s__(
'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
),
groupLink: '-/analytics/ci_cd?tab=change-failure-rate',
projectLink: '-/pipelines/charts?chart=change-failure-rate',
docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }),
unit: UNITS.PERCENT,
},
[FLOW_METRICS.LEAD_TIME]: {
label: s__('DORA4Metrics|Lead time'),
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
groupLink: '-/analytics/value_stream_analytics',
projectLink: '-/value_stream_analytics',
docsLink: helpPagePath('user/group/value_stream_analytics/index', {
anchor: 'lifecycle-metrics',
}),
unit: UNITS.DAYS,
},
[FLOW_METRICS.CYCLE_TIME]: {
label: s__('DORA4Metrics|Cycle time'),
description: s__(
"ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
@ -184,25 +212,39 @@ export const METRIC_TOOLTIPS = {
docsLink: helpPagePath('user/group/value_stream_analytics/index', {
anchor: 'lifecycle-metrics',
}),
unit: UNITS.DAYS,
},
[FLOW_METRICS.ISSUES]: {
label: s__('DORA4Metrics|Issues created'),
unit: UNITS.COUNT,
description: s__('ValueStreamAnalytics|Number of new issues created.'),
groupLink: '-/issues_analytics',
projectLink: '-/analytics/issues_analytics',
docsLink: helpPagePath('user/group/issues_analytics/index'),
},
[FLOW_METRICS.COMMITS]: {
label: s__('DORA4Metrics|Commits'),
unit: UNITS.COUNT,
description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
},
[FLOW_METRICS.DEPLOYS]: {
label: s__('DORA4Metrics|Deploys'),
unit: UNITS.COUNT,
description: s__('ValueStreamAnalytics|Total number of deploys to production.'),
groupLink: '-/analytics/productivity_analytics',
projectLink: '-/analytics/merge_request_analytics',
docsLink: helpPagePath('user/analytics/merge_request_analytics'),
},
};
export const VALUE_STREAM_METRIC_METADATA = {
...VALUE_STREAM_METRIC_TILE_METADATA,
[FLOW_METRICS.ISSUES_COMPLETED]: {
description: s__('ValueStreamAnalytics|Number of issues closed by month.'),
groupLink: '-/issues_analytics',
projectLink: '-/analytics/issues_analytics',
docsLink: helpPagePath('user/group/issues_analytics/index'),
},
[FLOW_METRICS.DEPLOYS]: {
description: s__('ValueStreamAnalytics|Total number of deploys to production.'),
groupLink: '-/analytics/productivity_analytics',
projectLink: '-/analytics/merge_request_analytics',
docsLink: helpPagePath('user/analytics/merge_request_analytics'),
},
[CONTRIBUTOR_METRICS.COUNT]: {
description: s__(
'ValueStreamAnalytics|Number of monthly unique users with contributions in the group.',
@ -254,42 +296,6 @@ export const METRIC_TOOLTIPS = {
},
};
// TODO: Remove this once the migration to METRIC_TOOLTIPS is complete
// https://gitlab.com/gitlab-org/gitlab/-/issues/388067
export const METRICS_POPOVER_CONTENT = {
lead_time: {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
},
cycle_time: {
description: s__(
"ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
},
lead_time_for_changes: {
description: s__(
'ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period.',
),
},
issues: { description: s__('ValueStreamAnalytics|Number of new issues created.') },
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
deployment_frequency: {
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
},
commits: {
description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
},
time_to_restore_service: {
description: s__(
'ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period.',
),
},
change_failure_rate: {
description: s__(
'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
),
},
};
export const USAGE_OVERVIEW_NO_DATA_ERROR = s__(
'ValueStreamAnalytics|Failed to load usage overview data',
);

View File

@ -0,0 +1,2 @@
export const BUCKETING_INTERVAL_ALL = 'ALL';
export const BUCKETING_INTERVAL_MONTHLY = 'MONTHLY';

View File

@ -0,0 +1,7 @@
fragment DoraMetricItem on DoraMetric {
date
deployment_frequency: deploymentFrequency
change_failure_rate: changeFailureRate
lead_time_for_changes: leadTimeForChanges
time_to_restore_service: timeToRestoreService
}

View File

@ -0,0 +1,25 @@
#import "./dora_metric_item.fragment.graphql"
query doraMetricsQuery(
$fullPath: ID!
$startDate: Date!
$endDate: Date!
$interval: DoraMetricBucketingInterval!
) {
project(fullPath: $fullPath) {
id
dora {
metrics(startDate: $startDate, endDate: $endDate, interval: $interval) {
...DoraMetricItem
}
}
}
group(fullPath: $fullPath) {
id
dora {
metrics(startDate: $startDate, endDate: $endDate, interval: $interval) {
...DoraMetricItem
}
}
}
}

View File

@ -0,0 +1,12 @@
fragment FlowMetricItem on ValueStreamAnalyticsMetric {
unit
value
identifier
links {
label
name
docsLink
url
}
title
}

View File

@ -0,0 +1,58 @@
#import "./flow_metric_item.fragment.graphql"
query flowMetricsQuery($fullPath: ID!, $startDate: Time!, $endDate: Time!, $labelNames: [String!]) {
project(fullPath: $fullPath) {
id
flowMetrics {
issues: issueCount(from: $startDate, to: $endDate, labelNames: $labelNames) {
...FlowMetricItem
}
issues_completed: issuesCompletedCount(
from: $startDate
to: $endDate
labelNames: $labelNames
) {
...FlowMetricItem
}
cycle_time: cycleTime(from: $startDate, to: $endDate, labelNames: $labelNames) {
...FlowMetricItem
}
lead_time: leadTime(from: $startDate, to: $endDate, labelNames: $labelNames) {
...FlowMetricItem
}
deploys: deploymentCount(from: $startDate, to: $endDate) {
...FlowMetricItem
}
median_time_to_merge: timeToMerge(from: $startDate, to: $endDate) {
...FlowMetricItem
}
}
}
group(fullPath: $fullPath) {
id
flowMetrics {
issues: issueCount(from: $startDate, to: $endDate, labelNames: $labelNames) {
...FlowMetricItem
}
issues_completed: issuesCompletedCount(
from: $startDate
to: $endDate
labelNames: $labelNames
) {
...FlowMetricItem
}
cycle_time: cycleTime(from: $startDate, to: $endDate, labelNames: $labelNames) {
...FlowMetricItem
}
lead_time: leadTime(from: $startDate, to: $endDate, labelNames: $labelNames) {
...FlowMetricItem
}
deploys: deploymentCount(from: $startDate, to: $endDate) {
...FlowMetricItem
}
median_time_to_merge: timeToMerge(from: $startDate, to: $endDate) {
...FlowMetricItem
}
}
}
}

View File

@ -3,7 +3,7 @@ import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
import { joinPaths } from '~/lib/utils/url_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats, METRICS_POPOVER_CONTENT } from './constants';
import { dateFormats, VALUE_STREAM_METRIC_METADATA } from './constants';
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
if (!searchTerm?.length) return data;
@ -117,7 +117,7 @@ const requestData = ({ request, endpoint, requestPath, params, name }) => {
export const fetchMetricsData = (requests = [], requestPath, params) => {
const promises = requests.map((r) => requestData({ ...r, requestPath, params }));
return Promise.all(promises).then((responses) =>
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
prepareTimeMetricsData(flatten(responses), VALUE_STREAM_METRIC_METADATA),
);
};

View File

@ -229,7 +229,9 @@ export default {
"
>
<template #link="{ content }">
<gl-link @click="onToggleDrawer()">{{ content }}</gl-link>
<gl-link data-testid="how-to-install-btn" @click="onToggleDrawer()">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</p>

View File

@ -1,3 +1,5 @@
import { EMOJI_VERSION } from '~/emoji';
// On Windows, flags render as two-letter country codes, see http://emojipedia.org/flags/
const flagACodePoint = 127462; // parseInt('1F1E6', 16)
const flagZCodePoint = 127487; // parseInt('1F1FF', 16)
@ -72,6 +74,14 @@ function isPersonZwjEmoji(emojiUnicode) {
return hasPersonEmoji && hasZwj;
}
// If the backend emoji support is newer, then there may already be emojis in use
// that were not "supported" before but were displayable. In that scenario, we want to
// allow those emojis to be recognized and displayed, until the frontend (usually in the
// following release) is updated.
function isBackendEmojiNewer() {
return EMOJI_VERSION < gon.emoji_backend_version;
}
// Helper so we don't have to run `isFlagEmoji` twice
// in `isEmojiUnicodeSupported` logic
function checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) {
@ -119,7 +129,7 @@ function isEmojiUnicodeSupported(unicodeSupportMap = {}, emojiUnicode, unicodeVe
// For comments about each scenario, see the comments above each individual respective function
return (
unicodeSupportMap[unicodeVersion] &&
(unicodeSupportMap[unicodeVersion] || isBackendEmojiNewer()) &&
!(isOlderThanChrome57 && isKeycapEmoji(emojiUnicode)) &&
checkFlagEmojiSupport(unicodeSupportMap, emojiUnicode) &&
checkSkinToneModifierSupport(unicodeSupportMap, emojiUnicode) &&

View File

@ -1,10 +1,15 @@
<script>
import { GlIntersperse } from '@gitlab/ui';
import { GlIcon, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
name: 'ListPresenter',
components: {
GlIcon,
GlIntersperse,
GlLink,
GlSprintf,
},
inject: ['presenter'],
props: {
@ -32,6 +37,12 @@ export default {
fields() {
return this.config.fields;
},
docsPath() {
return helpPagePath('user/glql');
},
},
i18n: {
generatedMessage: __('Generated by %{linkStart}GLQL%{linkEnd}'),
},
};
</script>
@ -43,16 +54,23 @@ export default {
:key="itemIndex"
:data-testid="`list-item-${itemIndex}`"
>
<gl-intersperse separator=" - ">
<gl-intersperse separator=" · ">
<span v-for="field in fields" :key="field.key">
<component :is="presenter.forField(item, field.key)" />
</span>
</gl-intersperse>
</li>
<li v-if="!items.length">
<em>{{ __('No data found for this query') }}</em>
</li>
<div v-if="!items.length" :dismissible="false" variant="tip" class="!gl-my-2">
{{ __('No data found for this query') }}
</div>
</component>
<small>{{ __('Generated by GLQL') }}</small>
<div class="gl-mt-3 gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle">
<gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" />
<gl-sprintf :message="$options.i18n.generatedMessage">
<template #link="{ content }">
<gl-link :href="docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</div>
</template>

View File

@ -1,5 +1,7 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import Sorter from '../../core/sorter';
import ThResizable from '../common/th_resizable.vue';
@ -7,6 +9,8 @@ export default {
name: 'TablePresenter',
components: {
GlIcon,
GlLink,
GlSprintf,
ThResizable,
},
inject: ['presenter'],
@ -33,11 +37,19 @@ export default {
table: null,
};
},
computed: {
docsPath() {
return helpPagePath('user/glql');
},
},
async mounted() {
await this.$nextTick();
this.table = this.$refs.table;
},
i18n: {
generatedMessage: __('Generated by %{linkStart}GLQL%{linkEnd}'),
},
};
</script>
<template>
@ -72,11 +84,18 @@ export default {
</tr>
<tr v-if="!items.length">
<td :colspan="fields.length" class="gl-text-center">
<em>{{ __('No data found for this query') }}</em>
{{ __('No data found for this query') }}
</td>
</tr>
</tbody>
</table>
<small>{{ __('Generated by GLQL') }}</small>
<div class="gl-mt-3 gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle">
<gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" />
<gl-sprintf :message="$options.i18n.generatedMessage">
<template #link="{ content }">
<gl-link :href="docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</div>
</template>

View File

@ -201,7 +201,7 @@ export default {
class="gl-border-b gl-flex gl-items-center gl-justify-between gl-bg-gray-10 gl-p-4 gl-py-5"
>
<span>{{ $options.i18n.helpText }}</span>
<gl-button variant="confirm" @click="isDrawerOpen = true">{{
<gl-button variant="confirm" data-testid="add-exclusions-btn" @click="isDrawerOpen = true">{{
$options.i18n.addExclusions
}}</gl-button>
</div>

View File

@ -278,19 +278,25 @@ export default {
v-if="showSaveAndAdd"
variant="confirm"
category="secondary"
data-testid="save-and-add-button"
:disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@click="handleSave(true)"
>
{{ $options.i18n.saveAndAdd }}
</gl-button>
<gl-button :disabled="isEventProcessed" @click="$emit('cancel')">
<gl-button
:disabled="isEventProcessed"
data-testid="cancel-button"
@click="$emit('cancel')"
>
{{ $options.i18n.cancel }}
</gl-button>
<gl-button
v-if="isEditing"
variant="danger"
class="gl-ml-auto"
data-testid="delete-button"
:disabled="isEventProcessed"
@click="$emit('delete')"
>

View File

@ -170,6 +170,7 @@ export default {
<gl-button
variant="confirm"
type="submit"
data-testid="add-rule-btn"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
>{{ s__('ContainerRegistry|Add rule') }}</gl-button

View File

@ -310,6 +310,7 @@ export default {
:aria-label="$options.i18n.minimumAccessLevelForPush"
:options="minimumAccessLevelOptions"
:disabled="isProtectionRuleMinimumAccessLevelForPushFormSelectDisabled(item)"
data-testid="push-access-select"
@change="updateProtectionRuleMinimumAccessLevelForPush(item)"
/>
</template>
@ -323,6 +324,7 @@ export default {
:title="__('Delete')"
:aria-label="__('Delete')"
:disabled="isProtectionRuleDeleteButtonDisabled(item)"
data-testid="delete-btn"
@click="showProtectionRuleDeletionConfirmModal(item)"
/>
</template>

View File

@ -203,6 +203,7 @@ export default {
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
data-testid="add-rule-btn"
>{{ s__('PackageRegistry|Add rule') }}</gl-button
>
<gl-button class="gl-ml-3" type="reset">{{ __('Cancel') }}</gl-button>

View File

@ -305,6 +305,7 @@ export default {
:aria-label="$options.i18n.minimumAccessLevelForPush"
:options="minimumAccessLevelOptions"
:disabled="isProtectionRuleMinimumAccessLevelFormSelectDisabled(item)"
data-testid="push-access-select"
@change="updatePackageProtectionRule(item)"
/>
</template>
@ -317,6 +318,7 @@ export default {
icon="remove"
:title="__('Delete')"
:aria-label="__('Delete')"
data-testid="delete-rule-btn"
:disabled="isProtectionRuleDeleteButtonDisabled(item)"
@click="showProtectionRuleDeletionConfirmModal(item)"
/>

View File

@ -250,7 +250,12 @@ export default {
>
{{ $options.i18n.saveChanges }}
</gl-button>
<gl-button variant="confirm" category="secondary" @click="$emit('close')">
<gl-button
variant="confirm"
category="secondary"
data-testid="cancel-btn"
@click="$emit('close')"
>
{{ $options.i18n.cancel }}
</gl-button>
</div>

View File

@ -222,9 +222,13 @@ export default {
<template #title>
<div class="gl-flex gl-items-center gl-gap-2" data-testid="projects-dashboard-tab-title">
<span>{{ tab.text }}</span>
<gl-badge v-if="shouldShowCountBadge(tab)" size="sm" class="gl-tab-counter-badge">{{
numberToMetricPrefix(tabCount(tab))
}}</gl-badge>
<gl-badge
v-if="shouldShowCountBadge(tab)"
size="sm"
class="gl-tab-counter-badge"
data-testid="tab-counter-badge"
>{{ numberToMetricPrefix(tabCount(tab)) }}</gl-badge
>
</div>
</template>

View File

@ -66,12 +66,14 @@ export default {
<gl-button
category="secondary"
variant="confirm"
data-testid="view-tag-btn"
@click="() => navigate($options.tagRefType)"
>{{ $options.i18n.viewTagButton }}</gl-button
>
<gl-button
category="secondary"
variant="confirm"
data-testid="view-branch-btn"
@click="() => navigate($options.branchRefType)"
>{{ $options.i18n.viewBranchButton }}</gl-button
>

View File

@ -617,11 +617,10 @@ export default {
<ul class="border-top commits-list flex-list gl-list-none gl-p-0 gl-pt-4">
<commit-edit
v-if="shouldShowSquashEdit"
v-model="squashCommitMessage"
:label="__('Squash commit message')"
:value="squashCommitMessage"
input-id="squash-message-edit"
class="!gl-m-0 !gl-p-0"
data-testid="squash-commit-message"
@input="setSquashCommitMessage"
>
<template #header>

View File

@ -66,6 +66,7 @@ export default {
icon="remove"
:aria-label="deleteButtonLabel"
category="tertiary"
data-testid="delete-group-btn"
@click="$emit('delete', data.id)"
/>
</div>

View File

@ -53,6 +53,7 @@ export default {
icon="remove"
:aria-label="deleteButtonLabel"
category="tertiary"
data-testid="delete-user-btn"
@click="$emit('delete', data.id)"
/>
</span>

View File

@ -594,6 +594,7 @@ export default {
v-model="findAndReplace.find"
:placeholder="__('Find')"
autofocus
data-testid="find-btn"
@keydown="handleKeyDown"
/>
</div>

View File

@ -378,6 +378,7 @@ export default {
:href="starsHref"
:aria-label="$options.i18n.stars"
class="gl-text-secondary"
data-testid="stars-btn"
>
<gl-icon name="star-o" />
<span>{{ starCount }}</span>
@ -388,6 +389,7 @@ export default {
:href="forksHref"
:aria-label="$options.i18n.forks"
class="gl-text-secondary"
data-testid="forks-btn"
>
<gl-icon name="fork" />
<span>{{ forksCount }}</span>
@ -398,6 +400,7 @@ export default {
:href="mergeRequestsHref"
:aria-label="$options.i18n.mergeRequests"
class="gl-text-secondary"
data-testid="mrs-btn"
>
<gl-icon name="merge-request" />
<span>{{ openMergeRequestsCount }}</span>
@ -408,6 +411,7 @@ export default {
:href="issuesHref"
:aria-label="$options.i18n.issues"
class="gl-text-secondary"
data-testid="issues-btn"
>
<gl-icon name="issues" />
<span>{{ openIssuesCount }}</span>

View File

@ -142,7 +142,7 @@ export default {
</div>
</template>
<footer class="gl-flex gl-justify-end gl-gap-3 gl-pt-3">
<gl-button @click="onClose()">{{ $options.i18n.close }}</gl-button>
<gl-button data-testid="close-btn" @click="onClose()">{{ $options.i18n.close }}</gl-button>
<gl-button variant="confirm" @click="onOk()">
{{ $options.i18n.deployRunnerInAws }}
<gl-icon name="external-link" :aria-label="$options.i18n.externalLink" />

View File

@ -8,7 +8,6 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { STATUS_OPEN, STATUS_CLOSED } from '~/issues/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
@ -21,7 +20,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import WorkItemPrefetch from '~/work_items/components/work_item_prefetch.vue';
import { STATE_OPEN, STATE_CLOSED, LINKED_CATEGORIES_MAP } from '~/work_items/constants';
import { isAssigneesWidget, isLabelsWidget, findLinkedItemsWidget } from '~/work_items/utils';
import {
isAssigneesWidget,
isLabelsWidget,
findLinkedItemsWidget,
canRouterNav,
} from '~/work_items/utils';
export default {
components: {
@ -296,12 +300,14 @@ export default {
if (!this.fullPath) {
visitUrl(this.issuableLinkHref);
}
const escapedFullPath = escapeRegExp(this.fullPath);
// eslint-disable-next-line no-useless-escape
const regex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`);
const isWorkItemPath = regex.test(this.issuableLinkHref);
const shouldRouterNav = canRouterNav({
fullPath: this.fullPath,
webUrl: this.issuableLinkHref,
isGroup: this.isGroup,
issueAsWorkItem: this.issueAsWorkItem,
});
if (isWorkItemPath || this.issueAsWorkItem) {
if (shouldRouterNav) {
this.$router.push({
name: 'workItem',
params: {

View File

@ -10,7 +10,6 @@ import {
GlTooltip,
GlTooltipDirective,
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import { __, s__, sprintf } from '~/locale';
import { isScopedLabel } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -18,7 +17,7 @@ import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/w
import RichTimestampTooltip from '../rich_timestamp_tooltip.vue';
import WorkItemTypeIcon from '../work_item_type_icon.vue';
import WorkItemStateBadge from '../work_item_state_badge.vue';
import { findLinkedItemsWidget, getDisplayReference } from '../../utils';
import { canRouterNav, findLinkedItemsWidget, getDisplayReference } from '../../utils';
import {
STATE_OPEN,
WIDGET_TYPE_ASSIGNEES,
@ -165,12 +164,16 @@ export default {
if (e.metaKey || e.ctrlKey) {
return;
}
const escapedFullPath = escapeRegExp(this.workItemFullPath);
// eslint-disable-next-line no-useless-escape
const regex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`);
const isWorkItemPath = regex.test(workItem.webUrl);
const shouldDefaultNavigate =
this.preventRouterNav ||
!canRouterNav({
fullPath: this.workItemFullPath,
webUrl: workItem.webUrl,
isGroup: this.isGroup,
issueAsWorkItem: this.issueAsWorkItem,
});
if (!(isWorkItemPath || this.issueAsWorkItem) || this.preventRouterNav) {
if (shouldDefaultNavigate) {
this.$emit('click', e);
} else {
e.preventDefault();

View File

@ -1,6 +1,5 @@
<script>
import { GlLink, GlDrawer, GlButton, GlTooltipDirective, GlOutsideDirective } from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import { __ } from '~/locale';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -8,7 +7,7 @@ import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { DETAIL_VIEW_QUERY_PARAM_NAME } from '~/work_items/constants';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { visitUrl, setUrlParams, updateHistory, removeParams } from '~/lib/utils/url_utility';
import { makeDrawerItemFullPath, makeDrawerUrlParam } from '../utils';
import { makeDrawerItemFullPath, makeDrawerUrlParam, canRouterNav } from '../utils';
export default {
name: 'WorkItemDrawer',
@ -118,12 +117,16 @@ export default {
return;
}
e.preventDefault();
const escapedFullPath = escapeRegExp(this.fullPath);
// eslint-disable-next-line no-useless-escape
const regex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`);
const isWorkItemPath = regex.test(workItem.webUrl);
const shouldRouterNav =
!this.preventRouterNav &&
canRouterNav({
fullPath: this.fullPath,
webUrl: workItem.webUrl,
isGroup: this.isGroup,
issueAsWorkItem: this.issueAsWorkItem,
});
if (this.$router && (isWorkItemPath || this.issueAsWorkItem)) {
if (shouldRouterNav) {
this.$router.push({
name: 'workItem',
params: {

View File

@ -1,3 +1,4 @@
import { escapeRegExp } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { queryToObject } from '~/lib/utils/url_utility';
import AccessorUtilities from '~/lib/utils/accessor';
@ -311,3 +312,14 @@ export const getItems = (showClosed) => {
return children.filter((item) => isItemDisplayable(item, showClosed));
};
};
export const canRouterNav = ({ fullPath, webUrl, isGroup, issueAsWorkItem }) => {
const escapedFullPath = escapeRegExp(fullPath);
// eslint-disable-next-line no-useless-escape
const groupRegex = new RegExp(`groups\/${escapedFullPath}\/-\/(work_items|epics)\/\\d+`);
// eslint-disable-next-line no-useless-escape
const projectRegex = new RegExp(`${escapedFullPath}\/-\/(work_items|issues)\/\\d+`);
const canGroupNavigate = groupRegex.test(webUrl) && isGroup;
const canProjectNavigate = projectRegex.test(webUrl) && issueAsWorkItem;
return canGroupNavigate || canProjectNavigate;
};

View File

@ -80,12 +80,13 @@ module ResolvesMergeRequests
detailed_merge_status: [:merge_schedule],
milestone: [:milestone],
security_auto_fix: [:author],
head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }],
head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request, :project] }],
timelogs: [:timelogs],
pipelines: [:merge_request_diffs], # used by `recent_diff_head_shas` to load pipelines
committers: [merge_request_diff: [:merge_request_diff_commits]],
suggested_reviewers: [:predictions],
diff_stats: [latest_merge_request_diff: [:merge_request_diff_commits]]
diff_stats: [latest_merge_request_diff: [:merge_request_diff_commits]],
source_branch_exists: [:source_project, { source_project: [:route] }]
}
end

View File

@ -55,6 +55,9 @@
"manage_security_policy_link": {
"type": "boolean"
},
"read_admin_dashboard": {
"type": "boolean"
},
"read_code": {
"type": "boolean"
},
@ -64,6 +67,9 @@
"read_dependency": {
"type": "boolean"
},
"read_runners": {
"type": "boolean"
},
"read_vulnerability": {
"type": "boolean"
},
@ -72,9 +78,6 @@
},
"remove_project": {
"type": "boolean"
},
"read_runners": {
"type": "boolean"
}
}
}

View File

@ -2,34 +2,31 @@
- page_title _('Password')
- @force_desktop_expanded_sidebar = true
.settings-section.js-search-settings-section
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0
= page_title
%p.gl-text-secondary
= render ::Layouts::SettingsSectionComponent.new(page_title) do |c|
- c.with_description do
- if @user.password_automatically_set
= _('Change your password.')
- else
= _('Change your password or recover your current one.')
= gitlab_ui_form_for @user, url: user_settings_password_path, method: :put, html: {class: "update-password"} do |f|
= form_errors(@user)
- c.with_body do
= gitlab_ui_form_for @user, url: user_settings_password_path, method: :put, html: {class: "update-password"} do |f|
= form_errors(@user)
- unless @user.password_automatically_set?
.form-group
= f.label :password, _('Current password'), class: 'label-bold'
= f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'current-password-field' }
%p.form-text.gl-text-subtle
= _('You must provide your current password in order to change it.')
.form-group
= f.label :new_password, _('New password'), class: 'label-bold'
= f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { testid: 'new-password-field' }
= render_if_exists 'shared/password_requirements_list'
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'confirm-password-field' }
.gl-mt-3.gl-mb-3
= f.submit _('Save password'), class: "gl-mr-3", data: { testid: 'save-password-button' }, pajamas_button: true
- unless @user.password_automatically_set?
= render Pajamas::ButtonComponent.new(href: reset_user_settings_password_path, variant: :link, method: :put) do
= _('I forgot my password')
.form-group
= f.label :password, _('Current password'), class: 'label-bold'
= f.password_field :password, required: true, autocomplete: 'current-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'current-password-field' }
%p.form-text.gl-text-subtle
= _('You must provide your current password in order to change it.')
.form-group
= f.label :new_password, _('New password'), class: 'label-bold'
= f.password_field :new_password, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-max-w-80', data: { testid: 'new-password-field' }
= render_if_exists 'shared/password_requirements_list'
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, autocomplete: 'new-password', class: 'form-control gl-form-input gl-max-w-80', data: { testid: 'confirm-password-field' }
.gl-my-3
= f.submit _('Save password'), class: "gl-mr-3", data: { testid: 'save-password-button' }, pajamas_button: true
- unless @user.password_automatically_set?
= render Pajamas::ButtonComponent.new(href: reset_user_settings_password_path, variant: :link, method: :put) do
= _('I forgot my password')

View File

@ -8,12 +8,8 @@
.js-user-profile{ data: user_profile_data(@user) }
- else
= gitlab_ui_form_for @user, url: user_settings_profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
.settings-section.js-search-settings-section
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0
= s_("Profiles|Public avatar")
%p.gl-text-secondary
= render ::Layouts::SettingsSectionComponent.new(s_("Profiles|Public avatar")) do |c|
- c.with_description do
- if @user.avatar?
- if gravatar_enabled?
= s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
@ -27,150 +23,146 @@
- if current_appearance&.profile_image_guidelines?
.md
= brand_profile_image_guidelines
.avatar-image
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5')
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
.gl-flex.gl-items-center.gl-my-3
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do
= s_("Profiles|Choose file...")
%span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
= f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.gl-text-subtle
= s_("Profiles|The ideal image size is 192 x 192 pixels.")
= s_("Profiles|The maximum file size allowed is 200 KiB.")
- if @user.avatar?
= render Pajamas::ButtonComponent.new(variant: :danger,
category: :secondary,
href: profile_avatar_path,
button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } },
method: :delete) do
= s_("Profiles|Remove avatar")
- c.with_body do
.avatar-image
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5')
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
.gl-flex.gl-items-center.gl-my-3
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do
= s_("Profiles|Choose file...")
%span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
= f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.gl-text-subtle
= s_("Profiles|The ideal image size is 192 x 192 pixels.")
= s_("Profiles|The maximum file size allowed is 200 KiB.")
- if @user.avatar?
= render Pajamas::ButtonComponent.new(variant: :danger,
category: :secondary,
href: profile_avatar_path,
button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } },
method: :delete) do
= s_("Profiles|Remove avatar")
.settings-section.js-search-settings-section.gl-border-t.gl-pt-6
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0= s_("Profiles|Current status")
%p.gl-text-secondary= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
.gl-max-w-80
#js-user-profile-set-status-form
= f.fields_for :status, @user.status do |status_form|
= status_form.hidden_field :emoji, data: { js_name: 'emoji' }
= status_form.hidden_field :message, data: { js_name: 'message' }
= status_form.hidden_field :availability, data: { js_name: 'availability' }
= status_form.hidden_field :clear_status_after,
value: user_clear_status_at(@user),
data: { js_name: 'clearStatusAfter' }
= render ::Layouts::SettingsSectionComponent.new(s_("Profiles|Current status")) do |c|
- c.with_description do
= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
- c.with_body do
.gl-max-w-80
#js-user-profile-set-status-form
= f.fields_for :status, @user.status do |status_form|
= status_form.hidden_field :emoji, data: { js_name: 'emoji' }
= status_form.hidden_field :message, data: { js_name: 'message' }
= status_form.hidden_field :availability, data: { js_name: 'availability' }
= status_form.hidden_field :clear_status_after,
value: user_clear_status_at(@user),
data: { js_name: 'clearStatusAfter' }
.settings-section.user-time-preferences.js-search-settings-section.gl-border-t.gl-pt-6
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0= s_("Profiles|Time settings")
%p.gl-text-secondary= s_("Profiles|Set your local time zone.")
= f.label :user_timezone, _("Time zone")
.js-timezone-dropdown{ data: { timezone_data: timezone_data_with_unique_identifiers.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
= render ::Layouts::SettingsSectionComponent.new(s_("Profiles|Time settings"),
options: { class: 'user-time-preferences' }) do |c|
- c.with_description do
= s_("Profiles|Set your local time zone.")
- c.with_body do
= f.label :user_timezone, _("Time zone")
.js-timezone-dropdown{ data: { timezone_data: timezone_data_with_unique_identifiers.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
.settings-section.js-search-settings-section.gl-border-t.gl-pt-6
.settings-sticky-header
.settings-sticky-header-inner
%h4.gl-my-0
= s_("Profiles|Main settings")
%p.gl-text-secondary
= render ::Layouts::SettingsSectionComponent.new(s_("Profiles|Main settings")) do |c|
- c.with_description do
= s_("Profiles|This information will appear on your profile.")
- if current_user.ldap_user?
= s_("Profiles|Some options are unavailable for LDAP accounts")
.form-group.gl-form-group.rspec-full-name.gl-max-w-80
= render 'user_settings/profiles/name', form: f, user: @user
.form-group.gl-form-group.gl-md-form-input-lg
= f.label :id, s_('Profiles|User ID')
= f.text_field :id, class: 'gl-form-input form-control', readonly: true
.form-group.gl-form-group
= f.label :pronouns, s_('Profiles|Pronouns')
= f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-text.gl-text-subtle
= s_("Profiles|Enter your pronouns to let people know how to refer to you.")
.form-group.gl-form-group
= f.label :pronunciation, s_('Profiles|Pronunciation')
= f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-text.gl-text-subtle
= s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
= render_if_exists 'profiles/extra_settings', form: f
= render_if_exists 'user_settings/profiles/email_settings', form: f
.form-group.gl-form-group
= f.label :skype
= f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
.form-group.gl-form-group
= f.label :linkedin
= f.text_field :linkedin,
class: 'gl-form-input form-control gl-md-form-input-lg',
placeholder: "profilename"
.form-text.gl-text-subtle
= s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
.form-group.gl-form-group
= f.label :twitter, _('X (formerly Twitter)')
= f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username")
.form-group.gl-form-group
- external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID.'))
- min_discord_length = 17
- max_discord_length = 20
= f.label :discord
= f.text_field :discord,
class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
placeholder: s_("Profiles|User ID"),
data: { min_length: min_discord_length,
min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
max_length: max_discord_length,
max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
allow_empty: true}
.form-text.gl-text-subtle
= external_accounts_docs_link
.form-group.gl-form-group
= f.label :bluesky
= f.text_field :bluesky, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
.form-group.gl-form-group
= f.label :mastodon
= f.text_field :mastodon, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "@username@server_name"
.form-group.gl-form-group
= f.label :website_url, s_('Profiles|Website URL')
= f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com")
.form-group.gl-form-group
= f.label :location, s_('Profiles|Location')
- if @user.read_only_attribute?(:location)
= f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true
- c.with_body do
.form-group.gl-form-group.rspec-full-name.gl-max-w-80
= render 'user_settings/profiles/name', form: f, user: @user
.form-group.gl-form-group.gl-md-form-input-lg
= f.label :id, s_('Profiles|User ID')
= f.text_field :id, class: 'gl-form-input form-control', readonly: true
.form-group.gl-form-group
= f.label :pronouns, s_('Profiles|Pronouns')
= f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-text.gl-text-subtle
= s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- else
= f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country")
.form-group.gl-form-group
= f.label :job_title, s_('Profiles|Job title')
= f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-group.gl-form-group
= f.label :organization, s_('Profiles|Organization')
= f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-text.gl-text-subtle
= s_("Profiles|Who you represent or work for.")
.form-group.gl-form-group.gl-mb-6.gl-max-w-80
= f.label :bio, s_('Profiles|Bio')
= f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
.form-text.gl-text-subtle
= s_("Profiles|Tell us about yourself in fewer than 250 characters.")
.gl-border-t.gl-pt-6
%fieldset.form-group.gl-form-group
%legend.col-form-label
= _('Private profile')
= render_if_exists 'user_settings/profiles/private_profile', form: f, user: @user
%fieldset.form-group.gl-form-group
%legend.col-form-label
= s_("Profiles|Private contributions")
= f.gitlab_ui_checkbox_component :include_private_contributions,
s_('Profiles|Include private contributions on your profile'),
help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
%fieldset.form-group.gl-form-group.gl-mb-0
%legend.col-form-label
= s_("Profiles|Achievements")
= f.gitlab_ui_checkbox_component :achievements_enabled,
s_('Profiles|Display achievements on your profile')
= s_("Profiles|Enter your pronouns to let people know how to refer to you.")
.form-group.gl-form-group
= f.label :pronunciation, s_('Profiles|Pronunciation')
= f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-text.gl-text-subtle
= s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
= render_if_exists 'profiles/extra_settings', form: f
= render_if_exists 'user_settings/profiles/email_settings', form: f
.form-group.gl-form-group
= f.label :skype
= f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
.form-group.gl-form-group
= f.label :linkedin
= f.text_field :linkedin,
class: 'gl-form-input form-control gl-md-form-input-lg',
placeholder: "profilename"
.form-text.gl-text-subtle
= s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
.form-group.gl-form-group
= f.label :twitter, _('X (formerly Twitter)')
= f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username")
.form-group.gl-form-group
- external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID.'))
- min_discord_length = 17
- max_discord_length = 20
= f.label :discord
= f.text_field :discord,
class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
placeholder: s_("Profiles|User ID"),
data: { min_length: min_discord_length,
min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
max_length: max_discord_length,
max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
allow_empty: true}
.form-text.gl-text-subtle
= external_accounts_docs_link
.form-group.gl-form-group
= f.label :bluesky
= f.text_field :bluesky, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
.form-group.gl-form-group
= f.label :mastodon
= f.text_field :mastodon, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "@username@server_name"
.form-group.gl-form-group
= f.label :website_url, s_('Profiles|Website URL')
= f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com")
.form-group.gl-form-group
= f.label :location, s_('Profiles|Location')
- if @user.read_only_attribute?(:location)
= f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true
.form-text.gl-text-subtle
= s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- else
= f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country")
.form-group.gl-form-group
= f.label :job_title, s_('Profiles|Job title')
= f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-group.gl-form-group
= f.label :organization, s_('Profiles|Organization')
= f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
.form-text.gl-text-subtle
= s_("Profiles|Who you represent or work for.")
.form-group.gl-form-group.gl-mb-6.gl-max-w-80
= f.label :bio, s_('Profiles|Bio')
= f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
.form-text.gl-text-subtle
= s_("Profiles|Tell us about yourself in fewer than 250 characters.")
.gl-border-t.gl-pt-6
%fieldset.form-group.gl-form-group
%legend.col-form-label
= _('Private profile')
= render_if_exists 'user_settings/profiles/private_profile', form: f, user: @user
%fieldset.form-group.gl-form-group
%legend.col-form-label
= s_("Profiles|Private contributions")
= f.gitlab_ui_checkbox_component :include_private_contributions,
s_('Profiles|Include private contributions on your profile'),
help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
%fieldset.form-group.gl-form-group.gl-mb-0
%legend.col-form-label
= s_("Profiles|Achievements")
= f.gitlab_ui_checkbox_component :achievements_enabled,
s_('Profiles|Display achievements on your profile')
.js-hide-when-nothing-matches-search.settings-sticky-footer
= f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true

View File

@ -2,7 +2,7 @@
name: find_and_replace
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/481892
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/164251
rollout_issue_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/504599
milestone: '17.6'
group: group::knowledge
type: wip

View File

@ -0,0 +1,10 @@
---
migration_job_name: DeleteOrphanedPartitionedCiRunnerMachineRecords
description: >-
Removes ci_runner_machines_687967fa8a records that don't have a matching ci_runners_e59bb2812d record.
This can happen because there was a period in time where a FK didn't exist.
feature_category: fleet_visibility
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172422
milestone: '17.7'
queued_migration_version: 20241112165507
finalized_by: # version of the migration that finalized this BBM

View File

@ -8,8 +8,6 @@ description: Verification step status for DAST Profiles
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105702
milestone: '15.7'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,8 +8,6 @@ description: Verification status for DAST Profiles
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/103063
milestone: '15.6'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,7 +8,5 @@ description: Scheduling for scans using DAST Profiles
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65327
milestone: '14.2'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -8,7 +8,5 @@ description: Profile used to run a DAST on-demand scan
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51296
milestone: '13.9'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -8,8 +8,6 @@ description: Join table between DAST Profiles and CI Pipelines
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56821
milestone: '13.11'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,8 +8,6 @@ description: Join Table for Runner tags and DAST Profiles
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108371
milestone: '15.8'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -9,7 +9,5 @@ description: A scanner profile defines the scanner settings used to run an on-de
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37404
milestone: '13.3'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -8,8 +8,6 @@ description: Join table between DAST Scanner Profiles and CI Builds
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63362
milestone: '14.1'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,8 +8,6 @@ description: Secret variables used in DAST on-demand scans
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56067
milestone: '13.11'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -9,7 +9,5 @@ description: A site profile describes the attributes of a web site to scan on de
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36659
milestone: '13.2'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -8,8 +8,6 @@ description: Join table between DAST Site Profiles and CI Builds
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63362
milestone: '14.1'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,7 +8,5 @@ description: Token for the site to be validated
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41639
milestone: '13.4'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -8,8 +8,6 @@ description: The site to be validated with a dast_site_token
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41639
milestone: '13.4'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,7 +8,5 @@ description: Site to run dast scan on
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36659
milestone: '13.2'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -9,8 +9,6 @@ description: Stores a subset of the Finding data which is used to optimize the p
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40368
milestone: '13.4'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -0,0 +1,10 @@
---
table_name: user_member_roles
classes:
- Users::UserMemberRole
feature_categories:
- groups_and_projects
description: Stores association between users and member roles
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/TODO
milestone: '17.6'
gitlab_schema: gitlab_main_clusterwide

View File

@ -9,7 +9,5 @@ description: Stores identifiers (like CVE or CWE) for vulnerabilities that have
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/6896
milestone: '11.4'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -8,8 +8,6 @@ description: Stores state transitions of a Vulnerability
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87957
milestone: '15.1'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
desired_sharding_key:
project_id:
references: projects

View File

@ -8,7 +8,5 @@ description: Stores pre-calculated vulnerability statistics for projects
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34289
milestone: '13.2'
gitlab_schema: gitlab_sec
allow_cross_foreign_keys:
- gitlab_main_clusterwide
sharding_key:
project_id: projects

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class RemoveNotNullFromMemberRoleBaseAccessLevel < Gitlab::Database::Migration[2.2]
milestone '17.6'
def up
change_column_null :member_roles, :base_access_level, true
end
def down
change_column_null :member_roles, :base_access_level, false
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateUserMemberRole < Gitlab::Database::Migration[2.2]
milestone '17.6'
def change
create_table :user_member_roles do |t|
t.bigint :user_id, null: false
t.bigint :member_role_id, null: false
t.timestamps_with_timezone null: false
t.index [:user_id], name: 'idx_user_member_roles_on_user_id'
t.index [:member_role_id], name: 'idx_user_member_roles_on_member_role_id'
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddUserMemberRolesMemberRoleFk < Gitlab::Database::Migration[2.2]
milestone '17.6'
disable_ddl_transaction!
def up
add_concurrent_foreign_key :user_member_roles, :member_roles, column: :member_role_id, on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :user_member_roles, column: :member_role_id
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddUserMemberRolesUserFk < Gitlab::Database::Migration[2.2]
milestone '17.6'
disable_ddl_transaction!
def up
add_concurrent_foreign_key :user_member_roles, :users, column: :user_id, on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :user_member_roles, column: :user_id
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
# We only want to run once the migration that backfills ci_runners_e59bb2812d
# has completed. This migration then deletes all ci_runner_machines_687967fa8a records
# that don't have a matching ci_runners_e59bb2812d record
class QueueDeleteOrphanedPCiRunnerMachineRecordsOnDotCom < Gitlab::Database::Migration[2.2]
milestone '17.7'
restrict_gitlab_migration gitlab_schema: :gitlab_ci
MIGRATION = "DeleteOrphanedPartitionedCiRunnerMachineRecords"
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 1000
SUB_BATCH_SIZE = 100
def up
return unless Gitlab.com_except_jh?
queue_batched_background_migration(
MIGRATION,
:ci_runner_machines_687967fa8a,
:runner_id,
job_interval: DELAY_INTERVAL,
batch_size: BATCH_SIZE,
batch_class_name: 'LooseIndexScanBatchingStrategy',
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
return unless Gitlab.com_except_jh?
delete_batched_background_migration(MIGRATION, :ci_runner_machines_687967fa8a, :runner_id, [])
end
end

View File

@ -0,0 +1 @@
40b88c63c5f1b89c11349f2279334a522afb37b8b5d4eb0543d6c51ff16d606e

View File

@ -0,0 +1 @@
6c38636353f6d9d89b35b470372196491799d9647cff5119a81ff2eadb28b9fc

View File

@ -0,0 +1 @@
c0f6def9847d6821db47acf07d704cfdd7dd2469cd4a732a8254c452e88cdaf5

View File

@ -0,0 +1 @@
381c5fe87faa98ebe8d1dafceb05e2f67615d71c5eeee6b4a722ef68acdd6779

View File

@ -0,0 +1 @@
309a7700d6af5bea81160b93e9b1a2cf361df9dbd77f7cbabfc2e7f5d6e419e5

View File

@ -14114,7 +14114,7 @@ CREATE TABLE member_roles (
namespace_id bigint,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
base_access_level integer NOT NULL,
base_access_level integer,
name text DEFAULT 'Custom'::text NOT NULL,
description text,
occupies_seat boolean DEFAULT false NOT NULL,
@ -20662,6 +20662,23 @@ CREATE TABLE user_highest_roles (
highest_access_level integer
);
CREATE TABLE user_member_roles (
id bigint NOT NULL,
user_id bigint NOT NULL,
member_role_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE SEQUENCE user_member_roles_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE user_member_roles_id_seq OWNED BY user_member_roles.id;
CREATE TABLE user_namespace_callouts (
id bigint NOT NULL,
user_id bigint NOT NULL,
@ -23865,6 +23882,8 @@ ALTER TABLE ONLY user_details ALTER COLUMN user_id SET DEFAULT nextval('user_det
ALTER TABLE ONLY user_group_callouts ALTER COLUMN id SET DEFAULT nextval('user_group_callouts_id_seq'::regclass);
ALTER TABLE ONLY user_member_roles ALTER COLUMN id SET DEFAULT nextval('user_member_roles_id_seq'::regclass);
ALTER TABLE ONLY user_namespace_callouts ALTER COLUMN id SET DEFAULT nextval('user_namespace_callouts_id_seq'::regclass);
ALTER TABLE ONLY user_permission_export_uploads ALTER COLUMN id SET DEFAULT nextval('user_permission_export_uploads_id_seq'::regclass);
@ -26672,6 +26691,9 @@ ALTER TABLE ONLY user_group_callouts
ALTER TABLE ONLY user_highest_roles
ADD CONSTRAINT user_highest_roles_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY user_member_roles
ADD CONSTRAINT user_member_roles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY user_namespace_callouts
ADD CONSTRAINT user_namespace_callouts_pkey PRIMARY KEY (id);
@ -28580,6 +28602,10 @@ CREATE INDEX idx_user_credit_card_validations_on_similar_to_meta_data ON user_cr
CREATE INDEX idx_user_details_on_provisioned_by_group_id_user_id ON user_details USING btree (provisioned_by_group_id, user_id);
CREATE INDEX idx_user_member_roles_on_member_role_id ON user_member_roles USING btree (member_role_id);
CREATE INDEX idx_user_member_roles_on_user_id ON user_member_roles USING btree (user_id);
CREATE INDEX idx_vreg_pkgs_maven_cached_responses_on_group_id_status ON virtual_registries_packages_maven_cached_responses USING btree (group_id, status);
CREATE INDEX idx_vreg_pkgs_maven_cached_responses_on_relative_path_trigram ON virtual_registries_packages_maven_cached_responses USING gin (relative_path gin_trgm_ops);
@ -35993,6 +36019,9 @@ ALTER TABLE ONLY environments
ALTER TABLE ONLY epics
ADD CONSTRAINT fk_765e132668 FOREIGN KEY (work_item_parent_link_id) REFERENCES work_item_parent_links(id) ON DELETE SET NULL;
ALTER TABLE ONLY user_member_roles
ADD CONSTRAINT fk_76b9a6bfac FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY notes
ADD CONSTRAINT fk_76db6d50c6 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
@ -36578,6 +36607,9 @@ ALTER TABLE ONLY subscription_add_on_purchases
ALTER TABLE ONLY duo_workflows_workflows
ADD CONSTRAINT fk_cb28eb3e34 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY user_member_roles
ADD CONSTRAINT fk_cb5a805cd4 FOREIGN KEY (member_role_id) REFERENCES member_roles(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_board_labels
ADD CONSTRAINT fk_cb8ded70e2 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;

View File

@ -41,9 +41,7 @@ To configure your GitLab instance to access the AI gateway:
```ruby
gitlab_rails['env'] = {
'GITLAB_LICENSE_MODE' => 'production',
'CUSTOMER_PORTAL_URL' => 'https://customers.gitlab.com',
'AI_GATEWAY_URL' => '<path_to_your_ai_gateway>:<port>'
'AI_GATEWAY_URL' => '<path_to_your_ai_gateway>:<port>'
}
```

View File

@ -24542,6 +24542,22 @@ four standard [pagination arguments](#pagination-arguments):
| ---- | ---- | ----------- |
| <a id="groupcustomemojiincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Includes custom emoji from parent groups. |
##### `Group.customField`
A custom field configured for the group. Available only when feature flag `custom_fields_feature` is enabled.
DETAILS:
**Introduced** in GitLab 17.6.
**Status**: Experiment.
Returns [`CustomField`](#customfield).
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="groupcustomfieldid"></a>`id` | [`IssuablesCustomFieldID!`](#issuablescustomfieldid) | Global ID of the custom field. |
##### `Group.customFields`
Custom fields configured for the group. Available only when feature flag `custom_fields_feature` is enabled.
@ -39051,6 +39067,7 @@ Member role permission.
| <a id="memberrolepermissionmanage_merge_request_settings"></a>`MANAGE_MERGE_REQUEST_SETTINGS` | Configure merge request settings at the group or project level. Group actions include managing merge checks and approval settings. Project actions include managing MR configurations, approval rules and settings, and branch targets. In order to enable Suggested reviewers, the "Manage project access tokens" custom permission needs to be enabled. |
| <a id="memberrolepermissionmanage_project_access_tokens"></a>`MANAGE_PROJECT_ACCESS_TOKENS` | Create, read, update, and delete project access tokens. When creating a token, users with this custom permission must select a role for that token that has the same or fewer permissions as the default role used as the base for the custom role. |
| <a id="memberrolepermissionmanage_security_policy_link"></a>`MANAGE_SECURITY_POLICY_LINK` | Allows linking security policy projects. |
| <a id="memberrolepermissionread_admin_dashboard"></a>`READ_ADMIN_DASHBOARD` | Read-only access to admin dashboard. |
| <a id="memberrolepermissionread_code"></a>`READ_CODE` | Allows read-only access to the source code in the user interface. Does not allow users to edit or download repository archives, clone or pull repositories, view source code in an IDE, or view merge requests for private projects. You can download individual files because read-only access inherently grants the ability to make a local copy of the file. |
| <a id="memberrolepermissionread_crm_contact"></a>`READ_CRM_CONTACT` | Read CRM contact. |
| <a id="memberrolepermissionread_dependency"></a>`READ_DEPENDENCY` | Allows read-only access to the dependencies and licenses. |

View File

@ -6,6 +6,15 @@ info: Any user with at least the Maintainer role can merge updates to this conte
# Vue 3 Testing
As we transition to using Vue 3, it's important that our tests pass in Vue 3 mode.
We're adding progressively stricter checks to our pipelines to enforce proper Vue 3 testing.
Right now, we fail pipelines if:
1. A new test file is added that fails in Vue 3 mode.
1. An existing test file fails under Vue 3 that was previously passing.
1. One of the known failures on the [quarantine list](#quarantine-list) is now passing and has not been removed from the quarantine list.
## Running unit tests using Vue 3
To run unit tests using Vue 3, set the `VUE_VERSION` environment variable to `3` when executing jest.
@ -238,3 +247,29 @@ export default {
</script>
```
## Quarantine list
The `scripts/frontend/quarantined_vue3_specs.txt` file is built up of all the known failing Vue 3 test files.
In order to not overwhelm us with failing pipelines, these files are skipped on the Vue 3 test job.
If you're reading this, it's likely you were sent here by a failing quarantine job.
This job is confusing as it fails when a test passes and it passes if they all fail.
The reason for this is because all newly passing tests should be [removed from the quarantine list](#removing-from-the-quarantine-list).
Congratulate yourself on fixing a previously failing test and remove it fom the quarantine list to get this pipeline passing again.
### Removing from the quarantine list
If your pipeline is failing because of the `vue3 check quarantined` jobs, good news!
You fixed a previously failing test!
What you need to do now is remove the newly-passing test from the quarantine list.
This ensures that the test will continue to pass and prevent any further regressions.
### Adding to the quarantine list
Don't do it.
This list should only get smaller, not larger.
If your MR introduces a new test file or breaks a currently passing one, then you should fix it.
If you are moving a test file from one location to another, then it's okay to modify the location in the quarantine list.
However, before doing so, consider fixing the test first.

View File

@ -59,29 +59,6 @@ target branches.
Detected vulnerabilities appear in [merge requests](../index.md#merge-request), the [pipeline security tab](../index.md#pipeline-security-tab),
and the [vulnerability report](../index.md#vulnerability-report).
1. To see all vulnerabilities detected, either:
- From your project, select **Security & Compliance**, then **Vulnerability report**.
- From your pipeline, select the **Security** tab.
- From the merge request, go to the **Security scanning** widget and select **Full report** tab.
1. Select a DAST vulnerability's description. The following fields are examples of what a DAST analyzer may produce to aid investigation and rectification of the underlying cause. Each analyzer may output different fields.
| Field | Description |
|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------ |
| Description | Description of the vulnerability. |
| Evidence | Evidence of the data found that verified the vulnerability. Often a snippet of the request or response, this can be used to help verify that the finding is a vulnerability. |
| Identifiers | Identifiers of the vulnerability. |
| Links | Links to further details of the detected vulnerability. |
| Method | HTTP method used to detect the vulnerability. |
| Project | Namespace and project in which the vulnerability was detected. |
| Request Headers | Headers of the request. |
| Response Headers | Headers of the response received from the application. |
| Response Status | Response status received from the application. |
| Scanner Type | Type of vulnerability report. |
| Severity | Severity of the vulnerability. |
| Solution | Details of a recommended solution to the vulnerability. |
| URL | URL at which the vulnerability was detected. |
NOTE:
A pipeline may consist of multiple jobs, including SAST and DAST scanning. If any job
fails to finish for any reason, the security dashboard doesn't show DAST scanner output. For

View File

@ -23,6 +23,12 @@ Some permissions require having other permissions enabled first. For example, ad
These requirements are documented in the `Required permission` column in the following table.
## Admin
| Name | Required permission | Description | Introduced in | Feature flag | Enabled in |
|:-----|:------------|:------------------|:---------|:--------------|:---------|
| [`read_admin_dashboard`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/171581) | | Read-only access to admin dashboard | GitLab [17.6](https://gitlab.com/gitlab-org/gitlab/-/issues/501549) | | |
## Code review workflow
| Name | Required permission | Description | Introduced in | Feature flag | Enabled in |

View File

@ -65,10 +65,14 @@ Group items that are migrated to the destination GitLab instance include:
### Excluded items
Some group items are excluded from migration because they either:
Some group items are excluded from migration because they:
- May contain sensitive information: CI/CD variables, webhooks, and deploy tokens.
- Are not supported: push rules.
- Might contain sensitive information:
- CI/CD variables
- Deploy tokens
- Webhooks
- Are not supported:
- Push rules
## Migrated project items
@ -195,13 +199,15 @@ Setting-related project items that are migrated to the destination GitLab instan
### Excluded items
Some project items are excluded from migration because they either:
Some project items are excluded from migration because they:
- May contain sensitive information:
- Might contain sensitive information:
- CI/CD variables
- CI/CD job logs
- Container registry images
- Deploy keys
- Deploy tokens
- Encrypted tokens
- Job artifacts
- Pipeline schedule variables
- Pipeline triggers
@ -209,10 +215,10 @@ Some project items are excluded from migration because they either:
- Are not supported:
- Agents
- Approval rules
- Container Registry
- Container registry
- Environments
- Feature flags
- Infrastructure Registry
- Infrastructure registry
- Package registry
- Pages domains
- Remote mirrors

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
class DeleteOrphanedPartitionedCiRunnerMachineRecords < BatchedMigrationJob
operation_name :delete_orphaned_partitioned_ci_runner_machine_records
feature_category :fleet_visibility
class CiRunner < ::Ci::ApplicationRecord
self.table_name = :ci_runners_e59bb2812d
self.primary_key = :id
end
def perform
distinct_each_batch do |batch|
runner_ids = batch.pluck(batch_column)
runner_query = CiRunner
.where('ci_runner_machines_687967fa8a.runner_id = ci_runners_e59bb2812d.id')
.where('ci_runner_machines_687967fa8a.runner_type = ci_runners_e59bb2812d.runner_type')
.select(1)
base_relation
.where(batch_column => runner_ids)
.where('NOT EXISTS (?)', runner_query)
.delete_all
end
end
private
def base_relation
define_batchable_model(batch_table, connection: connection, primary_key: :id)
.where(batch_column => start_id..end_id)
end
end
end
end

View File

@ -43,6 +43,7 @@ module Gitlab
gon.sprite_icons = IconsHelper.sprite_icon_path
gon.sprite_file_icons = IconsHelper.sprite_file_icons_path
gon.emoji_sprites_css_path = universal_path_to_stylesheet('emoji_sprites')
gon.emoji_backend_version = Gitlab::Emoji::EMOJI_VERSION
gon.gridstack_css_path = universal_path_to_stylesheet('lazy_bundles/gridstack')
gon.test_env = Rails.env.test?
gon.disable_animations = Gitlab.config.gitlab['disable_animations']

View File

@ -17334,6 +17334,9 @@ msgstr ""
msgid "DORA4Metrics|Change failure rate (percentage)"
msgstr ""
msgid "DORA4Metrics|Commits"
msgstr ""
msgid "DORA4Metrics|Contributor count"
msgstr ""
@ -24262,7 +24265,7 @@ msgstr ""
msgid "Generate site and private keys at"
msgstr ""
msgid "Generated by GLQL"
msgid "Generated by %{linkStart}GLQL%{linkEnd}"
msgstr ""
msgid "Generated files are collapsed by default. To change this behavior, edit the %{tagStart}.gitattributes%{tagEnd} file. %{linkStart}Learn more.%{linkEnd}"
@ -60760,9 +60763,6 @@ msgstr[1] ""
msgid "ValueStreamAnalytics|&lt;1 minute"
msgstr ""
msgid "ValueStreamAnalytics|Average number of deployments to production per day."
msgstr ""
msgid "ValueStreamAnalytics|Average number of deployments to production per day. This metric measures how often value is delivered to end users."
msgstr ""
@ -60784,15 +60784,9 @@ msgstr ""
msgid "ValueStreamAnalytics|Lifecycle metrics"
msgstr ""
msgid "ValueStreamAnalytics|Median time an incident was open on a production environment in the given time period."
msgstr ""
msgid "ValueStreamAnalytics|Median time between merge request created and merge request merged."
msgstr ""
msgid "ValueStreamAnalytics|Median time between merge request merge and deployment to a production environment for all MRs deployed in the given time period."
msgstr ""
msgid "ValueStreamAnalytics|Median time from issue created to issue closed."
msgstr ""

View File

@ -73,7 +73,7 @@
"@gitlab/duo-ui": "^2.0.0",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.3.0",
"@gitlab/query-language": "^0.0.5-a-20241105",
"@gitlab/query-language": "^0.0.5-a-20241112",
"@gitlab/svgs": "3.121.0",
"@gitlab/ui": "102.1.0",
"@gitlab/vue-router-vue3": "npm:vue-router@4.1.6",

View File

@ -76,6 +76,9 @@ function section(header, callback, { showCollapsed = true } = {}) {
}
function reportPassingSpecsShouldBeUnquarantined(passed) {
const docsLink =
// eslint-disable-next-line no-restricted-syntax
'https://docs.gitlab.com/ee/development/testing_guide/testing_vue3.html#quarantine-list';
console.warn(' ');
console.warn(
`The following ${passed.length} spec file(s) now pass(es) under Vue 3, and so must be removed from quarantine:`,
@ -88,6 +91,7 @@ function reportPassingSpecsShouldBeUnquarantined(passed) {
`To fix this job, remove the file(s) listed above from the file ${chalk.underline('scripts/frontend/quarantined_vue3_specs.txt')}.`,
),
);
console.warn(`For more information, please see ${docsLink}.`);
}
async function changedFiles() {

View File

@ -205,7 +205,6 @@ spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js
spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js
spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js
spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
spec/frontend/ci/runner/components/registration/registration_instructions_spec.js
spec/frontend/ci/runner/components/runner_details_spec.js
spec/frontend/ci/runner/components/runner_form_fields_spec.js
spec/frontend/ci/runner/components/runner_managers_table_spec.js

View File

@ -9,20 +9,6 @@ import {
} from '@vue/test-utils';
import { compose } from 'lodash/fp';
/**
* Create a VTU wrapper from an element.
*
* If a Vue instance manages the element, the wrapper is created
* with that Vue instance.
*
* @param {HTMLElement} element
* @param {Object} options
* @returns {Wrapper} VTU wrapper
*/
const createWrapperFromElement = (element, options) =>
// eslint-disable-next-line no-underscore-dangle
createWrapper(element.__vue__ || element, options || {});
/**
* Query function type
* @callback FindFunction
@ -154,7 +140,7 @@ export const extendedWrapper = (wrapper) => {
if (!elements.length) {
return new ErrorWrapper(query);
}
return createWrapperFromElement(elements[0], this.options);
return createWrapper(elements[0], this.options);
},
},
};
@ -169,7 +155,7 @@ export const extendedWrapper = (wrapper) => {
const elements = testingLibrary[`queryAll${query}`](this.element, text, options);
const wrappers = elements.map((element) => {
const elementWrapper = createWrapperFromElement(element, this.options);
const elementWrapper = createWrapper(element, this.options);
elementWrapper.selector = text;
return elementWrapper;

View File

@ -140,12 +140,10 @@ describe('Vue test utils helpers', () => {
const text = 'foo bar';
const options = { selector: 'div' };
const mockDiv = document.createElement('div');
let mockVm;
let wrapper;
beforeEach(() => {
jest.spyOn(vtu, 'createWrapper');
mockVm = new Vue({ render: (h) => h('div') }).$mount();
wrapper = extendedWrapper(
shallowMount({
@ -180,19 +178,6 @@ describe('Vue test utils helpers', () => {
});
});
describe('when a Vue instance element is found', () => {
beforeEach(() => {
jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => [mockVm.$el]);
});
it('returns a VTU wrapper', () => {
const result = wrapper[findMethod](text, options);
expect(vtu.createWrapper).toHaveBeenCalledWith(mockVm, wrapper.options);
expect(result).toBeInstanceOf(VTUWrapper);
expect(result.vm).toBeInstanceOf(Vue);
});
});
describe('when multiple elements are found', () => {
beforeEach(() => {
const mockSpan = document.createElement('span');
@ -208,23 +193,6 @@ describe('Vue test utils helpers', () => {
});
});
describe('when multiple Vue instances are found', () => {
beforeEach(() => {
const mockVm2 = new Vue({ render: (h) => h('span') }).$mount();
jest
.spyOn(testingLibrary, expectedQuery)
.mockImplementation(() => [mockVm.$el, mockVm2.$el]);
});
it('returns the first element as a VTU wrapper', () => {
const result = wrapper[findMethod](text, options);
expect(vtu.createWrapper).toHaveBeenCalledWith(mockVm, wrapper.options);
expect(result).toBeInstanceOf(VTUWrapper);
expect(result.vm).toBeInstanceOf(Vue);
});
});
describe('when element is not found', () => {
beforeEach(() => {
jest.spyOn(testingLibrary, expectedQuery).mockImplementation(() => []);

View File

@ -5,7 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
import { VSA_METRICS_GROUPS, METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants';
import { VSA_METRICS_GROUPS, VALUE_STREAM_METRIC_METADATA } from '~/analytics/shared/constants';
import { prepareTimeMetricsData } from '~/analytics/shared/utils';
import MetricTile from '~/analytics/shared/components/metric_tile.vue';
import ValueStreamsDashboardLink from '~/analytics/shared/components/value_streams_dashboard_link.vue';
@ -91,7 +91,10 @@ describe('ValueStreamMetrics', () => {
});
describe('filterFn', () => {
const transferredMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
const transferredMetricsData = prepareTimeMetricsData(
metricsData,
VALUE_STREAM_METRIC_METADATA,
);
it('with a filter function, will call the function with the metrics data', async () => {
const filteredData = [

View File

@ -142,7 +142,7 @@ describe('RegistrationInstructions', () => {
expect(findPlatformsDrawer().props('open')).toBe(false);
await findByText('How do I install GitLab Runner?').vm.$emit('click');
await wrapper.findByTestId('how-to-install-btn').vm.$emit('click');
expect(findPlatformsDrawer().props('open')).toBe(true);
await findPlatformsDrawer().vm.$emit('close');

View File

@ -527,6 +527,10 @@ describe('emoji', () => {
});
describe('isEmojiUnicodeSupported', () => {
beforeEach(() => {
gon.emoji_backend_version = EMOJI_VERSION;
});
it('should gracefully handle empty string with unicode support', () => {
const isSupported = isEmojiUnicodeSupported({ '1.0': true }, '', '1.0');
@ -536,7 +540,7 @@ describe('emoji', () => {
it('should gracefully handle empty string without unicode support', () => {
const isSupported = isEmojiUnicodeSupported({}, '', '1.0');
expect(isSupported).toBeUndefined();
expect(isSupported).toBe(false);
});
it('bomb(6.0) with 6.0 support', () => {
@ -575,6 +579,32 @@ describe('emoji', () => {
expect(isSupported).toBe(false);
});
it('bomb(6.0) without 6.0 but with backend support', () => {
gon.emoji_backend_version = EMOJI_VERSION + 1;
const emojiKey = 'bomb';
const unicodeSupportMap = emptySupportMap;
const isSupported = isEmojiUnicodeSupported(
unicodeSupportMap,
emojiFixtureMap[emojiKey].moji,
emojiFixtureMap[emojiKey].unicodeVersion,
);
expect(isSupported).toBe(true);
});
it('bomb(6.0) without 6.0 with empty backend version', () => {
gon.emoji_backend_version = null;
const emojiKey = 'bomb';
const unicodeSupportMap = emptySupportMap;
const isSupported = isEmojiUnicodeSupported(
unicodeSupportMap,
emojiFixtureMap[emojiKey].moji,
emojiFixtureMap[emojiKey].unicodeVersion,
);
expect(isSupported).toBe(false);
});
it('construction_worker_tone5(8.0) without skin tone modifier support', () => {
const emojiKey = 'construction_worker_tone5';
const unicodeSupportMap = {

View File

@ -50,9 +50,9 @@ describe('ListPresenter', () => {
expect(htmlPresenter1.props('data')).toBe(MOCK_ISSUES.nodes[0].description);
expect(htmlPresenter2.props('data')).toBe(MOCK_ISSUES.nodes[1].description);
expect(listItem1.text()).toEqual('Issue 1 (#1) - @foobar - Open - This is a description');
expect(listItem1.text()).toEqual('Issue 1 (#1) · @foobar · Open · This is a description');
expect(listItem2.text()).toEqual(
'Issue 2 (#2 - closed) - @janedoe - Closed - This is another description',
'Issue 2 (#2 - closed) · @janedoe · Closed · This is another description',
);
});

View File

@ -20,7 +20,7 @@ describe('GLQL Query Parser', () => {
const result = await parseQuery(query, config);
expect(prettify(result)).toMatchInlineSnapshot(`
"{
"query GLQL {
issues(assigneeUsernames: "foobar", first: 50) {
nodes {
id
@ -57,7 +57,7 @@ describe('GLQL Query Parser', () => {
const result = await parseQuery(query, config);
expect(prettify(result)).toMatchInlineSnapshot(`
"{
"query GLQL {
issues(
assigneeUsernames: "foobar"
or: {labelNames: ["bug", "feature"]}

View File

@ -36,7 +36,7 @@ describe('ExclusionsList component', () => {
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
const findConfirmRemoveModal = () => wrapper.findComponent(ConfirmRemovalModal);
const findByText = (text) => wrapper.findByText(text);
const findAddExclusionsButton = () => findByText('Add exclusions');
const findAddExclusionsButton = () => wrapper.findByTestId('add-exclusions-btn');
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findDrawer = () => wrapper.findComponent(AddExclusionsDrawer);

View File

@ -54,10 +54,10 @@ describe('Timeline events form', () => {
});
const findMarkdownField = () => wrapper.findComponent(MarkdownField);
const findSubmitButton = () => wrapper.findByText(timelineFormI18n.save);
const findSubmitAndAddButton = () => wrapper.findByText(timelineFormI18n.saveAndAdd);
const findCancelButton = () => wrapper.findByText(timelineFormI18n.cancel);
const findDeleteButton = () => wrapper.findByText(timelineFormI18n.delete);
const findSubmitButton = () => wrapper.findByTestId('save-button');
const findSubmitAndAddButton = () => wrapper.findByTestId('save-and-add-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findDeleteButton = () => wrapper.findByTestId('delete-button');
const findDatePicker = () => wrapper.findComponent(GlDatepicker);
const findHourInput = () => wrapper.findByTestId('input-hours');
const findMinuteInput = () => wrapper.findByTestId('input-minutes');

View File

@ -18,10 +18,9 @@ describe('toggle replies widget for notes', () => {
const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser];
const findCollapseToggleButton = () =>
wrapper.findByRole('button', { text: ToggleRepliesWidget.i18n.collapseReplies });
const findExpandToggleButton = () =>
wrapper.findByRole('button', { text: ToggleRepliesWidget.i18n.expandReplies });
// const findCollapseToggleButton = () =>
// wrapper.findComponentByRole('button', { text: ToggleRepliesWidget.i18n.collapseReplies });
const findToggleButton = () => wrapper.findByTestId('replies-toggle');
const findRepliesButton = () => wrapper.findByRole('button', { text: '5 replies' });
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const findAvatars = () => wrapper.findComponent(GlAvatarsInline);
@ -36,9 +35,8 @@ describe('toggle replies widget for notes', () => {
});
it('renders collapsed state elements', () => {
expect(findExpandToggleButton().exists()).toBe(true);
expect(findExpandToggleButton().props('icon')).toBe('chevron-right');
expect(findExpandToggleButton().attributes('aria-label')).toBe('Expand replies');
expect(findToggleButton().props('icon')).toBe('chevron-right');
expect(findToggleButton().attributes('aria-label')).toBe('Expand replies');
expect(findAvatars().props('avatars')).toHaveLength(3);
expect(findRepliesButton().exists()).toBe(true);
expect(wrapper.text()).toContain('Last reply by');
@ -47,7 +45,7 @@ describe('toggle replies widget for notes', () => {
});
it('emits "toggle" event when expand toggle button is clicked', () => {
findExpandToggleButton().trigger('click');
findToggleButton().trigger('click');
expect(wrapper.emitted('toggle')).toEqual([[]]);
});
@ -65,13 +63,12 @@ describe('toggle replies widget for notes', () => {
});
it('renders expanded state elements', () => {
expect(findCollapseToggleButton().exists()).toBe(true);
expect(findCollapseToggleButton().props('icon')).toBe('chevron-down');
expect(findCollapseToggleButton().attributes('aria-label')).toBe('Collapse replies');
expect(findToggleButton().props('icon')).toBe('chevron-down');
expect(findToggleButton().attributes('aria-label')).toBe('Collapse replies');
});
it('emits "toggle" event when collapse toggle button is clicked', () => {
findCollapseToggleButton().trigger('click');
findToggleButton().trigger('click');
expect(wrapper.emitted('toggle')).toEqual([[]]);
});

View File

@ -27,7 +27,7 @@ describe('container Protection Rule Form', () => {
wrapper.findByRole('textbox', { name: /repository path pattern/i });
const findMinimumAccessLevelForPushSelect = () =>
wrapper.findByRole('combobox', { name: /minimum access level for push/i });
const findSubmitButton = () => wrapper.findByRole('button', { name: /add rule/i });
const findSubmitButton = () => wrapper.findByTestId('add-rule-btn');
const mountComponent = ({ config, provide = defaultProvidedValues } = {}) => {
wrapper = mountExtended(ContainerProtectionRuleForm, {

View File

@ -280,15 +280,13 @@ describe('Container protection rules project settings', () => {
});
describe.each`
comboboxName | minimumAccessLevelAttribute
${'Minimum access level for push'} | ${'minimumAccessLevelForPush'}
comboboxName | minimumAccessLevelAttribute
${'push-access-select'} | ${'minimumAccessLevelForPush'}
`(
'column "$comboboxName" with selectbox (combobox)',
({ comboboxName, minimumAccessLevelAttribute }) => {
const findComboboxInTableRow = (i) =>
extendedWrapper(findTableRow(i).findByRole('combobox', { name: comboboxName }));
const findAllComboboxesInTableRow = (i) =>
extendedWrapper(findTableRow(i).findAllByRole('combobox'));
extendedWrapper(wrapper.findAllByTestId(comboboxName).at(i));
it('contains correct access level as options', async () => {
createComponent();
@ -358,25 +356,19 @@ describe('Container protection rules project settings', () => {
await findComboboxInTableRow(0).setValue(accessLevelValueOwner);
findAllComboboxesInTableRow(0).wrappers.forEach((combobox) =>
expect(combobox.props('disabled')).toBe(true),
);
expect(findTableRowButtonDelete(0).props('disabled')).toBe(true);
findAllComboboxesInTableRow(1).wrappers.forEach((combobox) =>
expect(combobox.props('disabled')).toBe(false),
);
expect(findTableRowButtonDelete(1).props('disabled')).toBe(false);
expect(findComboboxInTableRow(0).props('disabled')).toBe(true);
expect(findTableRowButtonDelete(0).attributes('disabled')).toBe('disabled');
expect(findComboboxInTableRow(1).props('disabled')).toBe(false);
expect(findTableRowButtonDelete(1).attributes('disabled')).toBeUndefined();
await waitForPromises();
findAllComboboxesInTableRow(0).wrappers.forEach((combobox) =>
expect(combobox.props('disabled')).toBe(false),
);
expect(findTableRowButtonDelete(0).props('disabled')).toBe(false);
findAllComboboxesInTableRow(1).wrappers.forEach((combobox) =>
expect(combobox.props('disabled')).toBe(false),
);
expect(findTableRowButtonDelete(1).props('disabled')).toBe(false);
expect(findComboboxInTableRow(0).props('disabled')).toBe(false);
expect(findTableRowButtonDelete(0).attributes('disabled')).toBeUndefined();
expect(findComboboxInTableRow(1).props('disabled')).toBe(false);
expect(findTableRowButtonDelete(1).attributes('disabled')).toBeUndefined();
});
it('handles erroneous graphql mutation', async () => {
@ -485,9 +477,9 @@ describe('Container protection rules project settings', () => {
await clickOnModalPrimaryBtn();
expect(findTableRowButtonDelete(0).props().disabled).toBe(true);
expect(findTableRowButtonDelete(0).attributes('disabled')).toBe('disabled');
expect(findTableRowButtonDelete(1).props().disabled).toBe(false);
expect(findTableRowButtonDelete(1).attributes('disabled')).toBeUndefined();
});
it('sends graphql mutation', async () => {

View File

@ -31,7 +31,7 @@ describe('Packages Protection Rule Form', () => {
const findPackageTypeSelect = () => wrapper.findByRole('combobox', { name: /type/i });
const findMinimumAccessLevelForPushSelect = () =>
wrapper.findByRole('combobox', { name: /minimum access level for push/i });
const findSubmitButton = () => wrapper.findByRole('button', { name: /add rule/i });
const findSubmitButton = () => wrapper.findByTestId('add-rule-btn');
const findForm = () => wrapper.findComponent(GlForm);
const mountComponent = ({ data, config, provide = defaultProvidedValues } = {}) => {

View File

@ -35,7 +35,8 @@ describe('Packages protection rules project settings', () => {
extendedWrapper(wrapper.findByRole('table', { name: /protected packages/i }));
const findTableBody = () => extendedWrapper(findTable().findAllByRole('rowgroup').at(1));
const findTableRow = (i) => extendedWrapper(findTableBody().findAllByRole('row').at(i));
const findTableRowButtonDelete = (i) => findTableRow(i).findByRole('button', { name: /delete/i });
const findTableRowButtonDelete = (i) =>
extendedWrapper(wrapper.findAllByTestId('delete-rule-btn').at(i));
const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findProtectionRuleForm = () => wrapper.findComponent(PackagesProtectionRuleForm);
const findAddProtectionRuleButton = () =>
@ -262,9 +263,7 @@ describe('Packages protection rules project settings', () => {
describe('column "Minimum access level for push" with selectbox (combobox)', () => {
const findComboboxInTableRow = (i) =>
extendedWrapper(
findTableRow(i).findByRole('combobox', { name: /minimum access level for push/i }),
);
extendedWrapper(wrapper.findAllByTestId('push-access-select').at(i));
it('contains combobox with respective access level', async () => {
createComponent();

View File

@ -19,7 +19,7 @@ describe('Edit Rule Drawer', () => {
let wrapper;
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findCancelButton = () => wrapper.findByText('Cancel');
const findCancelButton = () => wrapper.findByTestId('cancel-btn');
const findHeader = () => wrapper.find('h2');
const findSaveButton = () => wrapper.findByTestId('save-allowed-to-merge');
const findCheckboxes = () => wrapper.findAllComponents(GlFormCheckbox);

View File

@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { GlBadge, GlTabs, GlFilteredSearchToken } from '@gitlab/ui';
import projectCountsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/project_counts.query.graphql.json';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import YourWorkProjectsApp from '~/projects/your_work/components/app.vue';
import TabView from '~/projects/your_work/components/tab_view.vue';
import { createRouter } from '~/projects/your_work';
@ -85,7 +85,8 @@ describe('YourWorkProjectsApp', () => {
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
const findTabByName = (name) =>
wrapper.findAllByRole('tab').wrappers.find((tab) => tab.text().includes(name));
const getTabCount = (tabName) => findTabByName(tabName).findComponent(GlBadge).text();
const getTabCount = (tabName) =>
extendedWrapper(findTabByName(tabName)).findByTestId('tab-counter-badge').text();
const findFilteredSearchAndSort = () => wrapper.findComponent(FilteredSearchAndSort);
const findTabView = () => wrapper.findComponent(TabView);

View File

@ -29,9 +29,8 @@ describe('AmbiguousRefModal component', () => {
beforeEach(() => createComponent());
const findModal = () => wrapper.findComponent(GlModal);
const findByText = (text) => wrapper.findByText(text);
const findViewTagButton = () => findByText('View tag');
const findViewBranchButton = () => findByText('View branch');
const findViewTagButton = () => wrapper.findByTestId('view-tag-btn');
const findViewBranchButton = () => wrapper.findByTestId('view-branch-btn');
it('renders a GlModal component with the correct props', () => {
expect(showModalSpy).toHaveBeenCalled();

View File

@ -157,7 +157,7 @@ const triggerApprovalUpdated = () => eventHub.$emit('ApprovalUpdated');
const triggerEditCommitInput = () =>
wrapper.find('[data-testid="widget_edit_commit_message"]').vm.$emit('input', true);
const triggerEditSquashInput = (text) =>
wrapper.find('[data-testid="squash-commit-message"]').vm.$emit('input', text);
findCommitEditWithInputId('squash-message-edit').vm.$emit('input', text);
const triggerEditMergeInput = (text) =>
wrapper.find('[data-testid="merge-commit-message"]').vm.$emit('input', text);
const findMergeHelperText = () => wrapper.find('[data-testid="auto-merge-helper-text"]');
@ -417,54 +417,35 @@ describe('ReadyToMerge', () => {
describe('with squashing', () => {
const NEW_SQUASH_MESSAGE = 'updated squash message';
it('sends the user-updated squash message', async () => {
beforeEach(async () => {
createComponent({
mr: { shouldRemoveSourceBranch: false, enableSquashBeforeMerge: true },
});
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
jest.spyOn(service, 'merge').mockResolvedValue(response('merge_when_pipeline_succeeds'));
await triggerEditCommitInput();
await findCheckboxElement().vm.$emit('input', true);
});
it('sends the user-updated squash message', async () => {
await triggerEditSquashInput(NEW_SQUASH_MESSAGE);
await findMergeButton().vm.$emit('click');
findMergeButton().vm.$emit('click');
await waitForPromises();
const params = service.merge.mock.calls[0][0];
expect(params).toEqual(
expect(service.merge).toHaveBeenCalledWith(
expect.objectContaining({
sha: '12345678',
should_remove_source_branch: false,
auto_merge_strategy: 'merge_when_pipeline_succeeds',
squash_commit_message: NEW_SQUASH_MESSAGE,
}),
);
});
it('does not send the squash message if the user has not updated it', async () => {
createComponent({
mr: { shouldRemoveSourceBranch: false, enableSquashBeforeMerge: true },
});
await findMergeButton().vm.$emit('click');
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
jest.spyOn(service, 'merge').mockResolvedValue(response('merge_when_pipeline_succeeds'));
await triggerEditCommitInput();
await findCheckboxElement().vm.$emit('input', true);
findMergeButton().vm.$emit('click');
await waitForPromises();
const params = service.merge.mock.calls[0][0];
expect(params).toEqual(
expect(service.merge).toHaveBeenCalledTimes(1);
expect(service.merge).toHaveBeenCalledWith(
expect.not.objectContaining({
squash_commit_message: expect.any(String),
squash_commit_message: expect.anything(),
}),
);
});
@ -473,49 +454,31 @@ describe('ReadyToMerge', () => {
describe('without squashing', () => {
const NEW_COMMIT_MESSAGE = 'updated commit message';
it('sends the user-updated commit message', async () => {
beforeEach(async () => {
createComponent({ mr: { shouldRemoveSourceBranch: false } });
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
jest.spyOn(service, 'merge').mockResolvedValue(response('merge_when_pipeline_succeeds'));
await triggerEditCommitInput(); // Note this is intentional: `commit_message` shouldn't send until they actually edit it, even if they check the box
});
await triggerEditCommitInput();
it('sends the user-updated commit message', async () => {
await triggerEditMergeInput(NEW_COMMIT_MESSAGE);
await findMergeButton().vm.$emit('click');
findMergeButton().vm.$emit('click');
await waitForPromises();
const params = service.merge.mock.calls[0][0];
expect(params).toEqual(
expect(service.merge).toHaveBeenCalledWith(
expect.objectContaining({
sha: '12345678',
should_remove_source_branch: false,
auto_merge_strategy: 'merge_when_pipeline_succeeds',
commit_message: NEW_COMMIT_MESSAGE,
squash: false,
skip_merge_train: false,
}),
);
});
it('does not send the commit message if the user has not updated it', async () => {
createComponent({ mr: { shouldRemoveSourceBranch: false } });
await findMergeButton().vm.$emit('click');
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
jest.spyOn(service, 'merge').mockResolvedValue(response('merge_when_pipeline_succeeds'));
await triggerEditCommitInput(); // Note this is intentional: `commit_message` shouldn't send until they actually edit it, even if they check the box
findMergeButton().vm.$emit('click');
await waitForPromises();
const params = service.merge.mock.calls[0][0];
expect(params).toEqual(
expect(service.merge).toHaveBeenCalledTimes(1);
expect(service.merge).toHaveBeenCalledWith(
expect.not.objectContaining({
commit_message: expect.any(String),
commit_message: expect.anything(),
}),
);
});

View File

@ -423,7 +423,8 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
it('when full report is clicked it should call the respective telemetry event', async () => {
expect(wrapper.vm.telemetryHub.fullReportClicked).not.toHaveBeenCalled();
wrapper.findByText('Full report').vm.$emit('click');
wrapper.findByTestId('extension-actions-button').vm.$emit('click');
await nextTick();
expect(wrapper.vm.telemetryHub.fullReportClicked).toHaveBeenCalledTimes(1);
});

View File

@ -18,7 +18,7 @@ describe('GroupItem spec', () => {
};
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findDeleteButton = () => wrapper.findByRole('button', { fullName: 'Delete Group 1' });
const findDeleteButton = () => wrapper.findByTestId('delete-group-btn');
beforeEach(() => createComponent());

View File

@ -17,7 +17,7 @@ describe('UserItem spec', () => {
};
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findDeleteButton = () => wrapper.findByRole('button', { name: 'Delete Admin' });
const findDeleteButton = () => wrapper.findByTestId('delete-user-btn');
beforeEach(() => createComponent());

Some files were not shown because too many files have changed in this diff Show More