Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-06-17 12:20:51 +00:00
parent b70b663063
commit 2fbf0bd5c0
35 changed files with 1186 additions and 438 deletions

View File

@ -105,6 +105,8 @@ export default {
},
methods: {
onSelect(value) {
this.selectedValue = value;
if (this.isCustomDateRangeSelected) {
this.$emit('customDateRangeSelected');
} else {
@ -130,7 +132,7 @@ export default {
<template>
<div class="gl-flex gl-items-center gl-gap-3">
<gl-collapsible-listbox
v-model="selectedValue"
:selected="selectedValue"
:items="items"
:header-text="$options.i18n.label"
@select="onSelect"

View File

@ -210,7 +210,7 @@ export default {
<template v-if="hasError">
<gl-alert
:variant="error.variant"
class="gl-mb-3"
class="gl-my-3"
:primary-button-text="error.action"
@dismiss="dismissAlert"
@primaryAction="reloadGlqlBlock"

View File

@ -3,7 +3,6 @@ import { gql } from '@apollo/client/core';
import createDefaultClient from '~/lib/graphql';
import TaskQueue from '../utils/task_queue';
import { extractGroupOrProject } from '../utils/common';
import { joinPaths } from '../../lib/utils/url_utility';
const CONCURRENCY_LIMIT = 1;
@ -25,11 +24,9 @@ export default class Executor {
init(client) {
Executor.taskQueue = Executor.taskQueue || new TaskQueue(CONCURRENCY_LIMIT);
const glqlPath =
joinPaths(gon.relative_url_root || '', '/api/glql?') +
new URLSearchParams(extractGroupOrProject());
const searchParams = new URLSearchParams(extractGroupOrProject());
this.#client = client || createDefaultClient({}, { path: glqlPath });
this.#client = client || createDefaultClient({}, { path: `/api/glql?${searchParams}` });
return this;
}

View File

@ -1,6 +1,8 @@
import { glqlWorkItemsFeatureFlagEnabled } from '../../utils/feature_flags';
const fieldAliases = {
// We don't want to expose the id (GID) field to the user, so we alias it to iid
id: 'iid',
assignee: 'assignees',
closed: 'closedAt',
created: 'createdAt',

View File

@ -92,7 +92,7 @@ export default {
</script>
<template>
<div class="mr-users-list gl-flex gl-justify-center">
<div class="mr-users-list gl-relative gl-flex gl-justify-center">
<gl-avatars-inline
v-if="sortedUsers.length"
:avatars="sortedUsers"

View File

@ -16,10 +16,10 @@ query getProjectCounts(
count
}
}
personal: projects(personal: true) @skip(if: $skipPersonal) {
personal: projects(personal: true, archived: EXCLUDE) @skip(if: $skipPersonal) {
count
}
member: projects(membership: true) @skip(if: $skipMember) {
member: projects(membership: true, archived: EXCLUDE) @skip(if: $skipMember) {
count
}
inactive: projects(archived: ONLY, membership: true) @skip(if: $skipInactive) {

View File

@ -30,6 +30,7 @@ import PageHeading from '~/vue_shared/components/page_heading.vue';
import {
getDisplayReference,
getNewWorkItemAutoSaveKey,
getNewWorkItemWidgetsAutoSaveKey,
newWorkItemFullPath,
} from '~/work_items/utils';
import {
@ -644,6 +645,28 @@ export default {
this.discussionToResolve && this.mergeRequestToResolveDiscussionsOf ? '1' : 'all';
}
},
clearAutosaveDraft({ fullPath, workItemType }) {
const fullDraftAutosaveKey = getNewWorkItemAutoSaveKey({
fullPath,
workItemType,
});
clearDraft(fullDraftAutosaveKey);
const widgetsAutosaveKey = getNewWorkItemWidgetsAutoSaveKey({
fullPath,
});
clearDraft(widgetsAutosaveKey);
},
handleChangeType() {
setNewWorkItemCache({
fullPath: this.selectedProjectFullPath,
widgetDefinitions: this.selectedWorkItemType?.widgetDefinitions || [],
workItemType: this.selectedWorkItemTypeName,
workItemTypeId: this.selectedWorkItemTypeId,
workItemTypeIconName: this.selectedWorkItemTypeIconName,
});
this.$emit('changeType', this.selectedWorkItemTypeName);
},
async updateDraftData(type, value) {
if (type === 'title') {
this.localTitle = value;
@ -845,11 +868,10 @@ export default {
numberOfDiscussionsResolved: this.numberOfDiscussionsResolved,
});
const autosaveKey = getNewWorkItemAutoSaveKey({
this.clearAutosaveDraft({
fullPath: this.selectedProjectFullPath,
workItemType: this.selectedWorkItemTypeName,
});
clearDraft(autosaveKey);
} catch {
this.error = this.createErrorText;
this.loading = false;
@ -868,17 +890,16 @@ export default {
}
},
handleDiscardDraft() {
const autosaveKey = getNewWorkItemAutoSaveKey({
this.clearAutosaveDraft({
fullPath: this.selectedProjectFullPath,
workItemType: this.selectedWorkItemTypeName,
});
clearDraft(autosaveKey);
const selectedWorkItemWidgets = this.selectedWorkItemType?.widgetDefinitions || [];
setNewWorkItemCache({
fullPath: this.selectedProjectFullPath,
workItemWidgetDefinitions: selectedWorkItemWidgets,
widgetDefinitions: selectedWorkItemWidgets,
workItemType: this.selectedWorkItemTypeName,
workItemTypeId: this.selectedWorkItemTypeId,
workItemTypeIconName: this.selectedWorkItemTypeIconName,
@ -942,7 +963,7 @@ export default {
v-model="selectedWorkItemTypeId"
data-testid="work-item-types-select"
:options="formOptions"
@change="$emit('changeType', selectedWorkItemTypeName)"
@change="handleChangeType"
/>
</gl-form-group>
</div>

View File

@ -46,11 +46,13 @@ import {
findHierarchyWidgetChildren,
findNotesWidget,
getNewWorkItemAutoSaveKey,
getNewWorkItemWidgetsAutoSaveKey,
isNotesWidget,
newWorkItemFullPath,
newWorkItemId,
findColorWidget,
findStatusWidget,
getWorkItemWidgets,
} from '../utils';
import workItemByIidQuery from './work_item_by_iid.query.graphql';
import workItemByIdQuery from './work_item_by_id.query.graphql';
@ -316,6 +318,254 @@ export const updateWorkItemCurrentTodosWidget = ({ cache, fullPath, iid, todos }
cache.writeQuery({ ...query, data: newData });
};
export const getNewWorkItemSharedCache = ({
workItemAttributesWrapperOrder,
widgetDefinitions,
fullPath,
workItemType,
isValidWorkItemDescription,
workItemDescription = '',
}) => {
const widgetsAutosaveKey = getNewWorkItemWidgetsAutoSaveKey({ fullPath });
const fullDraftAutosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType });
const workItemTypeSpecificWidgets =
getWorkItemWidgets(JSON.parse(getDraft(fullDraftAutosaveKey))) || {};
const sharedCacheWidgets = JSON.parse(getDraft(widgetsAutosaveKey)) || {};
const availableWidgets = widgetDefinitions?.flatMap((i) => i.type) || [];
const draftTitle = sharedCacheWidgets.TITLE || '';
const draftDescription = sharedCacheWidgets[WIDGET_TYPE_DESCRIPTION]?.description || null;
const widgets = [];
widgets.push({
type: WIDGET_TYPE_DESCRIPTION,
description: isValidWorkItemDescription ? workItemDescription : draftDescription,
descriptionHtml: '',
lastEditedAt: null,
lastEditedBy: null,
taskCompletionStatus: null,
__typename: 'WorkItemWidgetDescription',
});
workItemAttributesWrapperOrder.forEach((widgetName) => {
if (availableWidgets.includes(widgetName)) {
if (widgetName === WIDGET_TYPE_ASSIGNEES) {
const assigneesWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_ASSIGNEES,
);
widgets.push({
type: 'ASSIGNEES',
allowsMultipleAssignees: assigneesWidgetData.allowsMultipleAssignees || false,
canInviteMembers: assigneesWidgetData.canInviteMembers || false,
assignees: {
nodes: sharedCacheWidgets[WIDGET_TYPE_ASSIGNEES]
? sharedCacheWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees.nodes || []
: [],
__typename: 'UserCoreConnection',
},
__typename: 'WorkItemWidgetAssignees',
});
}
if (widgetName === WIDGET_TYPE_LINKED_ITEMS) {
widgets.push({
type: WIDGET_TYPE_LINKED_ITEMS,
blockingCount: 0,
blockedByCount: 0,
linkedItems: {
nodes: [],
},
__typename: 'WorkItemWidgetLinkedItems',
});
}
if (widgetName === WIDGET_TYPE_CRM_CONTACTS) {
widgets.push({
type: 'CRM_CONTACTS',
contactsAvailable: false,
contacts: {
nodes: sharedCacheWidgets[WIDGET_TYPE_CRM_CONTACTS]
? sharedCacheWidgets[WIDGET_TYPE_CRM_CONTACTS]?.contacts.nodes || []
: [],
__typename: 'CustomerRelationsContactConnection',
},
__typename: 'WorkItemWidgetCrmContacts',
});
}
if (widgetName === WIDGET_TYPE_LABELS) {
const labelsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_LABELS,
);
widgets.push({
type: 'LABELS',
allowsScopedLabels: labelsWidgetData.allowsScopedLabels,
labels: {
nodes: sharedCacheWidgets[WIDGET_TYPE_LABELS]
? sharedCacheWidgets[WIDGET_TYPE_LABELS]?.labels.nodes || []
: [],
__typename: 'LabelConnection',
},
__typename: 'WorkItemWidgetLabels',
});
}
if (widgetName === WIDGET_TYPE_WEIGHT) {
const weightWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_WEIGHT,
);
widgets.push({
type: 'WEIGHT',
weight: sharedCacheWidgets[WIDGET_TYPE_WEIGHT]
? sharedCacheWidgets[WIDGET_TYPE_WEIGHT]?.weight || null
: null,
rolledUpWeight: 0,
rolledUpCompletedWeight: 0,
widgetDefinition: {
editable: weightWidgetData?.editable,
rollUp: weightWidgetData?.rollUp,
},
__typename: 'WorkItemWidgetWeight',
});
}
if (widgetName === WIDGET_TYPE_MILESTONE) {
widgets.push({
type: 'MILESTONE',
milestone: sharedCacheWidgets[WIDGET_TYPE_MILESTONE]
? sharedCacheWidgets[WIDGET_TYPE_MILESTONE]?.milestone || null
: null,
projectMilestone: false,
__typename: 'WorkItemWidgetMilestone',
});
}
if (widgetName === WIDGET_TYPE_ITERATION) {
widgets.push({
iteration: sharedCacheWidgets[WIDGET_TYPE_ITERATION]
? sharedCacheWidgets[WIDGET_TYPE_ITERATION]?.iteration || null
: null,
type: 'ITERATION',
__typename: 'WorkItemWidgetIteration',
});
}
if (widgetName === WIDGET_TYPE_START_AND_DUE_DATE) {
const startDueDateDraft = sharedCacheWidgets[WIDGET_TYPE_START_AND_DUE_DATE] || {};
widgets.push({
type: 'START_AND_DUE_DATE',
dueDate: startDueDateDraft?.dueDate || null,
startDate: startDueDateDraft?.startDate || null,
isFixed: startDueDateDraft?.isFixed || false,
rollUp: false,
__typename: 'WorkItemWidgetStartAndDueDate',
});
}
if (widgetName === WIDGET_TYPE_PROGRESS) {
widgets.push({
type: 'PROGRESS',
progress: null,
updatedAt: null,
__typename: 'WorkItemWidgetProgress',
});
}
if (widgetName === WIDGET_TYPE_HEALTH_STATUS) {
widgets.push({
type: 'HEALTH_STATUS',
healthStatus: sharedCacheWidgets[WIDGET_TYPE_HEALTH_STATUS]
? sharedCacheWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus || null
: null,
rolledUpHealthStatus: [],
__typename: 'WorkItemWidgetHealthStatus',
});
}
if (widgetName === WIDGET_TYPE_COLOR) {
widgets.push({
type: 'COLOR',
color: sharedCacheWidgets[WIDGET_TYPE_COLOR]
? sharedCacheWidgets[WIDGET_TYPE_COLOR]?.color || '#1068bf'
: '#1068bf',
textColor: '#FFFFFF',
__typename: 'WorkItemWidgetColor',
});
}
if (widgetName === WIDGET_TYPE_STATUS) {
const { defaultOpenStatus } = widgetDefinitions.find(
(widget) => widget.type === WIDGET_TYPE_STATUS,
);
widgets.push({
type: 'STATUS',
status: sharedCacheWidgets[WIDGET_TYPE_STATUS]
? sharedCacheWidgets[WIDGET_TYPE_STATUS]?.status || defaultOpenStatus
: defaultOpenStatus,
__typename: 'WorkItemWidgetStatus',
});
}
if (widgetName === WIDGET_TYPE_HIERARCHY) {
widgets.push({
type: 'HIERARCHY',
hasChildren: false,
hasParent: false,
// We're not using `sharedCacheWidgets` for hierarchy parent as
// each work item type can have its own allowed hierarchy parent types.
parent: workItemTypeSpecificWidgets[WIDGET_TYPE_HIERARCHY]
? workItemTypeSpecificWidgets[WIDGET_TYPE_HIERARCHY]?.parent || null
: null,
depthLimitReachedByType: [],
rolledUpCountsByType: [],
children: {
nodes: [],
__typename: 'WorkItemConnection',
},
__typename: 'WorkItemWidgetHierarchy',
});
}
if (widgetName === WIDGET_TYPE_TIME_TRACKING) {
widgets.push({
type: 'TIME_TRACKING',
timeEstimate: 0,
timelogs: {
nodes: [],
__typename: 'WorkItemTimelogConnection',
},
totalTimeSpent: 0,
__typename: 'WorkItemWidgetTimeTracking',
});
}
if (widgetName === WIDGET_TYPE_CUSTOM_FIELDS) {
const customFieldsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_CUSTOM_FIELDS,
);
widgets.push({
type: WIDGET_TYPE_CUSTOM_FIELDS,
// We're not using `sharedCacheWidgets` for custom fields as
// each work item type can have its own allowed custom fields.
customFieldValues: workItemTypeSpecificWidgets[WIDGET_TYPE_CUSTOM_FIELDS]
? workItemTypeSpecificWidgets[WIDGET_TYPE_CUSTOM_FIELDS]?.customFieldValues || []
: customFieldsWidgetData?.customFieldValues ?? [],
__typename: 'WorkItemWidgetCustomFields',
});
}
}
});
return {
draftTitle,
draftDescription,
widgets,
};
};
export const setNewWorkItemCache = async ({
fullPath,
widgetDefinitions,
@ -355,235 +605,253 @@ export const setNewWorkItemCache = async ({
const isValidWorkItemTitle = workItemTitle.trim().length > 0;
const isValidWorkItemDescription = workItemDescription.trim().length > 0;
const widgets = [];
const autosaveKey = getNewWorkItemAutoSaveKey({ fullPath, workItemType });
const getStorageDraftString = getDraft(autosaveKey);
const draftData = JSON.parse(getDraft(autosaveKey));
const widgets = [];
let draftTitle = '';
let draftDescription = '';
const draftTitle = draftData?.workspace?.workItem?.title || '';
const draftDescriptionWidget = findDescriptionWidget(draftData?.workspace?.workItem) || {};
const draftDescription = draftDescriptionWidget?.description || null;
// Experimental support for shared widget data across work item types
if (gon.features.workItemsAlpha) {
const sharedCache = getNewWorkItemSharedCache({
workItemAttributesWrapperOrder,
widgetDefinitions,
fullPath,
workItemType,
isValidWorkItemDescription,
workItemDescription,
});
widgets.push({
type: WIDGET_TYPE_DESCRIPTION,
description: isValidWorkItemDescription ? workItemDescription : draftDescription,
descriptionHtml: '',
lastEditedAt: null,
lastEditedBy: null,
taskCompletionStatus: null,
__typename: 'WorkItemWidgetDescription',
});
draftTitle = sharedCache.draftTitle;
draftDescription = sharedCache.draftDescription;
widgets.push(...sharedCache.widgets);
} else {
const draftDescriptionWidget = findDescriptionWidget(draftData?.workspace?.workItem) || {};
draftTitle = draftData?.workspace?.workItem?.title || '';
draftDescription = draftDescriptionWidget?.description || null;
workItemAttributesWrapperOrder.forEach((widgetName) => {
if (availableWidgets.includes(widgetName)) {
if (widgetName === WIDGET_TYPE_ASSIGNEES) {
const assigneesWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_ASSIGNEES,
);
widgets.push({
type: 'ASSIGNEES',
allowsMultipleAssignees: assigneesWidgetData.allowsMultipleAssignees || false,
canInviteMembers: assigneesWidgetData.canInviteMembers || false,
assignees: {
nodes: draftData
? findAssigneesWidget(draftData?.workspace?.workItem)?.assignees.nodes || []
: [],
__typename: 'UserCoreConnection',
},
__typename: 'WorkItemWidgetAssignees',
});
widgets.push({
type: WIDGET_TYPE_DESCRIPTION,
description: isValidWorkItemDescription ? workItemDescription : draftDescription,
descriptionHtml: '',
lastEditedAt: null,
lastEditedBy: null,
taskCompletionStatus: null,
__typename: 'WorkItemWidgetDescription',
});
workItemAttributesWrapperOrder.forEach((widgetName) => {
if (availableWidgets.includes(widgetName)) {
if (widgetName === WIDGET_TYPE_ASSIGNEES) {
const assigneesWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_ASSIGNEES,
);
widgets.push({
type: 'ASSIGNEES',
allowsMultipleAssignees: assigneesWidgetData.allowsMultipleAssignees || false,
canInviteMembers: assigneesWidgetData.canInviteMembers || false,
assignees: {
nodes: draftData
? findAssigneesWidget(draftData?.workspace?.workItem)?.assignees.nodes || []
: [],
__typename: 'UserCoreConnection',
},
__typename: 'WorkItemWidgetAssignees',
});
}
if (widgetName === WIDGET_TYPE_LINKED_ITEMS) {
widgets.push({
type: WIDGET_TYPE_LINKED_ITEMS,
blockingCount: 0,
blockedByCount: 0,
linkedItems: {
nodes: [],
},
__typename: 'WorkItemWidgetLinkedItems',
});
}
if (widgetName === WIDGET_TYPE_CRM_CONTACTS) {
widgets.push({
type: 'CRM_CONTACTS',
contactsAvailable: false,
contacts: {
nodes: draftData
? findCrmContactsWidget(draftData?.workspace?.workItem)?.contacts.nodes || []
: [],
__typename: 'CustomerRelationsContactConnection',
},
__typename: 'WorkItemWidgetCrmContacts',
});
}
if (widgetName === WIDGET_TYPE_LABELS) {
const labelsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_LABELS,
);
widgets.push({
type: 'LABELS',
allowsScopedLabels: labelsWidgetData.allowsScopedLabels,
labels: {
nodes: draftData
? findLabelsWidget(draftData?.workspace?.workItem)?.labels.nodes || []
: [],
__typename: 'LabelConnection',
},
__typename: 'WorkItemWidgetLabels',
});
}
if (widgetName === WIDGET_TYPE_WEIGHT) {
const weightWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_WEIGHT,
);
widgets.push({
type: 'WEIGHT',
weight: draftData
? findWeightWidget(draftData?.workspace?.workItem)?.weight || null
: null,
rolledUpWeight: 0,
rolledUpCompletedWeight: 0,
widgetDefinition: {
editable: weightWidgetData?.editable,
rollUp: weightWidgetData?.rollUp,
},
__typename: 'WorkItemWidgetWeight',
});
}
if (widgetName === WIDGET_TYPE_MILESTONE) {
widgets.push({
type: 'MILESTONE',
milestone: draftData
? findMilestoneWidget(draftData?.workspace?.workItem)?.milestone || null
: null,
projectMilestone: false,
__typename: 'WorkItemWidgetMilestone',
});
}
if (widgetName === WIDGET_TYPE_ITERATION) {
widgets.push({
iteration: draftData
? findIterationWidget(draftData?.workspace?.workItem)?.iteration || null
: null,
type: 'ITERATION',
__typename: 'WorkItemWidgetIteration',
});
}
if (widgetName === WIDGET_TYPE_START_AND_DUE_DATE) {
const startDueDateDraft = draftData
? findStartAndDueDateWidget(draftData?.workspace?.workItem)
: {};
widgets.push({
type: 'START_AND_DUE_DATE',
dueDate: startDueDateDraft?.dueDate || null,
startDate: startDueDateDraft?.startDate || null,
isFixed: startDueDateDraft?.isFixed || false,
rollUp: false,
__typename: 'WorkItemWidgetStartAndDueDate',
});
}
if (widgetName === WIDGET_TYPE_PROGRESS) {
widgets.push({
type: 'PROGRESS',
progress: null,
updatedAt: null,
__typename: 'WorkItemWidgetProgress',
});
}
if (widgetName === WIDGET_TYPE_HEALTH_STATUS) {
widgets.push({
type: 'HEALTH_STATUS',
healthStatus: draftData
? findHealthStatusWidget(draftData?.workspace?.workItem)?.healthStatus || null
: null,
rolledUpHealthStatus: [],
__typename: 'WorkItemWidgetHealthStatus',
});
}
if (widgetName === WIDGET_TYPE_COLOR) {
widgets.push({
type: 'COLOR',
color: draftData
? findColorWidget(draftData?.workspace?.workItem)?.color || '#1068bf'
: '#1068bf',
textColor: '#FFFFFF',
__typename: 'WorkItemWidgetColor',
});
}
if (widgetName === WIDGET_TYPE_STATUS) {
const { defaultOpenStatus } = widgetDefinitions.find(
(widget) => widget.type === WIDGET_TYPE_STATUS,
);
widgets.push({
type: 'STATUS',
status: draftData
? findStatusWidget(draftData?.workspace?.workItem)?.status || defaultOpenStatus
: defaultOpenStatus,
__typename: 'WorkItemWidgetStatus',
});
}
if (widgetName === WIDGET_TYPE_HIERARCHY) {
widgets.push({
type: 'HIERARCHY',
hasChildren: false,
hasParent: false,
parent: draftData
? findHierarchyWidget(draftData?.workspace?.workItem)?.parent || null
: null,
depthLimitReachedByType: [],
rolledUpCountsByType: [],
children: {
nodes: [],
__typename: 'WorkItemConnection',
},
__typename: 'WorkItemWidgetHierarchy',
});
}
if (widgetName === WIDGET_TYPE_TIME_TRACKING) {
widgets.push({
type: 'TIME_TRACKING',
timeEstimate: 0,
timelogs: {
nodes: [],
__typename: 'WorkItemTimelogConnection',
},
totalTimeSpent: 0,
__typename: 'WorkItemWidgetTimeTracking',
});
}
if (widgetName === WIDGET_TYPE_CUSTOM_FIELDS) {
const customFieldsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_CUSTOM_FIELDS,
);
widgets.push({
type: WIDGET_TYPE_CUSTOM_FIELDS,
customFieldValues: draftData
? findCustomFieldsWidget(draftData?.workspace?.workItem)?.customFieldValues || []
: customFieldsWidgetData?.customFieldValues ?? [],
__typename: 'WorkItemWidgetCustomFields',
});
}
}
if (widgetName === WIDGET_TYPE_LINKED_ITEMS) {
widgets.push({
type: WIDGET_TYPE_LINKED_ITEMS,
blockingCount: 0,
blockedByCount: 0,
linkedItems: {
nodes: [],
},
__typename: 'WorkItemWidgetLinkedItems',
});
}
if (widgetName === WIDGET_TYPE_CRM_CONTACTS) {
widgets.push({
type: 'CRM_CONTACTS',
contactsAvailable: false,
contacts: {
nodes: draftData
? findCrmContactsWidget(draftData?.workspace?.workItem)?.contacts.nodes || []
: [],
__typename: 'CustomerRelationsContactConnection',
},
__typename: 'WorkItemWidgetCrmContacts',
});
}
if (widgetName === WIDGET_TYPE_LABELS) {
const labelsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_LABELS,
);
widgets.push({
type: 'LABELS',
allowsScopedLabels: labelsWidgetData.allowsScopedLabels,
labels: {
nodes: draftData
? findLabelsWidget(draftData?.workspace?.workItem)?.labels.nodes || []
: [],
__typename: 'LabelConnection',
},
__typename: 'WorkItemWidgetLabels',
});
}
if (widgetName === WIDGET_TYPE_WEIGHT) {
const weightWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_WEIGHT,
);
widgets.push({
type: 'WEIGHT',
weight: draftData
? findWeightWidget(draftData?.workspace?.workItem)?.weight || null
: null,
rolledUpWeight: 0,
rolledUpCompletedWeight: 0,
widgetDefinition: {
editable: weightWidgetData?.editable,
rollUp: weightWidgetData?.rollUp,
},
__typename: 'WorkItemWidgetWeight',
});
}
if (widgetName === WIDGET_TYPE_MILESTONE) {
widgets.push({
type: 'MILESTONE',
milestone: draftData
? findMilestoneWidget(draftData?.workspace?.workItem)?.milestone || null
: null,
projectMilestone: false,
__typename: 'WorkItemWidgetMilestone',
});
}
if (widgetName === WIDGET_TYPE_ITERATION) {
widgets.push({
iteration: draftData
? findIterationWidget(draftData?.workspace?.workItem)?.iteration || null
: null,
type: 'ITERATION',
__typename: 'WorkItemWidgetIteration',
});
}
if (widgetName === WIDGET_TYPE_START_AND_DUE_DATE) {
const startDueDateDraft = draftData
? findStartAndDueDateWidget(draftData?.workspace?.workItem)
: {};
widgets.push({
type: 'START_AND_DUE_DATE',
dueDate: startDueDateDraft?.dueDate || null,
startDate: startDueDateDraft?.startDate || null,
isFixed: startDueDateDraft?.isFixed || false,
rollUp: false,
__typename: 'WorkItemWidgetStartAndDueDate',
});
}
if (widgetName === WIDGET_TYPE_PROGRESS) {
widgets.push({
type: 'PROGRESS',
progress: null,
updatedAt: null,
__typename: 'WorkItemWidgetProgress',
});
}
if (widgetName === WIDGET_TYPE_HEALTH_STATUS) {
widgets.push({
type: 'HEALTH_STATUS',
healthStatus: draftData
? findHealthStatusWidget(draftData?.workspace?.workItem)?.healthStatus || null
: null,
rolledUpHealthStatus: [],
__typename: 'WorkItemWidgetHealthStatus',
});
}
if (widgetName === WIDGET_TYPE_COLOR) {
widgets.push({
type: 'COLOR',
color: draftData
? findColorWidget(draftData?.workspace?.workItem)?.color || '#1068bf'
: '#1068bf',
textColor: '#FFFFFF',
__typename: 'WorkItemWidgetColor',
});
}
if (widgetName === WIDGET_TYPE_STATUS) {
const { defaultOpenStatus } = widgetDefinitions.find(
(widget) => widget.type === WIDGET_TYPE_STATUS,
);
widgets.push({
type: 'STATUS',
status: draftData
? findStatusWidget(draftData?.workspace?.workItem)?.status || defaultOpenStatus
: defaultOpenStatus,
__typename: 'WorkItemWidgetStatus',
});
}
if (widgetName === WIDGET_TYPE_HIERARCHY) {
widgets.push({
type: 'HIERARCHY',
hasChildren: false,
hasParent: false,
parent: draftData
? findHierarchyWidget(draftData?.workspace?.workItem)?.parent || null
: null,
depthLimitReachedByType: [],
rolledUpCountsByType: [],
children: {
nodes: [],
__typename: 'WorkItemConnection',
},
__typename: 'WorkItemWidgetHierarchy',
});
}
if (widgetName === WIDGET_TYPE_TIME_TRACKING) {
widgets.push({
type: 'TIME_TRACKING',
timeEstimate: 0,
timelogs: {
nodes: [],
__typename: 'WorkItemTimelogConnection',
},
totalTimeSpent: 0,
__typename: 'WorkItemWidgetTimeTracking',
});
}
if (widgetName === WIDGET_TYPE_CUSTOM_FIELDS) {
const customFieldsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_CUSTOM_FIELDS,
);
widgets.push({
type: WIDGET_TYPE_CUSTOM_FIELDS,
customFieldValues: draftData
? findCustomFieldsWidget(draftData?.workspace?.workItem)?.customFieldValues || []
: customFieldsWidgetData?.customFieldValues ?? [],
__typename: 'WorkItemWidgetCustomFields',
});
}
}
});
});
}
const issuesListApolloProvider = new VueApollo({
defaultClient: await issuesListClient(),

View File

@ -8,7 +8,9 @@ import {
findCustomFieldsWidget,
findStartAndDueDateWidget,
getNewWorkItemAutoSaveKey,
getNewWorkItemWidgetsAutoSaveKey,
newWorkItemFullPath,
getWorkItemWidgets,
} from '../utils';
import {
WIDGET_TYPE_ASSIGNEES,
@ -202,6 +204,10 @@ export const updateNewWorkItemCache = (input, cache) => {
if (isQueryDataValid && autosaveKey) {
updateDraft(autosaveKey, JSON.stringify(newData));
updateDraft(
getNewWorkItemWidgetsAutoSaveKey({ fullPath }),
JSON.stringify(getWorkItemWidgets(newData)),
);
}
} catch (e) {
Sentry.captureException(e);

View File

@ -399,9 +399,7 @@ export const makeDrawerUrlParam = (activeItem, fullPath, issuableType = TYPE_ISS
);
};
export const getNewWorkItemAutoSaveKey = ({ fullPath, workItemType }) => {
if (!workItemType || !fullPath) return '';
export const getAutosaveKeyQueryParamString = () => {
const allowedKeysInQueryParamString = ['vulnerability_id', 'discussion_to_resolve'];
const queryParams = new URLSearchParams(window.location.search);
// Remove extra params from queryParams
@ -411,7 +409,14 @@ export const getNewWorkItemAutoSaveKey = ({ fullPath, workItemType }) => {
queryParams.delete(key);
}
}
const queryParamString = queryParams.toString();
return queryParams.toString();
};
export const getNewWorkItemAutoSaveKey = ({ fullPath, workItemType }) => {
if (!workItemType || !fullPath) return '';
const queryParamString = getAutosaveKeyQueryParamString();
if (queryParamString) {
return `new-${fullPath}-${workItemType.toLowerCase()}-${queryParamString}-draft`;
@ -419,6 +424,31 @@ export const getNewWorkItemAutoSaveKey = ({ fullPath, workItemType }) => {
return `new-${fullPath}-${workItemType.toLowerCase()}-draft`;
};
export const getNewWorkItemWidgetsAutoSaveKey = ({ fullPath }) => {
if (!fullPath) return '';
const queryParamString = getAutosaveKeyQueryParamString();
if (queryParamString) {
return `new-${fullPath}-widgets-${queryParamString}-draft`;
}
return `new-${fullPath}-widgets-draft`;
};
export const getWorkItemWidgets = (draftData) => {
if (!draftData?.workspace?.workItem) return {};
const widgets = {};
for (const widget of draftData.workspace.workItem.widgets || []) {
if (widget.type) {
widgets[widget.type] = widget;
}
}
widgets.TITLE = draftData.workspace.workItem.title;
return widgets;
};
export const isItemDisplayable = (item, showClosed) => {
return item.state !== STATE_CLOSED || (item.state === STATE_CLOSED && showClosed);
};

View File

@ -28,7 +28,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def resume
if Ci::Runners::UpdateRunnerService.new(current_user, runner).execute(active: true).success?
if runner_update_service.execute(active: true).success?
redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
@ -36,7 +36,7 @@ class Projects::RunnersController < Projects::ApplicationController
end
def pause
if Ci::Runners::UpdateRunnerService.new(current_user, @runner).execute(active: false).success?
if runner_update_service.execute(active: false).success?
redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
@ -71,6 +71,10 @@ class Projects::RunnersController < Projects::ApplicationController
def runner_params
params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
end
def runner_update_service
Ci::Runners::UpdateRunnerService.new(current_user, runner)
end
end
Projects::RunnersController.prepend_mod

View File

@ -14,8 +14,6 @@ module Ci
self.table_name = 'p_ci_builds_metadata'
self.primary_key = 'id'
ignore_column :runtime_runner_features, remove_with: '18.1', remove_after: '2025-05-22'
query_constraints :id, :partition_id
partitionable scope: :build, partitioned: true

View File

@ -9,8 +9,6 @@ class CommitStatus < Ci::ApplicationRecord
include BulkInsertableAssociations
include TaggableQueries
ignore_column :trigger_request_id, remove_with: '18.2', remove_after: '2025-06-14'
self.table_name = :p_ci_builds
self.sequence_name = :ci_builds_id_seq
self.primary_key = :id

View File

@ -29,7 +29,7 @@ module Ci
def authorize
unless user.present? && user.can?(:assign_runner, runner)
return ServiceResponse.error(message: 'User not allowed to assign runner')
return ServiceResponse.error(message: 'User not allowed to unassign runner')
end
unless user.can?(:admin_project_runners, project)

View File

@ -9,17 +9,12 @@ module ContainerRegistry
private
def protected_patterns_for_delete(project:, current_user: nil)
tag_rules = ContainerRegistry::Protection::TagRule.tag_name_patterns_for_project(project.id)
return if user_can_admin_all_resources?(current_user, project)
if Feature.disabled?(:container_registry_immutable_tags, project)
return if current_user&.can_admin_all_resources?
tag_rules = ::ContainerRegistry::Protection::TagRule.tag_name_patterns_for_project(project.id)
tag_rules = fetch_eligible_tag_rules_for_project(tag_rules, project, current_user)
tag_rules = tag_rules.mutable
end
if current_user&.can_admin_all_resources?
tag_rules = tag_rules.immutable
elsif current_user
if current_user && !current_user.can_admin_all_resources?
user_access_level = project.team.max_member_access(current_user.id)
tag_rules = tag_rules.for_delete_and_access(user_access_level)
end
@ -40,6 +35,14 @@ module ContainerRegistry
project.has_container_registry_tags?
end
def user_can_admin_all_resources?(user, _project)
user&.can_admin_all_resources?
end
def fetch_eligible_tag_rules_for_project(tag_rules, _project, _user)
tag_rules.mutable
end
end
end
end

View File

@ -2,12 +2,12 @@
.gl-flex.gl-justify-between
%div
= runner_status_icon(runner, size: 16)
- if @project_runners.include?(runner)
- if runner.in?(@project_runners)
= link_to "##{runner.id} (#{runner.short_sha})", project_runner_path(@project, runner)
- else
%span
= "##{runner.id} (#{runner.short_sha})"
- if runner.locked? && runner.project_type?
- if runner.project_type? && runner.locked?
%span.has-tooltip{ title: s_('Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.') }
= sprite_icon('lock')
.gl-ml-2

View File

@ -5,4 +5,4 @@ feature_category: service_desk
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174504
milestone: '17.7'
queued_migration_version: 20241203075018
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20250610185554'

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class FinalizeBackfillIssueCustomerRelationsContactsNamespaceId < Gitlab::Database::Migration[2.3]
milestone '18.1'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillIssueCustomerRelationsContactsNamespaceId',
table_name: :issue_customer_relations_contacts,
column_name: :id,
job_arguments: [:namespace_id, :issues, :namespace_id, :issue_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1 @@
3afd4aed3093dd3c98de64259cb55c873759bcbd13b2c14dd33ed789a0e8abc4

View File

@ -813,7 +813,7 @@ Connections to HCP may return an error stating `SignatureDoesNotMatch - The requ
{{< /alert >}}
[HCP](https://docs.hitachivantara.com/r/en-us/content-platform-for-cloud-scale/2.6.x/mk-hcpcs008/getting-started/introducing-hcp-for-cloud-scale/support-for-the-amazon-s3-api) provides an S3-compatible API. Use the following configuration example:
[HCP](https://docs.hitachivantara.com/r/en-us/content-platform/9.7.x/mk-95hcph001/hcp-management-api-reference/introduction-to-the-hcp-management-api/support-for-the-amazon-s3-api) provides an S3-compatible API. Use the following configuration example:
```ruby
gitlab_rails['object_store']['connection'] = {

View File

@ -24,7 +24,7 @@ Below are available schemas related to Cells and Organizations:
Most tables will require a [sharding key](../organization/_index.md#defining-a-sharding-key-for-all-organizational-tables) to be defined.
To understand how existing tables are classified, you can use [this dashboard](https://manojmj.gitlab.io/tenant-scale-schema-progress/).
To understand how existing tables are classified, you can use [this dashboard](https://cells-progress-tracker-gitlab-org-tenant-scale-g-f4ad96bf01d25f.gitlab.io/schema_migration).
After a schema has been assigned, the merge request pipeline might fail due to one or more of the following reasons, which can be rectified by following the linked guidelines:
@ -62,6 +62,23 @@ Here are some considerations to think about:
- Does the data need to be consistent across different cells ?
- Do not use database tables to store [static data](#static-data).
## Creating a new schema
Schemas should default to require a sharding key, as features should be scoped to an Organization by default.
```yaml
# db/gitlab_schemas/gitlab_ci.yaml
require_sharding_key: true
sharding_root_tables:
- projects
- namespaces
- organizations
```
Setting `require_sharding_key` to `true` means that tables assigned to that
schema will require a `sharding_key` to be set.
You will also need to configure the list of allowed `sharding_root_tables` that can be used as sharding keys for tables in this schema.
## Static data
Problem: A database table is used to store static data.

View File

@ -372,6 +372,56 @@ end
Additionally, to view the executed ClickHouse queries in web interactions, on the performance bar, next to the `ch` label select the count.
## Handling Siphon Errors in Tests
GitLab uses a tool called [Siphon](https://gitlab.com/gitlab-org/analytics-section/siphon) to constantly synchronise data from specified tables in PostgreSQL to ClickHouse.
This process requires that for each specified table, the ClickHouse schema must contain a copy of the PostgreSQL schema.
During GitLab development, if you add a new column to PostgreSQL without adding a matching column in ClickHouse it will fail with an error:
```plaintext
This table is synchronised to ClickHouse and you've added a new column!
```
To resolve this, you should add a migration to add the column to ClickHouse too.
### Example
1. Add a new column `new_int` of type `int4` to a table that is being synchronised to ClickHouse, such as `milestones`.
1. Note that CI will fail with the error:
```plaintext
This table is synchronised to ClickHouse and you've added a new column!
```
1. Generate a new ClickHouse migration to add the new column, note that the ClickHouse table is prefixed with `siphon_`:
```plaintext
bundle exec rails generate gitlab:click_house:migration add_new_int_to_siphon_milestones
```
1. In the generated file, define up/down methods to add/remove the new column. ClickHouse data types map approximately to PostgreSQL.
Check `Gitlab::ClickHouse::SiphonGenerator::PG_TYPE_MAP` for the appropriate mapping for the new column. Using the wrong type will trigger a different error.
Additionally, consider making use of [`LowCardinaility`](https://clickhouse.com/docs/sql-reference/data-types/lowcardinality) where appropriate and use [`Nullable`](https://clickhouse.com/docs/sql-reference/data-types/nullable) sparingly opting for default values instead where possible.
```ruby
class AddNewIntToSiphonMilestones < ClickHouse::Migration
def up
execute <<~SQL
ALTER TABLE siphon_milestones ADD COLUMN new_int Int64 DEFAULT 42;
SQL
end
def down
execute <<~SQL
ALTER TABLE siphon_milestones DROP COLUMN new_int;
SQL
end
end
```
If you need further assistance, reach out to `#f_siphon` internally.
### Getting help
For additional information or specific questions, reach out to the ClickHouse Datastore working group in the `#f_clickhouse` Slack channel, or mention `@gitlab-org/maintainers/clickhouse` in a comment on GitLab.com.

View File

@ -46,3 +46,83 @@ devices are revoked. For details about **Remember me**, see
[cookies used for sign-in](_index.md#cookies-used-for-sign-in).
{{< /alert >}}
## Revoke sessions through the Rails console
You can also revoke user sessions through the Rails console. You can use this to revoke
multiple sessions at the same time.
### Revoke all sessions for all users
To revoke all sessions for all users:
1. [Start a Rails console session](../../administration/operations/rails_console.md#starting-a-rails-console-session).
1. Optional. List all active sessions with the following command:
```ruby
ActiveSession.list(User.all)
1. Revoke all sessions with the following command:
```ruby
ActiveSession.destroy_all
```
1. Verify sessions are closed with the following command:
```ruby
# Show all users with active sessions
puts "=== Currently Logged In Users ==="
User.find_each do |user|
sessions = ActiveSession.list(user)
if sessions.any?
puts "\n#{user.username} (#{user.name}):"
sessions.each do |session|
puts " - IP: #{session.ip_address}, Browser: #{session.browser}, Last active: #{session.updated_at}"
end
end
end
```
### Revoke all sessions for a user
To revoke all sessions for a specific user:
1. [Start a Rails console session](../../administration/operations/rails_console.md#starting-a-rails-console-session).
1. Find the user with the following commands:
- By username:
```ruby
user = User.find_by_username 'exampleuser'
```
- By user ID:
```ruby
user = User.find(123)
```
- By email address:
```ruby
user = User.find_by(email: 'user@example.com')
```
1. Optional. List all active sessions for the user with the following command:
```ruby
ActiveSession.list(user)
```
1. Revoke all sessions with the following command:
```ruby
ActiveSession.list(user).each { |session| ActiveSession.destroy_session(user, session.session_private_id) }
```
1. Verify all sessions are closed with the following command:
```ruby
# If all sessions are closed, returns an empty array.
ActiveSession.list(user)
```

View File

@ -107,7 +107,7 @@ for guidance on managing personal access tokens (for example, setting a short ex
{{< history >}}
- Ability to use the UI to rotate a personal access token [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241523) in GitLab 17.7.
- [Updated UI](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/194582) in GitLab 18.2.
- [Updated UI](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/194582) in GitLab 18.1.
{{< /history >}}

View File

@ -57,7 +57,6 @@ spec/frontend/admin/abuse_report/components/reported_content_spec.js
spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js
spec/frontend/admin/broadcast_messages/components/base_spec.js
spec/frontend/alert_management/components/alert_management_table_spec.js
spec/frontend/analytics/shared/components/date_ranges_dropdown_spec.js
spec/frontend/boards/board_list_spec.js
spec/frontend/boards/components/board_content_sidebar_spec.js
spec/frontend/boards/components/board_content_spec.js

View File

@ -18,10 +18,10 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let_it_be(:group) { create(:group, parent: parent_group) }
let_it_be(:other_project) { create(:project, group: group) }
subject { get :show, params: { namespace_id: project.namespace, project_id: project } }
subject(:request) { get :show, params: { namespace_id: project.namespace, project_id: project } }
it 'renders show with 200 status code' do
subject
request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
@ -33,7 +33,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'renders show with 404 status code' do
subject
request
expect(response).to have_gitlab_http_status(:not_found)
end
@ -47,7 +47,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'sets assignable project runners' do
subject
request
expect(assigns(:assignable_runners)).to contain_exactly(project_runner)
end
@ -57,7 +57,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
it 'sets project runners' do
subject
request
expect(assigns(:project_runners)).to contain_exactly(project_runner)
end
@ -68,7 +68,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let!(:group_runner) { create(:ci_runner, :group, groups: [group]) }
it 'sets group runners' do
subject
request
expect(assigns(:group_runners_count)).to be(1)
expect(assigns(:group_runners)).to contain_exactly(group_runner)
@ -79,7 +79,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let_it_be(:shared_runner) { create(:ci_runner, :instance) }
it 'sets shared runners' do
subject
request
expect(assigns(:shared_runners_count)).to be(1)
expect(assigns(:shared_runners)).to contain_exactly(shared_runner)
@ -119,7 +119,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'returns a success header' do
subject
request
expect(response).to have_gitlab_http_status(:ok)
end
@ -131,15 +131,17 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'returns not found' do
subject
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe '#reset_cache' do
subject { post :reset_cache, params: { namespace_id: project.namespace, project_id: project }, format: :json }
describe 'POST reset_cache' do
subject(:request) do
post :reset_cache, params: { namespace_id: project.namespace, project_id: project }, format: :json
end
before do
sign_in(user)
@ -155,12 +157,12 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
it 'calls reset project cache service' do
expect(ResetProjectCacheService).to receive_message_chain(:new, :execute)
subject
request
end
context 'when service returns successfully' do
it 'returns a success header' do
subject
request
expect(response).to have_gitlab_http_status(:ok)
end
@ -172,7 +174,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'returns an error header' do
subject
request
expect(response).to have_gitlab_http_status(:bad_request)
end
@ -185,23 +187,25 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'returns not found' do
subject
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'PUT #reset_registration_token' do
subject { put :reset_registration_token, params: { namespace_id: project.namespace, project_id: project } }
describe 'PUT reset_registration_token' do
subject(:request) do
put :reset_registration_token, params: { namespace_id: project.namespace, project_id: project }
end
it 'resets runner registration token' do
expect { subject }.to change { project.reload.runners_token }
expect { request }.to change { project.reload.runners_token }
expect(flash[:toast]).to eq('New runners registration token has been generated!')
end
it 'redirects the user to admin runners page' do
subject
request
expect(response).to redirect_to(namespace_project_settings_ci_cd_path)
end
@ -210,7 +214,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
describe 'PATCH update' do
let(:params) { { ci_config_path: '' } }
subject do
subject(:request) do
patch :update, params: {
namespace_id: project.namespace.to_param,
project_id: project,
@ -219,7 +223,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'redirects to the settings page' do
subject
request
expect(response).to have_gitlab_http_status(:found)
expect(flash[:toast]).to eq("Pipelines settings for &#39;#{project.name}&#39; were successfully updated.")
@ -232,7 +236,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:params) { { auto_devops_attributes: { enabled: '' } } }
it 'allows enabled to be set to nil' do
subject
request
project_auto_devops.reload
expect(project_auto_devops.enabled).to be_nil
@ -248,7 +252,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
context 'when the project repository is empty' do
it 'sets a notice flash' do
subject
request
expect(controller).to set_flash[:notice]
end
@ -256,7 +260,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
it 'does not queue a CreatePipelineWorker' do
expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
subject
request
end
end
@ -266,7 +270,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
it 'displays a toast message' do
allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
subject
request
expect(controller).to set_flash[:toast]
end
@ -274,13 +278,13 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
it 'queues a CreatePipelineWorker' do
expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
subject
request
end
it 'creates a pipeline', :sidekiq_inline do
project.repository.create_file(user, 'Gemfile', 'Gemfile contents', message: 'Add Gemfile', branch_name: 'master')
expect { subject }.to change { Ci::Pipeline.count }.by(1)
expect { request }.to change { Ci::Pipeline.count }.by(1)
end
end
end
@ -295,7 +299,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
it 'does not queue a CreatePipelineWorker' do
expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args)
subject
request
end
end
end
@ -305,7 +309,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:params) { { build_timeout_human_readable: '' } }
it 'set default timeout' do
subject
request
project.reload
expect(project.build_timeout).to eq(3600)
@ -316,7 +320,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:params) { { build_timeout_human_readable: '1h 30m' } }
it 'set specified timeout' do
subject
request
project.reload
expect(project.build_timeout).to eq(5400)
@ -327,7 +331,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:params) { { build_timeout_human_readable: '5m' } }
it 'set specified timeout' do
subject
request
expect(controller).to set_flash[:alert]
expect(response).to redirect_to(namespace_project_settings_ci_cd_path)
@ -342,7 +346,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'set specified git depth' do
subject
request
project.reload
expect(project.ci_default_git_depth).to eq(10)
@ -357,7 +361,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'sets forward deployment enabled' do
subject
request
project.reload
expect(project.ci_forward_deployment_enabled).to eq(false)
@ -368,7 +372,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
let(:params) { { ci_cd_settings_attributes: { forward_deployment_rollback_allowed: false } } }
it 'changes forward deployment rollback allowed' do
expect { subject }.to change { project.reload.ci_forward_deployment_rollback_allowed }.from(true).to(false)
expect { request }.to change { project.reload.ci_forward_deployment_rollback_allowed }.from(true).to(false)
end
end
@ -377,7 +381,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
context 'and user is a maintainer' do
it 'does not set delete_pipelines_in_human_readable' do
subject
request
project.reload
expect(project.ci_delete_pipelines_in_seconds).to be_nil
@ -388,7 +392,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
it 'sets delete_pipelines_in_human_readable' do
project.add_owner(user)
subject
request
project.reload
expect(project.ci_delete_pipelines_in_seconds).to eq(1.week)
@ -401,7 +405,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
context 'and user is not an admin' do
it 'does not set max_artifacts_size' do
subject
request
project.reload
expect(project.max_artifacts_size).to be_nil
@ -413,7 +417,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
context 'with admin mode disabled' do
it 'does not set max_artifacts_size' do
subject
request
project.reload
expect(project.max_artifacts_size).to be_nil
@ -422,7 +426,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
context 'with admin mode enabled', :enable_admin_mode do
it 'sets max_artifacts_size' do
subject
request
project.reload
expect(project.max_artifacts_size).to eq(10)
@ -438,7 +442,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'returns a success header' do
subject
request
expect(response).to redirect_to(project_settings_ci_cd_path(project))
end
@ -450,14 +454,14 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'returns not found' do
subject
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'GET #runner_setup_scripts' do
describe 'GET runner_setup_scripts' do
it 'renders the setup scripts' do
get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64', namespace_id: project.namespace, project_id: project }
@ -474,8 +478,8 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
end
describe 'GET #export_job_token_authorizations' do
subject(:get_authorizations) do
describe 'GET export_job_token_authorizations' do
subject(:request) do
get :export_job_token_authorizations, params: {
namespace_id: project.namespace,
project_id: project
@ -488,7 +492,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
context 'when the export is successful' do
it 'renders the CSV' do
get_authorizations
request
expect(response).to have_gitlab_http_status(:ok)
rows = response.body.lines
@ -509,7 +513,7 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
it 'sets a flash alert and redirects to the project CI/CD settings' do
get_authorizations
request
expect(flash[:alert]).to eq('Failed to generate export')
expect(response).to redirect_to(project_settings_ci_cd_path(project))
@ -518,38 +522,44 @@ RSpec.describe Projects::Settings::CiCdController, feature_category: :continuous
end
end
context 'as a developer' do
before do
sign_in(user)
project.add_developer(user)
describe 'GET show' do
subject(:request) do
get :show, params: { namespace_id: project.namespace, project_id: project }
end
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'as a developer' do
before do
sign_in(user)
project.add_developer(user)
end
context 'as a reporter' do
before do
sign_in(user)
project.add_reporter(user)
get :show, params: { namespace_id: project.namespace, project_id: project }
it 'responds with 404' do
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'as a reporter' do
before do
sign_in(user)
project.add_reporter(user)
end
context 'as an unauthenticated user' do
before do
get :show, params: { namespace_id: project.namespace, project_id: project }
it 'responds with 404' do
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'redirects to sign in' do
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to('/users/sign_in')
context 'as an unauthenticated user' do
it 'redirects to sign in' do
request
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to('/users/sign_in')
end
end
end
end

View File

@ -36,7 +36,10 @@ RSpec.describe 'ClickHouse siphon tables', :click_house, feature_category: :data
ch_field_type = ch_table_fields[field_name]
unless ch_field_type.present?
raise "Postgres field '#{field_name}' of table '#{pg_table}' is not present in ClickHouse"
raise "This table is synchronised to ClickHouse and you've added a new column! " \
"Missing ClickHouse field '#{field_name}' for table '#{pg_table}'. " \
"Create a ClickHouse migration to add this field. " \
"See: https://docs.gitlab.com/development/database/clickhouse/clickhouse_within_gitlab/#handling-siphon-errors-in-tests"
end
next if ch_field_type.include?(pg_type_map[type_id])

View File

@ -99,6 +99,27 @@ RSpec.describe 'Dashboard Projects', :js, feature_category: :groups_and_projects
expect(first('[data-testid*="projects-list-item"]')).to have_content(personal_project_with_stars.title)
end
context 'when a project is archived' do
let_it_be(:archived_project) { create(:project, :archived, namespace: user.namespace) }
let(:personal_tab) { find(".nav-item:nth-child(3)") }
let(:member_tab) { find(".nav-item:nth-child(4)") }
it 'is not included in the personal projects or member count' do
visit dashboard_projects_path
wait_for_requests
within "ul.gl-tabs-nav" do
expect(personal_tab).to have_text("Personal 2")
expect(member_tab).to have_text("Member 3")
personal_tab.click
expect(personal_tab).to have_text("Personal 2")
expect(page).not_to have_content(archived_project.name)
member_tab.click
expect(member_tab).to have_text("Member 3")
expect(page).not_to have_content(archived_project.name)
end
end
end
context 'when on Member projects tab' do
it 'shows all projects you are a member of' do
visit member_dashboard_projects_path

View File

@ -1,8 +1,8 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlAlert, GlButton, GlFormSelect, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { setHTMLFixture } from 'helpers/fixtures';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
@ -53,6 +53,7 @@ Vue.use(VueApollo);
describe('Create work item component', () => {
/** @type {import('@vue/test-utils').Wrapper} */
const originalFeatures = gon.features;
let wrapper;
let mockApollo;
@ -79,16 +80,15 @@ describe('Create work item component', () => {
const findGroupProjectSelector = () => wrapper.findComponent(WorkItemNamespaceListbox);
const findSelect = () => wrapper.findComponent(GlFormSelect);
const findTitleSuggestions = () => wrapper.findComponent(TitleSuggestions);
const findConfidentialCheckbox = () => wrapper.find('[data-testid="confidential-checkbox"]');
const findRelatesToCheckbox = () => wrapper.find('[data-testid="relates-to-checkbox"]');
const findCreateWorkItemView = () => wrapper.find('[data-testid="create-work-item-view"]');
const findFormButtons = () => wrapper.find('[data-testid="form-buttons"]');
const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findResolveDiscussionSection = () =>
wrapper.find('[data-testid="work-item-resolve-discussion"]');
const findConfidentialCheckbox = () => wrapper.findByTestId('confidential-checkbox');
const findRelatesToCheckbox = () => wrapper.findByTestId('relates-to-checkbox');
const findCreateWorkItemView = () => wrapper.findByTestId('create-work-item-view');
const findFormButtons = () => wrapper.findByTestId('form-buttons');
const findCreateButton = () => wrapper.findByTestId('create-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findResolveDiscussionSection = () => wrapper.findByTestId('work-item-resolve-discussion');
const findResolveDiscussionLink = () =>
wrapper.find('[data-testid="work-item-resolve-discussion"]').findComponent(GlLink);
wrapper.findByTestId('work-item-resolve-discussion').findComponent(GlLink);
const createComponent = ({
props = {},
@ -114,7 +114,7 @@ describe('Create work item component', () => {
resolvers,
);
wrapper = shallowMount(CreateWorkItem, {
wrapper = shallowMountExtended(CreateWorkItem, {
apolloProvider: mockApollo,
propsData: {
fullPath: 'full-path',
@ -158,6 +158,11 @@ describe('Create work item component', () => {
gon.current_user_fullname = mockCurrentUser.name;
gon.current_username = mockCurrentUser.username;
gon.current_user_avatar_url = mockCurrentUser.avatar_url;
gon.features = {};
});
afterAll(() => {
gon.features = originalFeatures;
});
describe('Default', () => {
@ -189,12 +194,18 @@ describe('Create work item component', () => {
it('Default', async () => {
createComponent();
await waitForPromises();
const AUTO_SAVE_KEY = 'autosave/new-full-path-epic-draft';
const typeSpecificAutosaveKey = `autosave/new-full-path-epic-draft`;
const sharedWidgetsAutosaveKey = 'autosave/new-full-path-widgets-draft';
findCancelButton().vm.$emit('click');
await nextTick();
expect(localStorage.removeItem).toHaveBeenCalledWith(AUTO_SAVE_KEY);
// clearDraft internally calls localStorage.removeItem twice per key,
// first with actual keyname, and then with `keyname/lockVersion`.
// We're only interested in actual call to remove by keyname.
expect(localStorage.removeItem).toHaveBeenCalledTimes(4);
expect(localStorage.removeItem).toHaveBeenNthCalledWith(1, typeSpecificAutosaveKey);
expect(localStorage.removeItem).toHaveBeenNthCalledWith(3, sharedWidgetsAutosaveKey);
expect(setNewWorkItemCache).toHaveBeenCalled();
});
@ -348,6 +359,25 @@ describe('Create work item component', () => {
expect(findSelect().attributes('value')).toBe(mockId);
});
it('sets new work item cache and emits changeType on select', async () => {
createComponent({ props: { preselectedWorkItemType: null } });
await waitForPromises();
const mockId = 'Issue';
findSelect().vm.$emit('change', mockId);
await nextTick();
expect(setNewWorkItemCache).toHaveBeenCalledWith({
fullPath: 'full-path',
widgetDefinitions: expect.any(Array),
workItemType: mockId,
workItemTypeId: 'gid://gitlab/WorkItems::Type/1',
workItemTypeIconName: 'issue-type-issue',
});
expect(wrapper.emitted('changeType')).toBeDefined();
});
it('hides title if set', async () => {
createComponent({ props: { hideFormTitle: true } });
await waitForPromises();

View File

@ -8,7 +8,7 @@ import {
updateCacheAfterCreatingNote,
updateCountsForParent,
} from '~/work_items/graphql/cache_utils';
import { findHierarchyWidget, findNotesWidget } from '~/work_items/utils';
import { findHierarchyWidget, findNotesWidget, getWorkItemWidgets } from '~/work_items/utils';
import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { apolloProvider } from '~/graphql_shared/issuable_client';
@ -20,11 +20,14 @@ import {
workItemResponseFactory,
mockCreateWorkItemDraftData,
mockNewWorkItemCache,
mockNewWorkItemIssueCache,
restoredDraftDataWidgets,
restoredDraftDataWidgetsForIssue,
restoredDraftDataWidgetsEmpty,
} from '../mock_data';
describe('work items graphql cache utils', () => {
const originalFeatures = window.gon.features;
const id = 'gid://gitlab/WorkItem/10';
const mockCacheData = {
workItem: {
@ -47,6 +50,18 @@ describe('work items graphql cache utils', () => {
],
},
};
// This looks like an odd pattern but is something we do already in several places
// across our codebase to run tests conditionally, often to quarantine tests.
// Here we're utilizing it skip test based on feature flag.
const itif = (condition) => (condition ? it : it.skip);
beforeEach(() => {
window.gon.features = {};
});
afterAll(() => {
window.gon.features = originalFeatures;
});
describe('addHierarchyChild', () => {
it('updates the work item with a new child', () => {
@ -227,56 +242,47 @@ describe('work items graphql cache utils', () => {
});
});
describe('setNewWorkItemCache', () => {
let originalWindowLocation;
let mockWriteQuery;
describe.each`
workItemsAlpha
${false}
${true}
`(
'setNewWorkItemCache with feature-flag workItemsAlpha: $workItemsAlpha',
({ workItemsAlpha }) => {
let originalWindowLocation;
let mockWriteQuery;
beforeEach(() => {
originalWindowLocation = window.location;
delete window.location;
window.location = new URL('https://gitlab.example.com');
window.gon.current_user_id = 1;
beforeEach(() => {
originalWindowLocation = window.location;
delete window.location;
window.location = new URL('https://gitlab.example.com');
window.gon.current_user_id = 1;
window.gon.features = {
workItemsAlpha,
};
mockWriteQuery = jest.fn();
apolloProvider.clients.defaultClient.cache.writeQuery = mockWriteQuery;
localStorage.setItem(
`autosave/new-gitlab-org-epic-draft`,
JSON.stringify(mockCreateWorkItemDraftData),
);
});
mockWriteQuery = jest.fn();
apolloProvider.clients.defaultClient.cache.writeQuery = mockWriteQuery;
afterEach(() => {
window.location = originalWindowLocation;
});
localStorage.setItem(
`autosave/new-gitlab-org-epic-draft`,
JSON.stringify(mockCreateWorkItemDraftData),
);
it('updates cache from localstorage to save cache data', async () => {
window.location.search = '';
await setNewWorkItemCache(mockNewWorkItemCache);
await waitForPromises();
if (window.gon.features.workItemsAlpha) {
localStorage.setItem(
`autosave/new-gitlab-org-widgets-draft`,
JSON.stringify(getWorkItemWidgets(mockCreateWorkItemDraftData)),
);
}
});
expect(mockWriteQuery).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
workspace: expect.objectContaining({
workItem: expect.objectContaining({
title: mockCreateWorkItemDraftData.workspace.workItem.title,
widgets: expect.arrayContaining(restoredDraftDataWidgets),
}),
}),
}),
}),
);
});
afterEach(() => {
window.location = originalWindowLocation;
});
it.each`
description | locationSearchString | expectedTitle | expectedWidgets
${'restores cache with empty form'} | ${'?vulnerability_id=1'} | ${''} | ${restoredDraftDataWidgetsEmpty}
${'restores cache with empty form'} | ${'?discussion_to_resolve=1'} | ${''} | ${restoredDraftDataWidgetsEmpty}
${'restores cache with draft'} | ${'?type=ISSUE'} | ${mockCreateWorkItemDraftData.workspace.workItem.title} | ${restoredDraftDataWidgets}
`(
'$description when URL params include $locationSearchString',
async ({ locationSearchString, expectedTitle, expectedWidgets }) => {
window.location.search = locationSearchString;
it('updates cache from localstorage to save cache data', async () => {
window.location.search = '';
await setNewWorkItemCache(mockNewWorkItemCache);
await waitForPromises();
@ -285,16 +291,64 @@ describe('work items graphql cache utils', () => {
data: expect.objectContaining({
workspace: expect.objectContaining({
workItem: expect.objectContaining({
title: expectedTitle,
widgets: expect.arrayContaining(expectedWidgets),
title: mockCreateWorkItemDraftData.workspace.workItem.title,
widgets: expect.arrayContaining(restoredDraftDataWidgets),
}),
}),
}),
}),
);
},
);
});
});
it.each`
description | locationSearchString | expectedTitle | expectedWidgets
${'restores cache with empty form'} | ${'?vulnerability_id=1'} | ${''} | ${restoredDraftDataWidgetsEmpty}
${'restores cache with empty form'} | ${'?discussion_to_resolve=1'} | ${''} | ${restoredDraftDataWidgetsEmpty}
${'restores cache with draft'} | ${'?type=ISSUE'} | ${mockCreateWorkItemDraftData.workspace.workItem.title} | ${restoredDraftDataWidgets}
`(
'$description when URL params include $locationSearchString',
async ({ locationSearchString, expectedTitle, expectedWidgets }) => {
window.location.search = locationSearchString;
await setNewWorkItemCache(mockNewWorkItemCache);
await waitForPromises();
expect(mockWriteQuery).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
workspace: expect.objectContaining({
workItem: expect.objectContaining({
title: expectedTitle,
widgets: expect.arrayContaining(expectedWidgets),
}),
}),
}),
}),
);
},
);
itif(workItemsAlpha)('shares widget data between work item types', async () => {
await setNewWorkItemCache(mockNewWorkItemIssueCache);
await waitForPromises();
expect(mockWriteQuery).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
workspace: expect.objectContaining({
workItem: expect.objectContaining({
// The title was originally set for Epic type in beforeEach call above
title: mockCreateWorkItemDraftData.workspace.workItem.title,
// The widgets data is shared
widgets: expect.arrayContaining(restoredDraftDataWidgetsForIssue),
}),
}),
}),
}),
);
});
},
);
describe('updateCacheAfterCreatingNote', () => {
const findDiscussions = ({ workspace }) =>

View File

@ -201,7 +201,8 @@ describe('work items graphql resolvers', () => {
});
it('updates the local storage with every mutation', async () => {
const AUTO_SAVE_KEY = `autosave/new-fullPath-issue-draft`;
const typeSpecificAutosaveKey = `autosave/new-fullPath-issue-draft`;
const sharedWidgetsAutosaveKey = 'autosave/new-fullPath-widgets-draft';
await mutate({ title: 'Title' });
@ -217,7 +218,25 @@ describe('work items graphql resolvers', () => {
},
};
expect(localStorage.setItem).toHaveBeenLastCalledWith(AUTO_SAVE_KEY, JSON.stringify(object));
const widgets = {};
for (const widget of queryResult.widgets || []) {
if (widget.type) {
widgets[widget.type] = widget;
}
}
widgets.TITLE = queryResult.title;
expect(localStorage.setItem).toHaveBeenCalledTimes(2);
expect(localStorage.setItem).toHaveBeenNthCalledWith(
1,
typeSpecificAutosaveKey,
JSON.stringify(object),
);
expect(localStorage.setItem).toHaveBeenNthCalledWith(
2,
sharedWidgetsAutosaveKey,
JSON.stringify(widgets),
);
});
});
});

View File

@ -7117,10 +7117,92 @@ export const mockNewWorkItemCache = {
},
],
workItemType: 'EPIC',
workItemTypeId: 'gid://gitlab/WorkItems::Type/8 ',
workItemTypeId: 'gid://gitlab/WorkItems::Type/8',
workItemTypeIconName: 'issue-type-epic',
};
export const mockNewWorkItemIssueCache = {
fullPath: 'gitlab-org',
widgetDefinitions: [
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'AWARD_EMOJI',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'CURRENT_USER_TODOS',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'DESCRIPTION',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'HEALTH_STATUS',
},
{
__typename: 'WorkItemWidgetDefinitionHierarchy',
type: 'HIERARCHY',
allowedChildTypes: {
__typename: 'WorkItemTypeConnection',
nodes: [
{
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
},
],
},
},
{
__typename: 'WorkItemWidgetDefinitionLabels',
type: 'LABELS',
allowsScopedLabels: true,
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'LINKED_ITEMS',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'NOTES',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'NOTIFICATIONS',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'PARTICIPANTS',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'START_AND_DUE_DATE',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'STATUS',
},
{
__typename: 'WorkItemWidgetDefinitionGeneric',
type: 'TIME_TRACKING',
},
{
__typename: 'WorkItemWidgetDefinitionWeight',
type: 'WEIGHT',
editable: false,
rollUp: true,
},
{
__typename: 'WorkItemWidgetDefinitionCustomFields',
type: WIDGET_TYPE_CUSTOM_FIELDS,
},
],
workItemType: 'Issue',
workItemTypeId: 'gid://gitlab/WorkItems::Type/2',
workItemTypeIconName: 'issue-type-issue',
};
export const restoredDraftDataWidgets = [
{
type: 'DESCRIPTION',
@ -7241,6 +7323,29 @@ export const restoredDraftDataWidgets = [
},
];
export const restoredDraftDataWidgetsForIssue = restoredDraftDataWidgets
// Drop any unsupported widget for Issue type
.filter((widget) => !['COLOR'].includes(widget.type))
// Override specific widgets for Issue type
.map((widget) => {
if (widget.type === 'HIERARCHY') {
return {
type: 'HIERARCHY',
hasChildren: false,
hasParent: false,
parent: null,
depthLimitReachedByType: [],
rolledUpCountsByType: [],
children: {
nodes: [],
__typename: 'WorkItemConnection',
},
__typename: 'WorkItemWidgetHierarchy',
};
}
return { ...widget };
});
export const restoredDraftDataWidgetsEmpty = [
{
type: 'DESCRIPTION',

View File

@ -2,6 +2,9 @@ import {
NEW_WORK_ITEM_IID,
STATE_CLOSED,
STATE_OPEN,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_TYPE_ENUM_EPIC,
WORK_ITEM_TYPE_ENUM_INCIDENT,
WORK_ITEM_TYPE_ENUM_ISSUE,
@ -41,9 +44,12 @@ import {
getParentGroupName,
createBranchMRApiPathHelper,
getNewWorkItemAutoSaveKey,
getNewWorkItemWidgetsAutoSaveKey,
getWorkItemWidgets,
} from '~/work_items/utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { TYPE_EPIC } from '~/issues/constants';
import { workItemQueryResponse } from './mock_data';
describe('formatLabelForListbox', () => {
const label = {
@ -449,6 +455,33 @@ describe('getNewWorkItemAutoSaveKey', () => {
});
});
describe('getNewWorkItemWidgetsAutoSaveKey', () => {
it('returns autosave key for a new work item', () => {
const autosaveKey = getNewWorkItemWidgetsAutoSaveKey({
fullPath: 'gitlab-org/gitlab',
});
expect(autosaveKey).toEqual('new-gitlab-org/gitlab-widgets-draft');
});
});
describe('getWorkItemWidgets', () => {
it('returns the correct widgets for a work item', () => {
const result = getWorkItemWidgets({
workspace: {
workItem: workItemQueryResponse.data.workItem,
},
});
const { widgets } = workItemQueryResponse.data.workItem;
expect(result).toEqual({
TITLE: workItemQueryResponse.data.workItem.title,
[WIDGET_TYPE_DESCRIPTION]: widgets.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION),
[WIDGET_TYPE_ASSIGNEES]: widgets.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES),
[WIDGET_TYPE_HIERARCHY]: widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY),
});
});
});
describe('`getItems`', () => {
it('returns all children when showClosed flag is on', () => {
const children = [

View File

@ -9,8 +9,9 @@ RSpec.describe ::Ci::Runners::UnassignRunnerService, '#execute', :aggregate_fail
let(:project_to_unassign) { other_project }
let(:runner_project) { runner.runner_projects.find_by(project_id: project_to_unassign.id) }
let(:service) { described_class.new(runner_project, user) }
subject(:execute) { described_class.new(runner_project, user).execute }
subject(:execute) { service.execute }
context 'without user' do
let(:user) { nil }
@ -20,7 +21,7 @@ RSpec.describe ::Ci::Runners::UnassignRunnerService, '#execute', :aggregate_fail
expect { execute }.not_to change { runner.runner_projects.count }.from(2)
expect(execute).to be_error
expect(execute.message).to eq('User not allowed to assign runner')
expect(execute.message).to eq('User not allowed to unassign runner')
end
end
@ -31,7 +32,7 @@ RSpec.describe ::Ci::Runners::UnassignRunnerService, '#execute', :aggregate_fail
expect(runner_project).not_to receive(:destroy)
expect(execute).to be_error
expect(execute.message).to eq('User not allowed to assign runner')
expect(execute.message).to eq('User not allowed to unassign runner')
end
end

View File

@ -38,21 +38,18 @@ RSpec.describe ContainerRegistry::Protection::Concerns::TagRule, feature_categor
)
end
let_it_be(:rule1) { create_rule(:owner, 'owner_pattern') }
let_it_be(:rule2) { create_rule(:admin, 'admin_pattern') }
let_it_be(:rule3) { create_rule(:maintainer, 'maintainer_pattern') }
let_it_be(:immutable_rule) do
create(:container_registry_protection_tag_rule, :immutable, project: project,
tag_name_pattern: 'immutable_pattern')
before_all do
create_rule(:owner, 'owner_pattern')
create_rule(:admin, 'admin_pattern')
create_rule(:maintainer, 'maintainer_pattern')
end
context 'when current user is nil' do
let_it_be(:current_user) { nil }
let(:expected_tag_name_pattern) { [rule1, rule2, rule3, immutable_rule].map(&:tag_name_pattern) }
it 'returns all tag rules' do
expect(tag_name_patterns).to all(be_a(Gitlab::UntrustedRegexp))
expect(tag_name_patterns.map(&:source)).to match_array(expected_tag_name_pattern)
expect(tag_name_patterns.map(&:source)).to match_array(%w[owner_pattern admin_pattern maintainer_pattern])
end
end
@ -60,25 +57,15 @@ RSpec.describe ContainerRegistry::Protection::Concerns::TagRule, feature_categor
context 'when current user is an admin', :enable_admin_mode do
let(:current_user) { build_stubbed(:admin) }
it 'returns immutable tag rules only' do
expect(tag_name_patterns.count).to eq(1)
expect(tag_name_patterns[0]).to be_a(Gitlab::UntrustedRegexp)
.and(have_attributes(source: 'immutable_pattern'))
end
context 'when feature container_registry_immutable_tags is disabled' do
before do
stub_feature_flags(container_registry_immutable_tags: false)
end
it { is_expected.to be_nil }
it 'does not return anything' do
expect(tag_name_patterns).to be_nil
end
end
where(:user_role, :expected_patterns) do
:developer | %w[admin_pattern maintainer_pattern owner_pattern immutable_pattern]
:maintainer | %w[admin_pattern owner_pattern immutable_pattern]
:owner | %w[admin_pattern immutable_pattern]
:developer | %w[admin_pattern maintainer_pattern owner_pattern]
:maintainer | %w[admin_pattern owner_pattern]
:owner | %w[admin_pattern]
end
with_them do
@ -90,17 +77,6 @@ RSpec.describe ContainerRegistry::Protection::Concerns::TagRule, feature_categor
expect(tag_name_patterns).to all(be_a(Gitlab::UntrustedRegexp))
expect(tag_name_patterns.map(&:source)).to match_array(expected_patterns)
end
context 'when feature container_registry_immutable_tags is disabled' do
before do
stub_feature_flags(container_registry_immutable_tags: false)
end
it 'returns the tag name patterns with access levels that are above the user excluding immutable tags' do
expect(tag_name_patterns).to all(be_a(Gitlab::UntrustedRegexp))
expect(tag_name_patterns.map(&:source)).to match_array(expected_patterns - %w[immutable_pattern])
end
end
end
end
end