Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
b70b663063
commit
2fbf0bd5c0
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
3afd4aed3093dd3c98de64259cb55c873759bcbd13b2c14dd33ed789a0e8abc4
|
||||
|
|
@ -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'] = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 >}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '#{project.name}' 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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue