Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
aa01c59edf
commit
a2d5a577e3
|
|
@ -1 +1 @@
|
|||
db58d685d85c5616bc90cdb806e9ee67cfc1c398
|
||||
ed5bd1bce7be929eeccc3e737157b018d35ad5a7
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const initJobDetails = () => {
|
|||
aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable),
|
||||
duoFeaturesEnabled: parseBoolean(duoFeaturesEnabled),
|
||||
pipelineTestReportUrl,
|
||||
userRole: userRole?.toLowerCase(),
|
||||
userRole,
|
||||
},
|
||||
render(h) {
|
||||
return h(JobApp, {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import { formatTime } from '~/lib/utils/datetime_utility';
|
|||
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import timeagoMixin from '~/vue_shared/mixins/timeago';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { sprintf, s__ } from '~/locale';
|
||||
|
||||
export default {
|
||||
iconSize: 12,
|
||||
i18n: {
|
||||
statusDescription: (id) => sprintf(s__('Jobs|Status for job %{id}'), { id }),
|
||||
},
|
||||
components: {
|
||||
CiIcon,
|
||||
GlIcon,
|
||||
|
|
@ -20,6 +25,13 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
jobId() {
|
||||
const id = getIdFromGraphQLId(this.job.id);
|
||||
return `#${id}`;
|
||||
},
|
||||
statusDescriptionId() {
|
||||
return `ci-status-description-${this.jobId}`;
|
||||
},
|
||||
finishedTime() {
|
||||
return this.job?.finishedAt;
|
||||
},
|
||||
|
|
@ -38,7 +50,12 @@ export default {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<ci-icon :status="job.detailedStatus" show-status-text />
|
||||
<p :id="statusDescriptionId" class="gl-sr-only">{{ $options.i18n.statusDescription(jobId) }}</p>
|
||||
<ci-icon
|
||||
:status="job.detailedStatus"
|
||||
show-status-text
|
||||
:aria-describedby="statusDescriptionId"
|
||||
/>
|
||||
<div class="gl-ml-1 gl-mt-2 gl-text-sm gl-text-subtle">
|
||||
<div v-if="duration" data-testid="job-duration">
|
||||
<gl-icon
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ const ROLE_OWNER = 'owner';
|
|||
export default {
|
||||
USER_ROLES: Object.freeze([ROLE_DEVELOPER, ROLE_MAINTAINER, ROLE_OWNER]),
|
||||
|
||||
inject: ['projectPath', 'userRole'],
|
||||
inject: {
|
||||
projectPath: { default: '' },
|
||||
fullPath: { default: '' },
|
||||
userRole: { default: '' },
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -24,7 +28,7 @@ export default {
|
|||
query: getPipelineVariablesMinimumOverrideRoleQuery,
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.projectPath,
|
||||
fullPath: this.projectPath || this.fullPath,
|
||||
};
|
||||
},
|
||||
update({ project }) {
|
||||
|
|
@ -53,7 +57,7 @@ export default {
|
|||
return false;
|
||||
}
|
||||
|
||||
const userRoleIndex = this.$options.USER_ROLES.indexOf(this.userRole);
|
||||
const userRoleIndex = this.$options.USER_ROLES.indexOf(this.userRole?.toLowerCase());
|
||||
const minRoleIndex = this.$options.USER_ROLES.indexOf(this.minimumRole);
|
||||
|
||||
return userRoleIndex >= minRoleIndex || false;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createAlert } from '~/alert';
|
|||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import SafeHtml from '~/vue_shared/directives/safe_html';
|
||||
import PipelineInputsForm from '~/ci/common/pipeline_inputs/pipeline_inputs_form.vue';
|
||||
import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
|
||||
import { IDENTITY_VERIFICATION_REQUIRED_ERROR } from '../constants';
|
||||
import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql';
|
||||
import RefsDropdown from './refs_dropdown.vue';
|
||||
|
|
@ -24,6 +25,7 @@ const i18n = {
|
|||
export default {
|
||||
name: 'PipelineNewForm',
|
||||
i18n,
|
||||
ROLE_MAINTAINER: 'maintainer',
|
||||
components: {
|
||||
GlAlert,
|
||||
GlButton,
|
||||
|
|
@ -37,7 +39,8 @@ export default {
|
|||
import('ee_component/vue_shared/components/pipeline_account_verification_alert.vue'),
|
||||
},
|
||||
directives: { SafeHtml },
|
||||
mixins: [glFeatureFlagsMixin()],
|
||||
mixins: [glFeatureFlagsMixin(), PipelineVariablesPermissionsMixin],
|
||||
inject: ['projectPath', 'userRole'],
|
||||
props: {
|
||||
pipelinesPath: {
|
||||
type: String,
|
||||
|
|
@ -68,10 +71,6 @@ export default {
|
|||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
projectPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
refParam: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
|
@ -86,10 +85,6 @@ export default {
|
|||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isMaintainer: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -116,6 +111,9 @@ export default {
|
|||
isPipelineInputsFeatureAvailable() {
|
||||
return this.glFeatures.ciInputsForPipelines;
|
||||
},
|
||||
isMaintainer() {
|
||||
return this.userRole?.toLowerCase() === this.$options.ROLE_MAINTAINER;
|
||||
},
|
||||
overMaxWarningsLimit() {
|
||||
return this.totalWarnings > this.maxWarnings;
|
||||
},
|
||||
|
|
@ -246,7 +244,7 @@ export default {
|
|||
</p>
|
||||
</details>
|
||||
</gl-alert>
|
||||
<div class="gl-flex gl-flex-col gl-gap-7">
|
||||
<div class="gl-flex gl-flex-col gl-gap-5">
|
||||
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')" class="gl-mb-0">
|
||||
<refs-dropdown
|
||||
v-model="refValue"
|
||||
|
|
@ -256,6 +254,7 @@ export default {
|
|||
</gl-form-group>
|
||||
<pipeline-inputs-form v-if="isPipelineInputsFeatureAvailable" />
|
||||
<pipeline-variables-form
|
||||
v-if="canViewPipelineVariables"
|
||||
:file-params="fileParams"
|
||||
:is-maintainer="isMaintainer"
|
||||
:project-path="projectPath"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default {
|
|||
GlSprintf,
|
||||
VariableValuesListbox,
|
||||
},
|
||||
inject: ['projectPath'],
|
||||
props: {
|
||||
fileParams: {
|
||||
type: Object,
|
||||
|
|
@ -59,10 +60,6 @@ export default {
|
|||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
projectPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
refParam: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ const mountPipelineNewForm = (el) => {
|
|||
// provide/inject
|
||||
projectRefsEndpoint,
|
||||
identityVerificationPath,
|
||||
projectPath,
|
||||
userRole,
|
||||
|
||||
// props
|
||||
defaultBranch,
|
||||
|
|
@ -17,11 +19,9 @@ const mountPipelineNewForm = (el) => {
|
|||
pipelinesEditorPath,
|
||||
canViewPipelineEditor,
|
||||
projectId,
|
||||
projectPath,
|
||||
refParam,
|
||||
settingsLink,
|
||||
varParam,
|
||||
isMaintainer,
|
||||
} = el.dataset;
|
||||
|
||||
const variableParams = JSON.parse(varParam);
|
||||
|
|
@ -44,6 +44,8 @@ const mountPipelineNewForm = (el) => {
|
|||
// rendered if a specific error is returned from the backend after
|
||||
// the create pipeline XHR request completes
|
||||
identityVerificationRequired: true,
|
||||
projectPath,
|
||||
userRole,
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement(PipelineNewForm, {
|
||||
|
|
@ -55,11 +57,9 @@ const mountPipelineNewForm = (el) => {
|
|||
pipelinesEditorPath,
|
||||
canViewPipelineEditor,
|
||||
projectId,
|
||||
projectPath,
|
||||
refParam,
|
||||
settingsLink,
|
||||
variableParams,
|
||||
isMaintainer,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
|
|||
import RefSelector from '~/ref/components/ref_selector.vue';
|
||||
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
|
||||
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
|
||||
import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
|
||||
import createPipelineScheduleMutation from '../graphql/mutations/create_pipeline_schedule.mutation.graphql';
|
||||
import updatePipelineScheduleMutation from '../graphql/mutations/update_pipeline_schedule.mutation.graphql';
|
||||
import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql';
|
||||
|
|
@ -34,7 +35,16 @@ export default {
|
|||
IntervalPatternInput,
|
||||
PipelineVariablesFormGroup,
|
||||
},
|
||||
inject: ['fullPath', 'projectId', 'defaultBranch', 'dailyLimit', 'settingsLink', 'schedulesPath'],
|
||||
mixins: [PipelineVariablesPermissionsMixin],
|
||||
inject: [
|
||||
'fullPath',
|
||||
'projectId',
|
||||
'defaultBranch',
|
||||
'dailyLimit',
|
||||
'settingsLink',
|
||||
'schedulesPath',
|
||||
'userRole',
|
||||
],
|
||||
props: {
|
||||
timezoneData: {
|
||||
type: Array,
|
||||
|
|
@ -303,6 +313,7 @@ export default {
|
|||
</gl-form-group>
|
||||
<!--Variable List-->
|
||||
<pipeline-variables-form-group
|
||||
v-if="canViewPipelineVariables"
|
||||
:initial-variables="variables"
|
||||
:editing="editing"
|
||||
@update-variables="updatedVariables = $event"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export default (selector, editing = false) => {
|
|||
defaultBranch,
|
||||
settingsLink,
|
||||
schedulesPath,
|
||||
userRole,
|
||||
} = containerEl.dataset;
|
||||
|
||||
return new Vue({
|
||||
|
|
@ -37,6 +38,7 @@ export default (selector, editing = false) => {
|
|||
dailyLimit: dailyLimit ?? '',
|
||||
settingsLink,
|
||||
schedulesPath,
|
||||
userRole,
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement(PipelineSchedulesForm, {
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@
|
|||
],
|
||||
"WorkItemWidgetDefinition": [
|
||||
"WorkItemWidgetDefinitionAssignees",
|
||||
"WorkItemWidgetDefinitionCustomFields",
|
||||
"WorkItemWidgetDefinitionCustomStatus",
|
||||
"WorkItemWidgetDefinitionGeneric",
|
||||
"WorkItemWidgetDefinitionHierarchy",
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ import { createAlert } from '~/alert';
|
|||
import { TIMESTAMP_TYPES } from '~/vue_shared/components/resource_lists/constants';
|
||||
import { FILTERED_SEARCH_TERM_KEY } from '~/projects/filtered_search_and_sort/constants';
|
||||
import { ACCESS_LEVELS_INTEGER_TO_STRING } from '~/access_level/constants';
|
||||
import { formatProjects } from '~/projects/your_work/utils';
|
||||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '../constants';
|
||||
import { formatProjects } from '../utils';
|
||||
|
||||
// Will be made more generic to work with groups and projects in future commits
|
||||
export default {
|
||||
name: 'YourWorkProjectsTabView',
|
||||
name: 'TabView',
|
||||
i18n: {
|
||||
errorMessage: __(
|
||||
'An error occurred loading the projects. Please refresh the page to try again.',
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
<script>
|
||||
import { GlTabs, GlTab, GlBadge, GlFilteredSearchToken } from '@gitlab/ui';
|
||||
import { isEqual, pick } from 'lodash';
|
||||
import { __ } from '~/locale';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
|
||||
} from '~/vue_shared/components/resource_lists/constants';
|
||||
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
|
||||
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
|
||||
import { createAlert } from '~/alert';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import { calculateGraphQLPaginationQueryParams } from '~/graphql_shared/utils';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_DIRECTION_ASC,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_OPTION_UPDATED,
|
||||
SORT_OPTION_CREATED,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import {
|
||||
CONTRIBUTED_TAB,
|
||||
CUSTOM_DASHBOARD_ROUTE_NAMES,
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
} from '~/projects/your_work/constants';
|
||||
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '../constants';
|
||||
import userPreferencesUpdateMutation from '../graphql/mutations/user_preferences_update.mutation.graphql';
|
||||
import TabView from './tab_view.vue';
|
||||
|
||||
// Will be made more generic to work with groups and projects in future commits
|
||||
export default {
|
||||
name: 'TabsWithList',
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
i18n: {
|
||||
projectCountError: __('An error occurred loading the project counts.'),
|
||||
},
|
||||
filteredSearchAndSort: {
|
||||
sortOptions: SORT_OPTIONS,
|
||||
namespace: FILTERED_SEARCH_NAMESPACE,
|
||||
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
searchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
},
|
||||
components: {
|
||||
GlTabs,
|
||||
GlTab,
|
||||
GlBadge,
|
||||
TabView,
|
||||
FilteredSearchAndSort,
|
||||
},
|
||||
inject: ['initialSort', 'programmingLanguages'],
|
||||
data() {
|
||||
return {
|
||||
activeTabIndex: this.initActiveTabIndex(),
|
||||
counts: PROJECT_DASHBOARD_TABS.reduce((accumulator, tab) => {
|
||||
return {
|
||||
...accumulator,
|
||||
[tab.value]: undefined,
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
counts() {
|
||||
return {
|
||||
query: projectCountsQuery,
|
||||
update(response) {
|
||||
const {
|
||||
currentUser: { contributed, starred },
|
||||
personal,
|
||||
member,
|
||||
inactive,
|
||||
} = response;
|
||||
|
||||
return {
|
||||
contributed: contributed.count,
|
||||
starred: starred.count,
|
||||
personal: personal.count,
|
||||
member: member.count,
|
||||
inactive: inactive.count,
|
||||
};
|
||||
},
|
||||
error(error) {
|
||||
createAlert({ message: this.$options.i18n.projectCountError, error, captureError: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
filteredSearchTokens() {
|
||||
return [
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
icon: 'code',
|
||||
title: __('Language'),
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: this.programmingLanguages.map(({ id, name }) => ({
|
||||
// Cast to string so it matches value from query string
|
||||
value: id.toString(),
|
||||
title: name,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
icon: 'user',
|
||||
title: __('Role'),
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: [
|
||||
{
|
||||
// Cast to string so it matches value from query string
|
||||
value: ACCESS_LEVEL_OWNER_INTEGER.toString(),
|
||||
title: __('Owner'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
sortQuery() {
|
||||
return this.$route.query.sort;
|
||||
},
|
||||
sort() {
|
||||
const sortOptionValues = SORT_OPTIONS.flatMap(({ value }) => [
|
||||
`${value}_${SORT_DIRECTION_ASC}`,
|
||||
`${value}_${SORT_DIRECTION_DESC}`,
|
||||
]);
|
||||
|
||||
if (this.sortQuery && sortOptionValues.includes(this.sortQuery)) {
|
||||
return this.sortQuery;
|
||||
}
|
||||
|
||||
if (sortOptionValues.includes(this.initialSort)) {
|
||||
return this.initialSort;
|
||||
}
|
||||
|
||||
return `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`;
|
||||
},
|
||||
activeSortOption() {
|
||||
return SORT_OPTIONS.find((sortItem) => this.sort.includes(sortItem.value));
|
||||
},
|
||||
isAscending() {
|
||||
return this.sort.endsWith(SORT_DIRECTION_ASC);
|
||||
},
|
||||
startCursor() {
|
||||
return this.$route.query[QUERY_PARAM_START_CURSOR];
|
||||
},
|
||||
endCursor() {
|
||||
return this.$route.query[QUERY_PARAM_END_CURSOR];
|
||||
},
|
||||
routeQueryWithoutPagination() {
|
||||
const {
|
||||
[QUERY_PARAM_START_CURSOR]: startCursor,
|
||||
[QUERY_PARAM_END_CURSOR]: endCursor,
|
||||
...routeQuery
|
||||
} = this.$route.query;
|
||||
|
||||
return routeQuery;
|
||||
},
|
||||
filters() {
|
||||
const filters = pick(this.routeQueryWithoutPagination, [
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
]);
|
||||
|
||||
// Normalize the property to Number since Vue Router 4 will
|
||||
// return this and all other query variables as a string
|
||||
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL] = Number(
|
||||
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
|
||||
);
|
||||
|
||||
return filters;
|
||||
},
|
||||
timestampType() {
|
||||
const SORT_MAP = {
|
||||
[SORT_OPTION_CREATED.value]: TIMESTAMP_TYPE_CREATED_AT,
|
||||
[SORT_OPTION_UPDATED.value]: TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
|
||||
};
|
||||
|
||||
return SORT_MAP[this.activeSortOption.value] || TIMESTAMP_TYPE_CREATED_AT;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
numberToMetricPrefix,
|
||||
createSortQuery({ sortBy, isAscending }) {
|
||||
return `${sortBy}_${isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC}`;
|
||||
},
|
||||
pushQuery(query) {
|
||||
if (isEqual(this.$route.query, query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.push({ query });
|
||||
},
|
||||
initActiveTabIndex() {
|
||||
return CUSTOM_DASHBOARD_ROUTE_NAMES.includes(this.$route.name)
|
||||
? 0
|
||||
: PROJECT_DASHBOARD_TABS.findIndex((tab) => tab.value === this.$route.name);
|
||||
},
|
||||
onTabUpdate(index) {
|
||||
// This return will prevent us overwriting the root `/` and `/dashboard/projects` paths
|
||||
// when we don't need to.
|
||||
if (index === this.activeTabIndex) return;
|
||||
|
||||
this.activeTabIndex = index;
|
||||
|
||||
const tab = PROJECT_DASHBOARD_TABS[index] || CONTRIBUTED_TAB;
|
||||
this.$router.push({ name: tab.value });
|
||||
},
|
||||
tabCount(tab) {
|
||||
return this.counts[tab.value];
|
||||
},
|
||||
shouldShowCountBadge(tab) {
|
||||
return this.tabCount(tab) !== undefined;
|
||||
},
|
||||
onSortDirectionChange(isAscending) {
|
||||
const sort = this.createSortQuery({ sortBy: this.activeSortOption.value, isAscending });
|
||||
|
||||
this.updateSort(sort);
|
||||
},
|
||||
onSortByChange(sortBy) {
|
||||
const sort = this.createSortQuery({ sortBy, isAscending: this.isAscending });
|
||||
|
||||
this.updateSort(sort);
|
||||
},
|
||||
updateSort(sort) {
|
||||
this.pushQuery({ ...this.routeQueryWithoutPagination, sort });
|
||||
this.userPreferencesUpdateMutate(sort);
|
||||
},
|
||||
onFilter(filters) {
|
||||
const { sort } = this.$route.query;
|
||||
|
||||
this.pushQuery({ sort, ...filters });
|
||||
},
|
||||
onPageChange(pagination) {
|
||||
this.pushQuery(
|
||||
calculateGraphQLPaginationQueryParams({ ...pagination, routeQuery: this.$route.query }),
|
||||
);
|
||||
},
|
||||
async userPreferencesUpdateMutate(sort) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: userPreferencesUpdateMutation,
|
||||
variables: {
|
||||
input: {
|
||||
projectsSort: sort.toUpperCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail but capture exception in Sentry
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-tabs :value="activeTabIndex" @input="onTabUpdate">
|
||||
<gl-tab v-for="tab in $options.PROJECT_DASHBOARD_TABS" :key="tab.text" lazy>
|
||||
<template #title>
|
||||
<div class="gl-flex gl-items-center gl-gap-2" data-testid="projects-dashboard-tab-title">
|
||||
<span>{{ tab.text }}</span>
|
||||
<gl-badge
|
||||
v-if="shouldShowCountBadge(tab)"
|
||||
size="sm"
|
||||
class="gl-tab-counter-badge"
|
||||
data-testid="tab-counter-badge"
|
||||
>{{ numberToMetricPrefix(tabCount(tab)) }}</gl-badge
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<tab-view
|
||||
v-if="tab.query"
|
||||
:tab="tab"
|
||||
:start-cursor="startCursor"
|
||||
:end-cursor="endCursor"
|
||||
:sort="sort"
|
||||
:filters="filters"
|
||||
:timestamp-type="timestampType"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
<template v-else>{{ tab.text }}</template>
|
||||
</gl-tab>
|
||||
|
||||
<template #tabs-end>
|
||||
<li class="gl-w-full">
|
||||
<filtered-search-and-sort
|
||||
class="gl-border-b-0"
|
||||
:filtered-search-namespace="$options.filteredSearchAndSort.namespace"
|
||||
:filtered-search-tokens="filteredSearchTokens"
|
||||
:filtered-search-term-key="$options.filteredSearchAndSort.searchTermKey"
|
||||
:filtered-search-recent-searches-storage-key="
|
||||
$options.filteredSearchAndSort.recentSearchesStorageKey
|
||||
"
|
||||
:filtered-search-query="$route.query"
|
||||
:is-ascending="isAscending"
|
||||
:sort-options="$options.filteredSearchAndSort.sortOptions"
|
||||
:active-sort-option="activeSortOption"
|
||||
@filter="onFilter"
|
||||
@sort-direction-change="onSortDirectionChange"
|
||||
@sort-by-change="onSortByChange"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</gl-tabs>
|
||||
</template>
|
||||
|
|
@ -4,3 +4,6 @@ export const SORT_LABEL_NAME = __('Name');
|
|||
export const SORT_LABEL_CREATED = __('Created date');
|
||||
export const SORT_LABEL_UPDATED = __('Updated date');
|
||||
export const SORT_LABEL_STARS = __('Stars');
|
||||
|
||||
export const FILTERED_SEARCH_TOKEN_LANGUAGE = 'language';
|
||||
export const FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL = 'min_access_level';
|
||||
|
|
|
|||
|
|
@ -1,318 +1,14 @@
|
|||
<script>
|
||||
import { GlTabs, GlTab, GlBadge, GlFilteredSearchToken } from '@gitlab/ui';
|
||||
import { isEqual, pick } from 'lodash';
|
||||
import { __ } from '~/locale';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
|
||||
} from '~/vue_shared/components/resource_lists/constants';
|
||||
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
|
||||
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
|
||||
import { createAlert } from '~/alert';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import { calculateGraphQLPaginationQueryParams } from '~/graphql_shared/utils';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_DIRECTION_ASC,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_OPTION_UPDATED,
|
||||
SORT_OPTION_CREATED,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import {
|
||||
CONTRIBUTED_TAB,
|
||||
CUSTOM_DASHBOARD_ROUTE_NAMES,
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '../constants';
|
||||
import projectCountsQuery from '../graphql/queries/project_counts.query.graphql';
|
||||
import userPreferencesUpdateMutation from '../graphql/mutations/user_preferences_update.mutation.graphql';
|
||||
import TabView from './tab_view.vue';
|
||||
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
|
||||
|
||||
export default {
|
||||
name: 'YourWorkProjectsApp',
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
i18n: {
|
||||
projectCountError: __('An error occurred loading the project counts.'),
|
||||
},
|
||||
filteredSearchAndSort: {
|
||||
sortOptions: SORT_OPTIONS,
|
||||
namespace: FILTERED_SEARCH_NAMESPACE,
|
||||
recentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
searchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
},
|
||||
components: {
|
||||
GlTabs,
|
||||
GlTab,
|
||||
GlBadge,
|
||||
TabView,
|
||||
FilteredSearchAndSort,
|
||||
},
|
||||
inject: ['initialSort', 'programmingLanguages'],
|
||||
data() {
|
||||
return {
|
||||
activeTabIndex: this.initActiveTabIndex(),
|
||||
counts: PROJECT_DASHBOARD_TABS.reduce((accumulator, tab) => {
|
||||
return {
|
||||
...accumulator,
|
||||
[tab.value]: undefined,
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
counts() {
|
||||
return {
|
||||
query: projectCountsQuery,
|
||||
update(response) {
|
||||
const {
|
||||
currentUser: { contributed, starred },
|
||||
personal,
|
||||
member,
|
||||
inactive,
|
||||
} = response;
|
||||
|
||||
return {
|
||||
contributed: contributed.count,
|
||||
starred: starred.count,
|
||||
personal: personal.count,
|
||||
member: member.count,
|
||||
inactive: inactive.count,
|
||||
};
|
||||
},
|
||||
error(error) {
|
||||
createAlert({ message: this.$options.i18n.projectCountError, error, captureError: true });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
filteredSearchTokens() {
|
||||
return [
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
icon: 'code',
|
||||
title: __('Language'),
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: this.programmingLanguages.map(({ id, name }) => ({
|
||||
// Cast to string so it matches value from query string
|
||||
value: id.toString(),
|
||||
title: name,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
icon: 'user',
|
||||
title: __('Role'),
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: [
|
||||
{
|
||||
// Cast to string so it matches value from query string
|
||||
value: ACCESS_LEVEL_OWNER_INTEGER.toString(),
|
||||
title: __('Owner'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
sortQuery() {
|
||||
return this.$route.query.sort;
|
||||
},
|
||||
sort() {
|
||||
const sortOptionValues = SORT_OPTIONS.flatMap(({ value }) => [
|
||||
`${value}_${SORT_DIRECTION_ASC}`,
|
||||
`${value}_${SORT_DIRECTION_DESC}`,
|
||||
]);
|
||||
|
||||
if (this.sortQuery && sortOptionValues.includes(this.sortQuery)) {
|
||||
return this.sortQuery;
|
||||
}
|
||||
|
||||
if (sortOptionValues.includes(this.initialSort)) {
|
||||
return this.initialSort;
|
||||
}
|
||||
|
||||
return `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`;
|
||||
},
|
||||
activeSortOption() {
|
||||
return SORT_OPTIONS.find((sortItem) => this.sort.includes(sortItem.value));
|
||||
},
|
||||
isAscending() {
|
||||
return this.sort.endsWith(SORT_DIRECTION_ASC);
|
||||
},
|
||||
startCursor() {
|
||||
return this.$route.query[QUERY_PARAM_START_CURSOR];
|
||||
},
|
||||
endCursor() {
|
||||
return this.$route.query[QUERY_PARAM_END_CURSOR];
|
||||
},
|
||||
routeQueryWithoutPagination() {
|
||||
const {
|
||||
[QUERY_PARAM_START_CURSOR]: startCursor,
|
||||
[QUERY_PARAM_END_CURSOR]: endCursor,
|
||||
...routeQuery
|
||||
} = this.$route.query;
|
||||
|
||||
return routeQuery;
|
||||
},
|
||||
filters() {
|
||||
const filters = pick(this.routeQueryWithoutPagination, [
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
]);
|
||||
|
||||
// Normalize the property to Number since Vue Router 4 will
|
||||
// return this and all other query variables as a string
|
||||
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL] = Number(
|
||||
filters[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
|
||||
);
|
||||
|
||||
return filters;
|
||||
},
|
||||
timestampType() {
|
||||
const SORT_MAP = {
|
||||
[SORT_OPTION_CREATED.value]: TIMESTAMP_TYPE_CREATED_AT,
|
||||
[SORT_OPTION_UPDATED.value]: TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
|
||||
};
|
||||
|
||||
return SORT_MAP[this.activeSortOption.value] || TIMESTAMP_TYPE_CREATED_AT;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
numberToMetricPrefix,
|
||||
createSortQuery({ sortBy, isAscending }) {
|
||||
return `${sortBy}_${isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC}`;
|
||||
},
|
||||
pushQuery(query) {
|
||||
if (isEqual(this.$route.query, query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.push({ query });
|
||||
},
|
||||
initActiveTabIndex() {
|
||||
return CUSTOM_DASHBOARD_ROUTE_NAMES.includes(this.$route.name)
|
||||
? 0
|
||||
: PROJECT_DASHBOARD_TABS.findIndex((tab) => tab.value === this.$route.name);
|
||||
},
|
||||
onTabUpdate(index) {
|
||||
// This return will prevent us overwriting the root `/` and `/dashboard/projects` paths
|
||||
// when we don't need to.
|
||||
if (index === this.activeTabIndex) return;
|
||||
|
||||
this.activeTabIndex = index;
|
||||
|
||||
const tab = PROJECT_DASHBOARD_TABS[index] || CONTRIBUTED_TAB;
|
||||
this.$router.push({ name: tab.value });
|
||||
},
|
||||
tabCount(tab) {
|
||||
return this.counts[tab.value];
|
||||
},
|
||||
shouldShowCountBadge(tab) {
|
||||
return this.tabCount(tab) !== undefined;
|
||||
},
|
||||
onSortDirectionChange(isAscending) {
|
||||
const sort = this.createSortQuery({ sortBy: this.activeSortOption.value, isAscending });
|
||||
|
||||
this.updateSort(sort);
|
||||
},
|
||||
onSortByChange(sortBy) {
|
||||
const sort = this.createSortQuery({ sortBy, isAscending: this.isAscending });
|
||||
|
||||
this.updateSort(sort);
|
||||
},
|
||||
updateSort(sort) {
|
||||
this.pushQuery({ ...this.routeQueryWithoutPagination, sort });
|
||||
this.userPreferencesUpdateMutate(sort);
|
||||
},
|
||||
onFilter(filters) {
|
||||
const { sort } = this.$route.query;
|
||||
|
||||
this.pushQuery({ sort, ...filters });
|
||||
},
|
||||
onPageChange(pagination) {
|
||||
this.pushQuery(
|
||||
calculateGraphQLPaginationQueryParams({ ...pagination, routeQuery: this.$route.query }),
|
||||
);
|
||||
},
|
||||
async userPreferencesUpdateMutate(sort) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: userPreferencesUpdateMutation,
|
||||
variables: {
|
||||
input: {
|
||||
projectsSort: sort.toUpperCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail but capture exception in Sentry
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
},
|
||||
TabsWithList,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-tabs :value="activeTabIndex" @input="onTabUpdate">
|
||||
<gl-tab v-for="tab in $options.PROJECT_DASHBOARD_TABS" :key="tab.text" lazy>
|
||||
<template #title>
|
||||
<div class="gl-flex gl-items-center gl-gap-2" data-testid="projects-dashboard-tab-title">
|
||||
<span>{{ tab.text }}</span>
|
||||
<gl-badge
|
||||
v-if="shouldShowCountBadge(tab)"
|
||||
size="sm"
|
||||
class="gl-tab-counter-badge"
|
||||
data-testid="tab-counter-badge"
|
||||
>{{ numberToMetricPrefix(tabCount(tab)) }}</gl-badge
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<tab-view
|
||||
v-if="tab.query"
|
||||
:tab="tab"
|
||||
:start-cursor="startCursor"
|
||||
:end-cursor="endCursor"
|
||||
:sort="sort"
|
||||
:filters="filters"
|
||||
:timestamp-type="timestampType"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
<template v-else>{{ tab.text }}</template>
|
||||
</gl-tab>
|
||||
|
||||
<template #tabs-end>
|
||||
<li class="gl-w-full">
|
||||
<filtered-search-and-sort
|
||||
class="gl-border-b-0"
|
||||
:filtered-search-namespace="$options.filteredSearchAndSort.namespace"
|
||||
:filtered-search-tokens="filteredSearchTokens"
|
||||
:filtered-search-term-key="$options.filteredSearchAndSort.searchTermKey"
|
||||
:filtered-search-recent-searches-storage-key="
|
||||
$options.filteredSearchAndSort.recentSearchesStorageKey
|
||||
"
|
||||
:filtered-search-query="$route.query"
|
||||
:is-ascending="isAscending"
|
||||
:sort-options="$options.filteredSearchAndSort.sortOptions"
|
||||
:active-sort-option="activeSortOption"
|
||||
@filter="onFilter"
|
||||
@sort-direction-change="onSortDirectionChange"
|
||||
@sort-by-change="onSortByChange"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</gl-tabs>
|
||||
<tabs-with-list />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -95,5 +95,3 @@ export const CUSTOM_DASHBOARD_ROUTE_NAMES = [
|
|||
|
||||
export const FILTERED_SEARCH_NAMESPACE = 'dashboard';
|
||||
export const FILTERED_SEARCH_TERM_KEY = 'name';
|
||||
export const FILTERED_SEARCH_TOKEN_LANGUAGE = 'language';
|
||||
export const FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL = 'min_access_level';
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
isEditing: false,
|
||||
isUpdating: false,
|
||||
workItem: {},
|
||||
};
|
||||
},
|
||||
|
|
@ -241,9 +242,11 @@ export default {
|
|||
this.isEditing = true;
|
||||
updateDraft(this.autosaveKey, this.note.body);
|
||||
},
|
||||
async updateNote({ commentText }) {
|
||||
async updateNote({ commentText, executeOptimisticResponse = true }) {
|
||||
try {
|
||||
this.isEditing = false;
|
||||
this.isUpdating = true;
|
||||
|
||||
await this.$apollo.mutate({
|
||||
mutation: updateWorkItemNoteMutation,
|
||||
variables: {
|
||||
|
|
@ -252,15 +255,18 @@ export default {
|
|||
body: commentText,
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
updateNote: {
|
||||
errors: [],
|
||||
note: {
|
||||
...this.note,
|
||||
bodyHtml: renderMarkdown(commentText),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Ignore this when toggling checkbox https://gitlab.com/gitlab-org/gitlab/-/issues/521723
|
||||
optimisticResponse: executeOptimisticResponse
|
||||
? {
|
||||
updateNote: {
|
||||
errors: [],
|
||||
note: {
|
||||
...this.note,
|
||||
bodyHtml: renderMarkdown(commentText),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
clearDraft(this.autosaveKey);
|
||||
} catch (error) {
|
||||
|
|
@ -268,6 +274,8 @@ export default {
|
|||
this.isEditing = true;
|
||||
this.$emit('error', __('Something went wrong when updating a comment. Please try again'));
|
||||
Sentry.captureException(error);
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
getNewAssigneesAndWidget() {
|
||||
|
|
@ -428,7 +436,13 @@ export default {
|
|||
@submitForm="updateNote"
|
||||
/>
|
||||
<div v-else class="timeline-discussion-body">
|
||||
<note-body ref="noteBody" :note="note" :has-replies="hasReplies" />
|
||||
<note-body
|
||||
ref="noteBody"
|
||||
:note="note"
|
||||
:has-replies="hasReplies"
|
||||
:is-updating="isUpdating"
|
||||
@updateNote="updateNote"
|
||||
/>
|
||||
</div>
|
||||
<edited-at
|
||||
v-if="note.lastEditedBy && !isEditing"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<script>
|
||||
import SafeHtml from '~/vue_shared/directives/safe_html';
|
||||
import { renderGFM } from '~/behaviors/markdown/render_gfm';
|
||||
import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils';
|
||||
|
||||
const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
|
||||
|
||||
export default {
|
||||
name: 'WorkItemNoteBody',
|
||||
|
|
@ -17,6 +20,11 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isUpdating: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'note.bodyHtml': {
|
||||
|
|
@ -27,6 +35,12 @@ export default {
|
|||
}
|
||||
await this.$nextTick();
|
||||
this.renderGFM();
|
||||
this.disableCheckboxes(false);
|
||||
},
|
||||
},
|
||||
isUpdating: {
|
||||
handler(isUpdating) {
|
||||
this.disableCheckboxes(isUpdating);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -35,6 +49,32 @@ export default {
|
|||
renderGFM(this.$refs['note-body']);
|
||||
gl?.lazyLoader?.searchLazyImages();
|
||||
},
|
||||
disableCheckboxes(disabled) {
|
||||
this.$el.querySelectorAll('.task-list-item-checkbox').forEach((checkbox) => {
|
||||
checkbox.disabled = disabled; // eslint-disable-line no-param-reassign
|
||||
});
|
||||
},
|
||||
toggleCheckboxes(event) {
|
||||
const { target } = event;
|
||||
|
||||
if (!isCheckbox(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sourcepos } = target.parentElement.dataset;
|
||||
|
||||
if (!sourcepos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentText = toggleMarkCheckboxes({
|
||||
rawMarkdown: this.note.body,
|
||||
checkboxChecked: target.checked,
|
||||
sourcepos,
|
||||
});
|
||||
|
||||
this.$emit('updateNote', { commentText, executeOptimisticResponse: false });
|
||||
},
|
||||
},
|
||||
safeHtmlConfig: {
|
||||
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
|
||||
|
|
@ -48,6 +88,7 @@ export default {
|
|||
v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml"
|
||||
class="note-text md"
|
||||
data-testid="work-item-note-body"
|
||||
@change="toggleCheckboxes"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ module LooksAhead
|
|||
|
||||
def node_selection(selection = lookahead)
|
||||
return selection unless selection&.selected?
|
||||
return selection if selection.field.type.list?
|
||||
return selection.selection(:edges).selection(:node) if selection.selects?(:edges)
|
||||
|
||||
# Will return a NullSelection object if :nodes is not a selection. This
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ module Ci
|
|||
project_id: project.id,
|
||||
default_branch: project.default_branch,
|
||||
settings_link: project_settings_ci_cd_path(project),
|
||||
schedules_path: pipeline_schedules_path(project)
|
||||
schedules_path: pipeline_schedules_path(project),
|
||||
user_role: current_user ? project.team.human_max_access(current_user.id) : nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ module Ci
|
|||
project_refs_endpoint: refs_project_path(project, sort: 'updated_desc'),
|
||||
settings_link: project_settings_ci_cd_path(project),
|
||||
max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT,
|
||||
is_maintainer: can?(current_user, :maintainer_access, project)
|
||||
user_role: project.team.human_max_access(current_user&.id)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -551,7 +551,7 @@ module Ci
|
|||
def registration_available?
|
||||
authenticated_user_registration_type? &&
|
||||
created_at > REGISTRATION_AVAILABILITY_TIME.ago &&
|
||||
!runner_managers.any?
|
||||
started_creation_state?
|
||||
end
|
||||
|
||||
# CI_JOB_JWT_V2 that uses this method is deprecated
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
# Warning: gitlab_base.SelfReferential
|
||||
# Checks for wordy, self-referential phrases.
|
||||
#
|
||||
# For a list of all options, see https://vale.sh/docs/topics/styles/
|
||||
extends: existence
|
||||
message: "Rewrite '%s'. Talk directly about the feature or purpose instead."
|
||||
ignorecase: true
|
||||
nonword: true
|
||||
vocab: false
|
||||
level: warning
|
||||
link: https://docs.gitlab.com/development/documentation/styleguide/#self-referential-writing
|
||||
tokens:
|
||||
- This (page|guide) (builds|contains|covers|describes|documents|explains|guides|lists|offers|provides|shows)
|
||||
|
|
@ -20,6 +20,7 @@ To choose the packages you want to synchronize with the GitLab Package Metadata
|
|||
|
||||
1. On the left sidebar, at the bottom, select **Admin**.
|
||||
1. Select **Settings > Security and compliance**.
|
||||
1. Expand **License Compliance**.
|
||||
1. In **Package registry metadata to sync**, select or clear checkboxes for the
|
||||
package registries that you want to sync.
|
||||
1. Select **Save changes**.
|
||||
|
|
|
|||
|
|
@ -26884,7 +26884,7 @@ four standard [pagination arguments](#pagination-arguments):
|
|||
| <a id="groupcustomfieldsactive"></a>`active` | [`Boolean`](#boolean) | Filter for active fields. If `false`, excludes active fields. If `true`, returns only active fields. |
|
||||
| <a id="groupcustomfieldsfieldtype"></a>`fieldType` | [`CustomFieldType`](#customfieldtype) | Filter for selected field type. |
|
||||
| <a id="groupcustomfieldssearch"></a>`search` | [`String`](#string) | Search query for custom field name. |
|
||||
| <a id="groupcustomfieldsworkitemtypeids"></a>`workItemTypeIds` | [`[WorkItemsTypeID!]`](#workitemstypeid) | Filter custom fields associated to the given work item types. If empty, returns custom fields not associated to any work item type. |
|
||||
| <a id="groupcustomfieldsworkitemtypeids"></a>`workItemTypeIds` | [`[WorkItemsTypeID!]`](#workitemstypeid) | Filter custom fields associated to any of the given work item types. If empty, returns custom fields not associated to any work item type. |
|
||||
|
||||
##### `Group.customizableDashboardVisualizations`
|
||||
|
||||
|
|
@ -39918,6 +39918,17 @@ Represents an assignees widget definition.
|
|||
| <a id="workitemwidgetdefinitionassigneescaninvitemembers"></a>`canInviteMembers` | [`Boolean!`](#boolean) | Indicates whether the current user can invite members to the work item's parent. |
|
||||
| <a id="workitemwidgetdefinitionassigneestype"></a>`type` | [`WorkItemWidgetType!`](#workitemwidgettype) | Widget type. |
|
||||
|
||||
### `WorkItemWidgetDefinitionCustomFields`
|
||||
|
||||
Represents a custom fields widget definition.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="workitemwidgetdefinitioncustomfieldscustomfields"></a>`customFields` {{< icon name="warning-solid" >}} | [`[CustomField!]`](#customfield) | **Introduced** in GitLab 17.10. **Status**: Experiment. Custom fields available for the work item type. Available only when feature flag `custom_fields_feature` is enabled. |
|
||||
| <a id="workitemwidgetdefinitioncustomfieldstype"></a>`type` | [`WorkItemWidgetType!`](#workitemwidgettype) | Widget type. |
|
||||
|
||||
### `WorkItemWidgetDefinitionCustomStatus`
|
||||
|
||||
Represents an Custom Status widget definition.
|
||||
|
|
@ -46631,6 +46642,7 @@ Implementations:
|
|||
Implementations:
|
||||
|
||||
- [`WorkItemWidgetDefinitionAssignees`](#workitemwidgetdefinitionassignees)
|
||||
- [`WorkItemWidgetDefinitionCustomFields`](#workitemwidgetdefinitioncustomfields)
|
||||
- [`WorkItemWidgetDefinitionCustomStatus`](#workitemwidgetdefinitioncustomstatus)
|
||||
- [`WorkItemWidgetDefinitionGeneric`](#workitemwidgetdefinitiongeneric)
|
||||
- [`WorkItemWidgetDefinitionHierarchy`](#workitemwidgetdefinitionhierarchy)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ title: Groups API
|
|||
|
||||
{{< /details >}}
|
||||
|
||||
Interact with [groups](../user/group/_index.md) by using the REST API.
|
||||
Use the Groups API to list and manage GitLab groups through REST API calls. For more information, see [groups](../user/group/_index.md).
|
||||
|
||||
The fields returned in responses vary based on the [permissions](../user/permissions.md) of the authenticated user.
|
||||
Endpoint responses might vary based on the [permissions](../user/permissions.md) of the authenticated user in the group.
|
||||
|
||||
## Get a single group
|
||||
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ Use:
|
|||
|
||||
- GitLab has different types of pipelines to help address your development needs.
|
||||
|
||||
Tested in [`SelfReferential.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab_base/SelfReferential.yml).
|
||||
|
||||
### Capitalization
|
||||
|
||||
As a company, we tend toward lowercase.
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ Use **offline environment** to describe installations that have physical barrier
|
|||
|
||||
## allow, enable
|
||||
|
||||
Try to avoid **allow** and **enable**, unless you are talking about security-related features.
|
||||
Try to avoid **allow** and **enable**, unless you are talking about security-related features or the
|
||||
state of a feature flag.
|
||||
|
||||
Use:
|
||||
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ The table below lists current exit codes and their meanings:
|
|||
|167 | `gitlab.com` overloaded |
|
||||
|168 | gRPC resource exhausted |
|
||||
|169 | SQL query limit exceeded |
|
||||
|170 | SQL table is write protected |
|
||||
|
||||
This list can be expanded as new failure patterns emerge. To avoid conflicts with standard Bash exit codes, new custom codes must be 160 or higher.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
stage: Application Security Testing
|
||||
group: Static Analysis
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
title: Analyze
|
||||
---
|
||||
|
||||
Analysis is the third phase of the vulnerability management lifecycle: detect, triage, analyze,
|
||||
remediate.
|
||||
|
||||
Analysis is the process of evaluating the details of a vulnerability to determine if it can and
|
||||
should be remediated. Vulnerabilities can be triaged in bulk but analysis must be done individually.
|
||||
Prioritize analysis of each vulnerability according to its severity and associated risk. As part of
|
||||
a risk management framework, analysis helps ensure resources are applied where they're most
|
||||
effective.
|
||||
|
||||
Use the data contained in the [security dashboard](../security_dashboard/_index.md) and the
|
||||
[vulnerability report](../vulnerability_report/_index.md) to help narrow your focus. According the
|
||||
vulnerability management lifecycle, only confirmed vulnerabilities need to be analyzed. To focus on
|
||||
only these, use the following filter criteria in the vulnerability report:
|
||||
|
||||
- **Status:** Confirmed
|
||||
|
||||
Use additional vulnerability report filters to narrow your focus further. For more details, see
|
||||
[Analysis strategies](#analysis-strategies).
|
||||
|
||||
## Risk analysis
|
||||
|
||||
You should conduct vulnerability analysis according to a risk assessment framework. If you're not
|
||||
already using a risk assessment framework, consider the following:
|
||||
|
||||
- SANS Institute [Vulnerability Management Framework](https://www.sans.org/blog/the-vulnerability-assessment-framework/)
|
||||
- OWASP [Threat and Safeguard Matrix (TaSM)](https://owasp.org/www-project-threat-and-safeguard-matrix/)
|
||||
|
||||
Calculating the risk score of a vulnerability depends on criteria that are specific to your
|
||||
organization. A basic risk score formula is:
|
||||
|
||||
Risk = Likelihood x Impact
|
||||
|
||||
Both the likelihood and impact numbers vary according to the vulnerability and your environment.
|
||||
Determining these numbers and calculating a risk score may require some information not available in
|
||||
GitLab. Instead, you must calculate these according to your risk management framework. After
|
||||
calculating these, record them in the issue you raised for the vulnerability.
|
||||
|
||||
Generally, the amount of time and effort spent on a vulnerability should be proportional to its
|
||||
risk. For example, you might choose to analyze only vulnerabilities of critical and high risk and
|
||||
dismiss the rest. You should make this decision according to your risk threshold for
|
||||
vulnerabilities.
|
||||
|
||||
## Analysis strategies
|
||||
|
||||
Use a risk assessment framework to help guide your vulnerability analysis process. The following
|
||||
strategies may also help.
|
||||
|
||||
### Prioritize vulnerabilities of highest severity
|
||||
|
||||
To help identify vulnerabilities of highest severity:
|
||||
|
||||
- If you've not already done this in the triage phase, use the
|
||||
[Vulnerability Prioritizer CI/CD component](../vulnerabilities/risk_assessment_data.md#vulnerability-prioritizer)
|
||||
to help prioritize vulnerabilities for analysis.
|
||||
- For each group, use the following filter criteria in the vulnerability report to prioritize
|
||||
analysis of vulnerabilities by severity:
|
||||
- **Status:** Confirmed
|
||||
- **Activity:** Still detected
|
||||
- **Group by:** Severity
|
||||
- Prioritize vulnerability triage on your highest-priority projects - for example, applications
|
||||
deployed to customers.
|
||||
|
||||
### Prioritize vulnerabilities that have a solution available
|
||||
|
||||
Some vulnerabilities have a solution available, for example "Upgrade from version 13.2 to 13.8".
|
||||
This reduces the time taken to analyze and remediate these vulnerabilities. Some solutions are
|
||||
available only if GitLab Duo is enabled.
|
||||
|
||||
Use the following filter criteria in the vulnerability report to identify vulnerabilities that have
|
||||
a solution available.
|
||||
|
||||
- For vulnerabilities detected by SBOM scanning, use the criteria:
|
||||
- **Status:** Confirmed
|
||||
- **Activity:** Has a solution
|
||||
- For vulnerabilities detected by SAST, use the criteria:
|
||||
- **Status:** Confirmed
|
||||
- **Activity:** Vulnerability Resolution available
|
||||
|
||||
## Vulnerability details and action
|
||||
|
||||
Every vulnerability has a [vulnerability page](../vulnerabilities/_index.md) which contains details
|
||||
including when it was detected, how it was detected, its severity rating, and a complete log. Use
|
||||
this information to help analyze a vulnerability.
|
||||
|
||||
The following tips may also help you analyze a vulnerability:
|
||||
|
||||
- Use [GitLab Duo Vulnerability Explanation](../vulnerabilities/_index.md#explaining-a-vulnerability)
|
||||
to help explain the vulnerability and suggest a remediation. Available only for vulnerabilities
|
||||
detected by SAST.
|
||||
- Use [security training](../vulnerabilities/_index.md#view-security-training-for-a-vulnerability)
|
||||
provided by third-party training vendors to help understand the nature of a specific
|
||||
vulnerability.
|
||||
|
||||
After analyzing each confirmed vulnerability you should either:
|
||||
|
||||
- Leave its status as **Confirmed** if you decide it should be remediated.
|
||||
- Change its status to **Dismissed** if you decide it should not be remediated.
|
||||
|
||||
If you confirm a vulnerability:
|
||||
|
||||
1. [Create an issue](../vulnerabilities/_index.md#create-a-gitlab-issue-for-a-vulnerability) to
|
||||
track, document, and manage the remediation work.
|
||||
1. Continue to the remediation phase of the vulnerability management lifecycle.
|
||||
|
||||
If you dismiss a vulnerability you must provide a brief comment that states why you've dismissed
|
||||
it. Dismissed vulnerabilities are ignored if detected in subsequent scans. Vulnerability records
|
||||
are permanent but you can change a vulnerability's status at any time.
|
||||
|
|
@ -49,11 +49,12 @@ Workflow:
|
|||
|
||||
## Prerequisites
|
||||
|
||||
To use Workflow:
|
||||
Before you can use Duo Workflow, you must:
|
||||
|
||||
- You must have [completed setup](set_up.md).
|
||||
- You must have an account on GitLab.com.
|
||||
- You must have a project that meets the following requirements:
|
||||
- [Install Visual Studio Code](https://code.visualstudio.com/download) (VS Code).
|
||||
- [Set up the GitLab Workflow extension for VS Code](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow#setup). Minimum version 5.16.0.
|
||||
- Have an account on GitLab.com.
|
||||
- Have a project that meets the following requirements:
|
||||
- The project is on GitLab.com.
|
||||
- You have at least the Developer role.
|
||||
- The project belongs to a [group namespace](../namespace/_index.md) with an Ultimate subscription.
|
||||
|
|
@ -62,6 +63,8 @@ To use Workflow:
|
|||
- The repository you want to work with should be small or medium-sized.
|
||||
Workflow can be slow or fail for large repositories.
|
||||
|
||||
To isolate GitLab Duo Workflow in a Docker container, you must complete the [Docker setup](docker_set_up.md). This is not the preferred method to run Duo Workflow.
|
||||
|
||||
## Use Workflow in VS Code
|
||||
|
||||
To use Workflow in VS Code:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
stage: AI-powered
|
||||
group: Duo Workflow
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
title: Set up Docker for GitLab Duo Workflow
|
||||
---
|
||||
|
||||
{{< details >}}
|
||||
|
||||
- Tier: Ultimate
|
||||
- Offering: GitLab.com
|
||||
- Status: Experiment
|
||||
|
||||
{{< /details >}}
|
||||
|
||||
{{< alert type="warning" >}}
|
||||
|
||||
This feature is considered [experimental](../../policy/development_stages_support.md) and is not intended for customer usage outside of initial design partners. We expect major changes to this feature.
|
||||
|
||||
{{< /alert >}}
|
||||
|
||||
Use the following guide to set up GitLab Duo Workflow with Docker.
|
||||
|
||||
This is not the preferred method to run Workflow.
|
||||
|
||||
## Install Docker and set the socket file path
|
||||
|
||||
Workflow needs an execution platform like Docker where it can execute arbitrary code,
|
||||
read and write files, and make API calls to GitLab.
|
||||
|
||||
If you are on macOS or Linux, you can either:
|
||||
|
||||
- Use the [automated setup script](docker_set_up.md#automated-setup). Recommended.
|
||||
- Follow the [manual setup](docker_set_up.md#manual-setup).
|
||||
|
||||
If you are not on macOS or Linux, follow the [manual setup](docker_set_up.md#manual-setup).
|
||||
|
||||
### Automated setup
|
||||
|
||||
The automated setup script:
|
||||
|
||||
- Installs [Docker](https://formulae.brew.sh/formula/docker) and [Colima](https://github.com/abiosoft/colima).
|
||||
- Sets Docker socket path in VS Code settings.
|
||||
|
||||
You can run the script with the `--dry-run` flag to check the dependencies
|
||||
that get installed with the script.
|
||||
|
||||
1. Download the [setup script](https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/blob/main/scripts/install-runtime).
|
||||
|
||||
```shell
|
||||
wget https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/raw/main/scripts/install-runtime
|
||||
```
|
||||
|
||||
1. Run the script.
|
||||
|
||||
```shell
|
||||
chmod +x install-runtime
|
||||
./install-runtime
|
||||
```
|
||||
|
||||
### Manual setup
|
||||
|
||||
1. Install a Docker container engine, such as [Rancher Desktop](https://docs.rancherdesktop.io/getting-started/installation/).
|
||||
1. Set the Docker socket path and Docker settings in VS Code:
|
||||
1. Open VS Code, then open its settings:
|
||||
- On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
|
||||
- On Windows and Linux: <kbd>Ctrl</kbd> + <kbd>,</kbd>
|
||||
1. In the upper-right corner, select the **Open Settings (JSON)** icon.
|
||||
1. Add the Docker socket path setting `gitlab.duoWorkflow.dockerSocket`, according to your container manager, and save your settings file.
|
||||
Some examples for common container managers on macOS, where you would replace `<your_user>` with your user's home folder:
|
||||
|
||||
- Rancher Desktop:
|
||||
|
||||
```json
|
||||
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.rd/docker.sock",
|
||||
"gitlab.duoWorkflow.useDocker": true,
|
||||
```
|
||||
|
||||
- Colima:
|
||||
|
||||
```json
|
||||
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.colima/default/docker.sock",
|
||||
"gitlab.duoWorkflow.useDocker": true,
|
||||
```
|
||||
|
|
@ -1,90 +1,13 @@
|
|||
---
|
||||
stage: AI-powered
|
||||
group: Duo Workflow
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
title: Set up GitLab Duo Workflow
|
||||
redirect_to: 'docker_set_up.md'
|
||||
remove_date: '2025-06-05'
|
||||
---
|
||||
|
||||
{{< details >}}
|
||||
<!-- markdownlint-disable -->
|
||||
|
||||
- Tier: Ultimate
|
||||
- Offering: GitLab.com
|
||||
- Status: Experiment
|
||||
This document was moved to [another location](docker_set_up.md).
|
||||
|
||||
{{< /details >}}
|
||||
|
||||
{{< alert type="warning" >}}
|
||||
|
||||
This feature is considered [experimental](../../policy/development_stages_support.md) and is not intended for customer usage outside of initial design partners. We expect major changes to this feature.
|
||||
|
||||
{{< /alert >}}
|
||||
|
||||
Use the following guide to set up GitLab Duo Workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you can set up Workflow:
|
||||
|
||||
- [Install Visual Studio Code](https://code.visualstudio.com/download) (VS Code).
|
||||
- [Install and set up](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow#setup)
|
||||
the GitLab Workflow extension for VS Code. Minimum version 5.16.0.
|
||||
|
||||
Then, complete the following steps.
|
||||
|
||||
## Install Docker and set the socket file path
|
||||
|
||||
Workflow needs an execution platform like Docker where it can execute arbitrary code,
|
||||
read and write files, and make API calls to GitLab.
|
||||
|
||||
If you are on macOS or Linux, you can either:
|
||||
|
||||
- Use the [automated setup script](set_up.md#automated-setup). Recommended.
|
||||
- Follow the [manual setup](set_up.md#manual-setup).
|
||||
|
||||
If you are not on macOS or Linux, follow the [manual setup](set_up.md#manual-setup).
|
||||
|
||||
### Automated setup
|
||||
|
||||
The automated setup script:
|
||||
|
||||
- Installs [Docker](https://formulae.brew.sh/formula/docker) and [Colima](https://github.com/abiosoft/colima).
|
||||
- Sets Docker socket path in VS Code settings.
|
||||
|
||||
You can run the script with the `--dry-run` flag to check the dependencies
|
||||
that get installed with the script.
|
||||
|
||||
1. Download the [setup script](https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/blob/main/scripts/install-runtime).
|
||||
|
||||
```shell
|
||||
wget https://gitlab.com/gitlab-org/duo-workflow/duo-workflow-executor/-/raw/main/scripts/install-runtime
|
||||
```
|
||||
|
||||
1. Run the script.
|
||||
|
||||
```shell
|
||||
chmod +x install-runtime
|
||||
./install-runtime
|
||||
```
|
||||
|
||||
### Manual setup
|
||||
|
||||
1. Install a Docker container engine, such as [Rancher Desktop](https://docs.rancherdesktop.io/getting-started/installation/).
|
||||
1. Set the Docker socket path in VS Code:
|
||||
1. Open VS Code, then open its settings:
|
||||
- On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
|
||||
- On Windows and Linux: <kbd>Ctrl</kbd> + <kbd>,</kbd>
|
||||
1. In the upper-right corner, select the **Open Settings (JSON)** icon.
|
||||
1. Add the Docker socket path setting `gitlab.duoWorkflow.dockerSocket`, according to your container manager, and save your settings file.
|
||||
Some examples for common container managers on macOS, where you would replace `<your_user>` with your user's home folder:
|
||||
|
||||
- Rancher Desktop:
|
||||
|
||||
```json
|
||||
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.rd/docker.sock",
|
||||
```
|
||||
|
||||
- Colima:
|
||||
|
||||
```json
|
||||
"gitlab.duoWorkflow.dockerSocket": "/Users/<your_user>/.colima/default/docker.sock",
|
||||
```
|
||||
<!-- This redirect file can be deleted after <2025-06-05>. -->
|
||||
<!-- Redirects that point to other docs in the same project expire in three months. -->
|
||||
<!-- Redirects that point to docs in a different project or site (link is not relative and starts with `https:`) expire in one year. -->
|
||||
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html -->
|
||||
|
|
|
|||
|
|
@ -19,25 +19,14 @@ This feature is considered [experimental](../../policy/development_stages_suppor
|
|||
|
||||
{{< /alert >}}
|
||||
|
||||
## General guidance
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Ensure that you have the latest version of the GitLab Workflow extension.
|
||||
1. Ensure that the project you want to use it with meets the [prerequisites](set_up.md#prerequisites).
|
||||
1. Ensure that the project you want to use it with meets the [prerequisites](_index.md#prerequisites).
|
||||
1. Ensure that the folder you opened in VS Code has a Git repository for your GitLab project.
|
||||
1. Ensure that you've checked out the branch for the code you'd like to change.
|
||||
1. Check your Docker configuration:
|
||||
1. [Install Docker and set the socket file path](set_up.md#install-docker-and-set-the-socket-file-path).
|
||||
1. Restart your container manager. For example, if you use Colima, `colima restart`.
|
||||
1. Pull the base Docker image:
|
||||
|
||||
```shell
|
||||
docker pull registry.gitlab.com/gitlab-org/duo-workflow/default-docker-image/workflow-generic-image:v0.0.4
|
||||
```
|
||||
|
||||
1. For permission issues, ensure your operating system user has the necessary Docker permissions.
|
||||
1. Verify Docker's internet connectivity by executing the command `docker image pull redhat/ubi8`.
|
||||
If this does not work, the DNS configuration of Colima might be at fault.
|
||||
Edit the DNS setting in `~/.colima/default/colima.yaml` to `dns: [1.1.1.1]` and then restart Colima with `colima restart`.
|
||||
1. Check local debugging logs:
|
||||
1. For more output in the logs, open the settings:
|
||||
1. On macOS: <kbd>Cmd</kbd> + <kbd>,</kbd>
|
||||
|
|
@ -46,7 +35,23 @@ If you encounter issues:
|
|||
1. Check the language server logs:
|
||||
1. To open the logs in VS Code, select **View** > **Output**. In the output panel at the bottom, in the top-right corner, select **GitLab Workflow** or **GitLab Language Server** from the list.
|
||||
1. Review for errors, warnings, connection issues, or authentication problems.
|
||||
1. Check the executor logs:
|
||||
1. Use `docker ps -a | grep duo-workflow` to get the list of Workflow containers and their ids.
|
||||
1. Use `docker logs <container_id>` to view the logs for the specific container.
|
||||
1. Examine the [Workflow Service production LangSmith trace](https://smith.langchain.com/o/477de7ad-583e-47b6-a1c4-c4a0300e7aca/projects/p/5409132b-2cf3-4df8-9f14-70204f90ed9b?timeModel=%7B%22duration%22%3A%227d%22%7D&tab=0).
|
||||
|
||||
## Docker guidance
|
||||
|
||||
If you encounter issues with your Docker setup for Duo Workflow, try the following steps.
|
||||
|
||||
1. [Install Docker and set the socket file path](docker_set_up.md#install-docker-and-set-the-socket-file-path).
|
||||
1. Restart your container manager. For example, if you use Colima, `colima restart`.
|
||||
1. Pull the base Docker image:
|
||||
|
||||
```shell
|
||||
docker pull registry.gitlab.com/gitlab-org/duo-workflow/default-docker-image/workflow-generic-image:v0.0.4
|
||||
```
|
||||
|
||||
1. For permission issues, ensure your operating system user has the necessary Docker permissions.
|
||||
1. Verify Docker's internet connectivity by executing the command `docker image pull redhat/ubi8`.
|
||||
If this does not work, the DNS configuration of Colima might be at fault.
|
||||
Edit the DNS setting in `~/.colima/default/colima.yaml` to `dns: [1.1.1.1]` and then restart Colima with `colima restart`.
|
||||
1. Check the executor logs:
|
||||
1. Use `docker ps -a | grep duo-workflow` to get the list of Workflow containers and their ids.
|
||||
1. Use `docker logs <container_id>` to view the logs for the specific container.
|
||||
|
|
|
|||
|
|
@ -101,6 +101,16 @@ You can mention `@GitLabDuo` in comments to interact with GitLab Duo on your mer
|
|||
|
||||
Interactions with GitLab Duo can help to improve the suggestions and feedback as you work to improve your merge request.
|
||||
|
||||
### Automatic reviews from GitLab Duo
|
||||
|
||||
To enable `@GitLabDuo` to automatically review merge requests, edit your
|
||||
[merge request template](../../../user/project/description_templates.md#create-a-merge-request-template)
|
||||
and add the line `/assign_reviewer @GitLabDuo`. Add this line to your default template,
|
||||
and any other templates in your project where you want `@GitLabDuo` to perform a review.
|
||||
|
||||
Additional settings and configuration are planned. To that work, see
|
||||
[issue 506537](https://gitlab.com/gitlab-org/gitlab/-/issues/506537).
|
||||
|
||||
## Summarize a code review
|
||||
|
||||
{{< details >}}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
|
||||
module Gitlab
|
||||
class Blame
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
IGNORE_REVS_FILE_NAME = '.git-blame-ignore-revs'
|
||||
|
||||
attr_accessor :blob, :commit, :range
|
||||
|
||||
def initialize(blob, commit, range: nil)
|
||||
def initialize(blob, commit, range: nil, ignore_revs: nil)
|
||||
@blob = blob
|
||||
@commit = commit
|
||||
@range = range
|
||||
@ignore_revs = ignore_revs
|
||||
end
|
||||
|
||||
def first_line
|
||||
|
|
@ -41,9 +46,23 @@ module Gitlab
|
|||
|
||||
private
|
||||
|
||||
attr_reader :ignore_revs
|
||||
|
||||
def blame
|
||||
@blame ||= Gitlab::Git::Blame.new(repository, @commit.id, @blob.path, range: range)
|
||||
Gitlab::Git::Blame.new(
|
||||
repository,
|
||||
@commit.id,
|
||||
@blob.path,
|
||||
range: range,
|
||||
ignore_revisions_blob: ignore_revs ? default_ignore_revisions_ref : nil
|
||||
)
|
||||
end
|
||||
strong_memoize_attr :blame
|
||||
|
||||
def default_ignore_revisions_ref
|
||||
"refs/heads/#{project.default_branch}:#{IGNORE_REVS_FILE_NAME}"
|
||||
end
|
||||
strong_memoize_attr :default_ignore_revisions_ref
|
||||
|
||||
def highlighted_lines
|
||||
@blob.load_all_data!
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ module Gitlab
|
|||
|
||||
attr_reader :lines, :blames, :range
|
||||
|
||||
def initialize(repository, sha, path, range: nil)
|
||||
def initialize(repository, sha, path, range: nil, ignore_revisions_blob: nil)
|
||||
@repo = repository
|
||||
@sha = sha
|
||||
@path = path
|
||||
@range = range
|
||||
@lines = []
|
||||
@ignore_revisions_blob = ignore_revisions_blob
|
||||
@blames = load_blame
|
||||
end
|
||||
|
||||
|
|
@ -27,6 +28,8 @@ module Gitlab
|
|||
|
||||
private
|
||||
|
||||
attr_reader :ignore_revisions_blob
|
||||
|
||||
def range_spec
|
||||
"#{range.first},#{range.last}" if range
|
||||
end
|
||||
|
|
@ -37,7 +40,8 @@ module Gitlab
|
|||
end
|
||||
|
||||
def fetch_raw_blame
|
||||
@repo.gitaly_commit_client.raw_blame(@sha, @path, range: range_spec)
|
||||
@repo.gitaly_commit_client.raw_blame(@sha, @path, range: range_spec,
|
||||
ignore_revisions_blob: ignore_revisions_blob)
|
||||
rescue ArgumentError
|
||||
# Return an empty result when the blame range is out-of-range or path is not found
|
||||
""
|
||||
|
|
|
|||
|
|
@ -3767,6 +3767,15 @@ msgstr ""
|
|||
msgid "Adds this %{issuable_type} as related to the %{issuable_type} it was created from"
|
||||
msgstr ""
|
||||
|
||||
msgid "AdherenceReport|Have questions or thoughts on the new improvements we made? %{linkStart}Please provide feedback on your experience%{linkEnd}."
|
||||
msgstr ""
|
||||
|
||||
msgid "AdherenceReport|Learn more about the changes in our %{linkStart}documentation%{linkEnd}."
|
||||
msgstr ""
|
||||
|
||||
msgid "AdherenceReport|We've updated the Adherence Report with new features to enhance your compliance workflow."
|
||||
msgstr ""
|
||||
|
||||
msgid "Adjust how frequently the GitLab UI polls for updates."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -33126,6 +33135,9 @@ msgstr ""
|
|||
msgid "Jobs|Stage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Jobs|Status for job %{id}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Jobs|There was a problem fetching the failed jobs."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
"@gitlab/fonts": "^1.3.0",
|
||||
"@gitlab/query-language-rust": "0.4.0",
|
||||
"@gitlab/svgs": "3.123.0",
|
||||
"@gitlab/ui": "108.10.0",
|
||||
"@gitlab/ui": "109.0.0",
|
||||
"@gitlab/vue-router-vue3": "npm:vue-router@4.5.0",
|
||||
"@gitlab/vuex-vue3": "npm:vuex@4.1.0",
|
||||
"@gitlab/web-ide": "^0.0.1-dev-20250211142744",
|
||||
|
|
|
|||
|
|
@ -543,7 +543,7 @@ function log_disk_usage() {
|
|||
|
||||
# all functions below are for customizing CI job exit code
|
||||
function run_with_custom_exit_code() {
|
||||
set +e # temprorarily disable exit on error to prevent premature exit
|
||||
set +e # temporarily disable exit on error to prevent premature exit
|
||||
|
||||
# runs command passed in as argument, save standard error and standard output
|
||||
output=$("$@" 2>&1)
|
||||
|
|
@ -600,6 +600,9 @@ function find_custom_exit_code() {
|
|||
-e "500 Internal Server Error" \
|
||||
-e "Internal Server Error 500" \
|
||||
-e "502 Bad Gateway" \
|
||||
-e "502 Server Error" \
|
||||
-e "502 \"Bad Gateway\"" \
|
||||
-e "status code: 502" \
|
||||
-e "503 Service Unavailable" "$trace_file"; then
|
||||
echoerr "Detected 5XX error. Changing exit code to 161."
|
||||
exit_code=161
|
||||
|
|
@ -648,6 +651,10 @@ function find_custom_exit_code() {
|
|||
echoerr "Detected Gitlab::QueryLimiting::Transaction::ThresholdExceededError. Changing exit code to 169."
|
||||
exit_code=169
|
||||
|
||||
elif grep -i -q -e \
|
||||
"is write protected within this Gitlab database" "$trace_file"; then
|
||||
echoerr "Detected SQL table is write-protected error in job trace. Changing exit code to 170."
|
||||
exit_code=170
|
||||
else
|
||||
echoinfo "not changing exit code"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -43,11 +43,15 @@ RSpec.describe Admin::RunnersController, feature_category: :fleet_visibility do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#register' do
|
||||
describe '#register', :freeze_time do
|
||||
subject(:register) { get :register, params: { id: new_runner.id } }
|
||||
|
||||
let(:new_runner) do
|
||||
create(:ci_runner, :unregistered, *runner_traits, registration_type: :authenticated_user)
|
||||
end
|
||||
|
||||
context 'when runner can be registered after creation' do
|
||||
let_it_be(:new_runner) { create(:ci_runner, registration_type: :authenticated_user) }
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it 'renders a :register template' do
|
||||
register
|
||||
|
|
@ -58,7 +62,7 @@ RSpec.describe Admin::RunnersController, feature_category: :fleet_visibility do
|
|||
end
|
||||
|
||||
context 'when runner cannot be registered after creation' do
|
||||
let_it_be(:new_runner) { runner }
|
||||
let(:runner_traits) { [:created_after_registration_deadline] }
|
||||
|
||||
it 'returns :not_found' do
|
||||
register
|
||||
|
|
|
|||
|
|
@ -151,16 +151,20 @@ RSpec.describe Groups::RunnersController, feature_category: :fleet_visibility do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#register' do
|
||||
describe '#register', :freeze_time do
|
||||
subject(:register) { get :register, params: { group_id: group, id: new_runner } }
|
||||
|
||||
let(:new_runner) do
|
||||
create(:ci_runner, :unregistered, *runner_traits, :group, groups: [group], registration_type: :authenticated_user)
|
||||
end
|
||||
|
||||
context 'when user is owner' do
|
||||
before_all do
|
||||
group.add_owner(user)
|
||||
end
|
||||
|
||||
context 'when runner can be registered after creation' do
|
||||
let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it 'renders a :register template' do
|
||||
register
|
||||
|
|
@ -171,7 +175,7 @@ RSpec.describe Groups::RunnersController, feature_category: :fleet_visibility do
|
|||
end
|
||||
|
||||
context 'when runner cannot be registered after creation' do
|
||||
let_it_be(:new_runner) { runner }
|
||||
let(:runner_traits) { [:created_after_registration_deadline] }
|
||||
|
||||
it 'returns :not_found' do
|
||||
register
|
||||
|
|
@ -187,7 +191,7 @@ RSpec.describe Groups::RunnersController, feature_category: :fleet_visibility do
|
|||
end
|
||||
|
||||
context 'when runner can be registered after creation' do
|
||||
let_it_be(:new_runner) { create(:ci_runner, :group, groups: [group], registration_type: :authenticated_user) }
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it 'returns :not_found' do
|
||||
register
|
||||
|
|
|
|||
|
|
@ -53,20 +53,24 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
|
|||
end
|
||||
end
|
||||
|
||||
describe '#register' do
|
||||
describe '#register', :freeze_time do
|
||||
subject(:register) do
|
||||
get :register, params: { namespace_id: project.namespace, project_id: project, id: new_runner }
|
||||
end
|
||||
|
||||
let(:new_runner) do
|
||||
create(
|
||||
:ci_runner, :unregistered, *runner_traits, :project, projects: [project], registration_type: :authenticated_user
|
||||
)
|
||||
end
|
||||
|
||||
context 'when user is maintainer' do
|
||||
before_all do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
context 'when runner can be registered after creation' do
|
||||
let_it_be(:new_runner) do
|
||||
create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user)
|
||||
end
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it 'renders a :register template' do
|
||||
register
|
||||
|
|
@ -77,7 +81,7 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
|
|||
end
|
||||
|
||||
context 'when runner cannot be registered after creation' do
|
||||
let_it_be(:new_runner) { runner }
|
||||
let(:runner_traits) { [:created_after_registration_deadline] }
|
||||
|
||||
it 'returns :not_found' do
|
||||
register
|
||||
|
|
@ -93,9 +97,7 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
|
|||
end
|
||||
|
||||
context 'when runner can be registered after creation' do
|
||||
let_it_be(:new_runner) do
|
||||
create(:ci_runner, :project, projects: [project], registration_type: :authenticated_user)
|
||||
end
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it 'returns :not_found' do
|
||||
register
|
||||
|
|
|
|||
|
|
@ -80,6 +80,14 @@ FactoryBot.define do
|
|||
created_at { 0.001.seconds.after(Ci::Runner.stale_deadline) }
|
||||
end
|
||||
|
||||
trait :created_before_registration_deadline do
|
||||
created_at { 0.001.seconds.after(Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago) }
|
||||
end
|
||||
|
||||
trait :created_after_registration_deadline do
|
||||
created_at { Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago }
|
||||
end
|
||||
|
||||
trait :instance do
|
||||
runner_type { :instance_type }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -130,3 +130,14 @@ export const mockPipelineVariablesPermissions = (value) => ({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const minimumRoleResponse = {
|
||||
data: {
|
||||
project: {
|
||||
id: mockId,
|
||||
ciCdSettings: {
|
||||
pipelineVariablesMinimumOverrideRole: 'developer',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ describe('Pipeline Variables Permissions Mixin', () => {
|
|||
const ROLE_NO_ONE = 'no_one_allowed';
|
||||
const ROLE_DEVELOPER = 'developer';
|
||||
const ROLE_MAINTAINER = 'maintainer';
|
||||
const ROLE_OWNER = 'owner';
|
||||
|
||||
const defaultProvide = {
|
||||
userRole: ROLE_DEVELOPER,
|
||||
|
|
@ -67,6 +66,24 @@ describe('Pipeline Variables Permissions Mixin', () => {
|
|||
const findErrorState = () => wrapper.findByTestId('error-state');
|
||||
|
||||
describe('on load', () => {
|
||||
describe('provide data', () => {
|
||||
beforeEach(() => {
|
||||
minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse());
|
||||
});
|
||||
|
||||
it('uses `projectPath` for the query if provided', async () => {
|
||||
await createComponent();
|
||||
expect(minimumRoleHandler).toHaveBeenCalledWith({ fullPath: 'project/path' });
|
||||
});
|
||||
|
||||
it('uses `fullPath` for the query if provided', async () => {
|
||||
await createComponent({
|
||||
provide: { fullPath: 'project/another/path', projectPath: undefined },
|
||||
});
|
||||
expect(minimumRoleHandler).toHaveBeenCalledWith({ fullPath: 'project/another/path' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when settings query is successful', () => {
|
||||
beforeEach(async () => {
|
||||
minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse());
|
||||
|
|
@ -110,11 +127,12 @@ describe('Pipeline Variables Permissions Mixin', () => {
|
|||
|
||||
describe('permissions calculations based on user roles', () => {
|
||||
it.each`
|
||||
scenario | userRole | minimumRole | isAuthorized
|
||||
${'user role is lower than minimum role'} | ${ROLE_DEVELOPER} | ${ROLE_MAINTAINER} | ${false}
|
||||
${'user role is equal to minimum role'} | ${ROLE_MAINTAINER} | ${ROLE_MAINTAINER} | ${true}
|
||||
${'user role is higher than minimum role'} | ${ROLE_OWNER} | ${ROLE_MAINTAINER} | ${true}
|
||||
${'minimum role is no_one_allowed'} | ${ROLE_OWNER} | ${ROLE_NO_ONE} | ${false}
|
||||
scenario | userRole | minimumRole | isAuthorized
|
||||
${'user role is lower than minimum role'} | ${'Developer'} | ${ROLE_MAINTAINER} | ${false}
|
||||
${'user role is equal to minimum role'} | ${'Maintainer'} | ${ROLE_MAINTAINER} | ${true}
|
||||
${'user role is higher than minimum role'} | ${'Owner'} | ${ROLE_MAINTAINER} | ${true}
|
||||
${'user role is higher than minimum role'} | ${''} | ${ROLE_MAINTAINER} | ${false}
|
||||
${'minimum role is no_one_allowed'} | ${'Owner'} | ${ROLE_NO_ONE} | ${false}
|
||||
`(
|
||||
'when $scenario, authorization is $isAuthorized',
|
||||
async ({ userRole, minimumRole, isAuthorized }) => {
|
||||
|
|
|
|||
|
|
@ -48,18 +48,18 @@ describe('Pipeline New Form', () => {
|
|||
provide: {
|
||||
identityVerificationRequired: true,
|
||||
identityVerificationPath: '/test',
|
||||
projectPath,
|
||||
userRole: 'Maintainer',
|
||||
},
|
||||
propsData: {
|
||||
projectId: mockProjectId,
|
||||
pipelinesPath,
|
||||
pipelinesEditorPath,
|
||||
canViewPipelineEditor: true,
|
||||
projectPath,
|
||||
defaultBranch,
|
||||
refParam: defaultBranch,
|
||||
settingsLink: '',
|
||||
maxWarnings: 25,
|
||||
isMaintainer: false,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import PipelineNewForm from '~/ci/pipeline_new/components/pipeline_new_form.vue'
|
|||
import PipelineVariablesForm from '~/ci/pipeline_new/components/pipeline_variables_form.vue';
|
||||
import pipelineCreateMutation from '~/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql';
|
||||
import RefsDropdown from '~/ci/pipeline_new/components/refs_dropdown.vue';
|
||||
import { mockPipelineVariablesPermissions } from 'jest/ci/job_details/mock_data';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { mockProjectId, mockPipelineConfigButtonText } from '../mock_data';
|
||||
|
||||
Vue.directive('safe-html', {
|
||||
|
|
@ -36,7 +38,6 @@ const defaultProps = {
|
|||
pipelinesPath: '/root/project/-/pipelines',
|
||||
pipelinesEditorPath: '/root/project/-/ci/editor',
|
||||
canViewPipelineEditor: true,
|
||||
projectPath: '/root/project/-/pipelines/config_variables',
|
||||
defaultBranch: 'main',
|
||||
refParam: 'main',
|
||||
settingsLink: '',
|
||||
|
|
@ -44,6 +45,13 @@ const defaultProps = {
|
|||
isMaintainer: true,
|
||||
};
|
||||
|
||||
const defaultProvide = {
|
||||
projectPath: '/root/project/-/pipelines/config_variables',
|
||||
userRole: 'Maintainer',
|
||||
identityVerificationRequired: true,
|
||||
identityVerificationPath: '/test',
|
||||
};
|
||||
|
||||
describe('Pipeline New Form', () => {
|
||||
let wrapper;
|
||||
let mock;
|
||||
|
|
@ -74,14 +82,14 @@ describe('Pipeline New Form', () => {
|
|||
mountFn = shallowMountExtended,
|
||||
stubs = {},
|
||||
ciInputsForPipelines = false,
|
||||
pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true),
|
||||
} = {}) => {
|
||||
const handlers = [[pipelineCreateMutation, pipelineCreateMutationHandler]];
|
||||
mockApollo = createMockApollo(handlers);
|
||||
wrapper = mountFn(PipelineNewForm, {
|
||||
apolloProvider: mockApollo,
|
||||
provide: {
|
||||
identityVerificationRequired: true,
|
||||
identityVerificationPath: '/test',
|
||||
...defaultProvide,
|
||||
glFeatures: {
|
||||
ciInputsForPipelines,
|
||||
},
|
||||
|
|
@ -91,6 +99,7 @@ describe('Pipeline New Form', () => {
|
|||
...props,
|
||||
},
|
||||
stubs,
|
||||
mixins: [glFeatureFlagMixin(), pipelineVariablesPermissionsMixin],
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
|
@ -147,33 +156,47 @@ describe('Pipeline New Form', () => {
|
|||
});
|
||||
|
||||
describe('Pipeline variables form', () => {
|
||||
beforeEach(async () => {
|
||||
pipelineCreateMutationHandler.mockResolvedValue(mockPipelineCreateMutationResponse);
|
||||
await createComponentWithApollo();
|
||||
});
|
||||
describe('when user has permission to view variables', () => {
|
||||
beforeEach(async () => {
|
||||
pipelineCreateMutationHandler.mockResolvedValue(mockPipelineCreateMutationResponse);
|
||||
await createComponentWithApollo();
|
||||
});
|
||||
|
||||
it('renders the pipeline variables form component', () => {
|
||||
expect(findPipelineVariablesForm().exists()).toBe(true);
|
||||
expect(findPipelineVariablesForm().props()).toMatchObject({
|
||||
isMaintainer: true,
|
||||
projectPath: defaultProps.projectPath,
|
||||
refParam: `refs/heads/${defaultProps.refParam}`,
|
||||
settingsLink: '',
|
||||
it('renders the pipeline variables form component', () => {
|
||||
expect(findPipelineVariablesForm().exists()).toBe(true);
|
||||
expect(findPipelineVariablesForm().props()).toMatchObject({
|
||||
isMaintainer: true,
|
||||
refParam: `refs/heads/${defaultProps.refParam}`,
|
||||
settingsLink: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes variables to the create mutation', async () => {
|
||||
const variables = [{ key: 'TEST_VAR', value: 'test_value' }];
|
||||
findPipelineVariablesForm().vm.$emit('variables-updated', variables);
|
||||
findForm().vm.$emit('submit', dummySubmitEvent);
|
||||
await waitForPromises();
|
||||
|
||||
expect(pipelineCreateMutationHandler).toHaveBeenCalledWith({
|
||||
input: {
|
||||
ref: 'main',
|
||||
projectPath: '/root/project/-/pipelines/config_variables',
|
||||
variables,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('passes variables to the create mutation', async () => {
|
||||
const variables = [{ key: 'TEST_VAR', value: 'test_value' }];
|
||||
findPipelineVariablesForm().vm.$emit('variables-updated', variables);
|
||||
findForm().vm.$emit('submit', dummySubmitEvent);
|
||||
await waitForPromises();
|
||||
describe('when user does not have permission to view variables', () => {
|
||||
beforeEach(async () => {
|
||||
pipelineCreateMutationHandler.mockResolvedValue(mockPipelineCreateMutationResponse);
|
||||
await createComponentWithApollo({
|
||||
pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false),
|
||||
});
|
||||
});
|
||||
|
||||
expect(pipelineCreateMutationHandler).toHaveBeenCalledWith({
|
||||
input: {
|
||||
ref: 'main',
|
||||
projectPath: '/root/project/-/pipelines/config_variables',
|
||||
variables,
|
||||
},
|
||||
it('does not render the pipeline variables form component', () => {
|
||||
expect(findPipelineVariablesForm().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ describe('PipelineVariablesForm', () => {
|
|||
|
||||
const defaultProps = {
|
||||
isMaintainer: true,
|
||||
projectPath: 'group/project',
|
||||
refParam: 'refs/heads/feature',
|
||||
settingsLink: 'link/to/settings',
|
||||
};
|
||||
|
||||
const defaultProvide = { projectPath: 'group/project' };
|
||||
|
||||
const configVariablesWithOptions = [
|
||||
{
|
||||
key: 'VAR_WITH_OPTIONS',
|
||||
|
|
@ -62,6 +63,7 @@ describe('PipelineVariablesForm', () => {
|
|||
wrapper = shallowMountExtended(PipelineVariablesForm, {
|
||||
apolloProvider: mockApollo,
|
||||
propsData: { ...defaultProps, ...props },
|
||||
provide: { ...defaultProvide },
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
|
@ -146,7 +148,7 @@ describe('PipelineVariablesForm', () => {
|
|||
await createComponent();
|
||||
|
||||
expect(mockCiConfigVariables).toHaveBeenCalledWith({
|
||||
fullPath: defaultProps.projectPath,
|
||||
fullPath: defaultProvide.projectPath,
|
||||
ref: defaultProps.refParam,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { GlForm, GlLoadingIcon } from '@gitlab/ui';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { visitUrl } from '~/lib/utils/url_utility';
|
||||
import { createAlert } from '~/alert';
|
||||
import PipelineSchedulesForm from '~/ci/pipeline_schedules/components/pipeline_schedules_form.vue';
|
||||
|
|
@ -16,7 +14,12 @@ import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone
|
|||
import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
|
||||
import createPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql';
|
||||
import updatePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql';
|
||||
import getPipelineVariablesMinimumOverrideRoleQuery from '~/ci/pipeline_variables_minimum_override_role/graphql/queries/get_pipeline_variables_minimum_override_role_project_setting.query.graphql';
|
||||
import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql';
|
||||
import {
|
||||
mockPipelineVariablesPermissions,
|
||||
minimumRoleResponse,
|
||||
} from 'jest/ci/job_details/mock_data';
|
||||
import { timezoneDataFixture } from '../../../vue_shared/components/timezone_dropdown/helpers';
|
||||
import {
|
||||
createScheduleMutationResponse,
|
||||
|
|
@ -51,6 +54,16 @@ describe('Pipeline schedules form', () => {
|
|||
const cron = '';
|
||||
const dailyLimit = '';
|
||||
|
||||
const defaultProvide = {
|
||||
fullPath: 'gitlab-org/gitlab',
|
||||
projectId,
|
||||
defaultBranch,
|
||||
dailyLimit,
|
||||
settingsLink: '',
|
||||
schedulesPath: '/root/ci-project/-/pipeline_schedules',
|
||||
userRole: 'maintainer',
|
||||
};
|
||||
|
||||
const querySuccessHandler = jest.fn().mockResolvedValue(mockSinglePipelineScheduleNode);
|
||||
const queryFailedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
|
||||
|
||||
|
|
@ -59,27 +72,32 @@ describe('Pipeline schedules form', () => {
|
|||
const updateMutationHandlerSuccess = jest.fn().mockResolvedValue(updateScheduleMutationResponse);
|
||||
const updateMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error'));
|
||||
|
||||
const minimumRoleHandler = jest.fn().mockResolvedValue(minimumRoleResponse);
|
||||
|
||||
const createMockApolloProvider = (
|
||||
requestHandlers = [[createPipelineScheduleMutation, createMutationHandlerSuccess]],
|
||||
requestHandlers = [
|
||||
[getPipelineVariablesMinimumOverrideRoleQuery, minimumRoleHandler],
|
||||
[createPipelineScheduleMutation, createMutationHandlerSuccess],
|
||||
],
|
||||
) => {
|
||||
return createMockApollo(requestHandlers);
|
||||
};
|
||||
|
||||
const createComponent = (mountFn = shallowMountExtended, editing = false, requestHandlers) => {
|
||||
wrapper = mountFn(PipelineSchedulesForm, {
|
||||
const createComponent = ({
|
||||
editing = false,
|
||||
pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true),
|
||||
requestHandlers,
|
||||
} = {}) => {
|
||||
wrapper = shallowMountExtended(PipelineSchedulesForm, {
|
||||
propsData: {
|
||||
timezoneData: timezoneDataFixture,
|
||||
refParam: 'master',
|
||||
editing,
|
||||
},
|
||||
provide: {
|
||||
fullPath: 'gitlab-org/gitlab',
|
||||
projectId,
|
||||
defaultBranch,
|
||||
dailyLimit,
|
||||
settingsLink: '',
|
||||
schedulesPath: '/root/ci-project/-/pipeline_schedules',
|
||||
...defaultProvide,
|
||||
},
|
||||
mixins: [pipelineVariablesPermissionsMixin],
|
||||
apolloProvider: createMockApolloProvider(requestHandlers),
|
||||
});
|
||||
};
|
||||
|
|
@ -95,19 +113,21 @@ describe('Pipeline schedules form', () => {
|
|||
const findPipelineVariables = () => wrapper.findComponent(PipelineVariablesFormGroup);
|
||||
|
||||
describe('Form elements', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('displays form', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findForm().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('displays the description input', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findDescription().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('displays the interval pattern component', () => {
|
||||
createComponent();
|
||||
|
||||
const intervalPattern = findIntervalComponent();
|
||||
|
||||
expect(intervalPattern.exists()).toBe(true);
|
||||
|
|
@ -119,6 +139,8 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
it('displays the Timezone dropdown', () => {
|
||||
createComponent();
|
||||
|
||||
const timezoneDropdown = findTimezoneDropdown();
|
||||
|
||||
expect(timezoneDropdown.exists()).toBe(true);
|
||||
|
|
@ -130,6 +152,8 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
it('displays the branch/tag selector', () => {
|
||||
createComponent();
|
||||
|
||||
const refSelector = findRefSelector();
|
||||
|
||||
expect(refSelector.exists()).toBe(true);
|
||||
|
|
@ -144,7 +168,9 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('displays variable list', () => {
|
||||
it('displays variable list when the user has permissions', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findPipelineVariables().exists()).toBe(true);
|
||||
expect(findPipelineVariables().props()).toEqual({
|
||||
initialVariables: [],
|
||||
|
|
@ -152,7 +178,17 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not display variable list when the user has no permissions', () => {
|
||||
createComponent({
|
||||
pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false),
|
||||
});
|
||||
|
||||
expect(findPipelineVariables().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('displays the submit and cancel buttons', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findSubmitButton().exists()).toBe(true);
|
||||
expect(findCancelButton().exists()).toBe(true);
|
||||
expect(findCancelButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules');
|
||||
|
|
@ -167,9 +203,10 @@ describe('Pipeline schedules form', () => {
|
|||
`(
|
||||
'button text is $expectedText when editing is $editing',
|
||||
async ({ editing, expectedText }) => {
|
||||
createComponent(shallowMountExtended, editing, [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
]);
|
||||
createComponent({
|
||||
editing,
|
||||
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
|
|
@ -201,22 +238,12 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
describe('schedule creation success', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// mock is needed when we fully mount
|
||||
// downstream components request needs to be mocked
|
||||
mock = new MockAdapter(axios);
|
||||
createComponent(mountExtended);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('creates pipeline schedule', async () => {
|
||||
findDescription().element.value = 'My schedule';
|
||||
findDescription().trigger('change');
|
||||
findDescription().vm.$emit('input', 'My schedule');
|
||||
|
||||
findTimezoneDropdown().vm.$emit('input', {
|
||||
formattedTimezone: '[UTC-4] Eastern Time (US & Canada)',
|
||||
|
|
@ -261,9 +288,9 @@ describe('Pipeline schedules form', () => {
|
|||
|
||||
describe('schedule creation failure', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(shallowMountExtended, false, [
|
||||
[createPipelineScheduleMutation, createMutationHandlerFailed],
|
||||
]);
|
||||
createComponent({
|
||||
requestHandlers: [[createPipelineScheduleMutation, createMutationHandlerFailed]],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for failed pipeline schedule creation', async () => {
|
||||
|
|
@ -279,20 +306,11 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
describe('Schedule editing', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows loading state when editing', async () => {
|
||||
createComponent(shallowMountExtended, true, [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
]);
|
||||
createComponent({
|
||||
editing: true,
|
||||
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
|
||||
});
|
||||
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
|
||||
|
|
@ -302,9 +320,10 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
it('provides variables to the variable list', async () => {
|
||||
createComponent(shallowMountExtended, true, [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
]);
|
||||
createComponent({
|
||||
editing: true,
|
||||
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
expect(findPipelineVariables().props('editing')).toBe(true);
|
||||
|
|
@ -313,26 +332,30 @@ describe('Pipeline schedules form', () => {
|
|||
|
||||
describe('schedule fetch success', () => {
|
||||
it('fetches schedule and sets form data correctly', async () => {
|
||||
createComponent(mountExtended, true, [[getPipelineSchedulesQuery, querySuccessHandler]]);
|
||||
createComponent({
|
||||
editing: true,
|
||||
requestHandlers: [[getPipelineSchedulesQuery, querySuccessHandler]],
|
||||
});
|
||||
|
||||
expect(querySuccessHandler).toHaveBeenCalled();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findDescription().element.value).toBe(schedule.description);
|
||||
expect(findDescription().props('value')).toBe(schedule.description);
|
||||
expect(findIntervalComponent().props('initialCronInterval')).toBe(schedule.cron);
|
||||
expect(findTimezoneDropdown().props('value')).toBe(schedule.cronTimezone);
|
||||
expect(findRefSelector().props('value')).toBe(schedule.ref);
|
||||
expect(findPipelineVariables().props('initialVariables')).toHaveLength(3);
|
||||
expect(findPipelineVariables().props('initialVariables')).toHaveLength(2);
|
||||
expect(findPipelineVariables().props('initialVariables')[0].key).toBe(variables[0].key);
|
||||
expect(findPipelineVariables().props('initialVariables')[1].key).toBe(variables[1].key);
|
||||
});
|
||||
});
|
||||
|
||||
it('schedule fetch failure', async () => {
|
||||
createComponent(shallowMountExtended, true, [
|
||||
[getPipelineSchedulesQuery, queryFailedHandler],
|
||||
]);
|
||||
createComponent({
|
||||
editing: true,
|
||||
requestHandlers: [[getPipelineSchedulesQuery, queryFailedHandler]],
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
|
|
@ -342,15 +365,17 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
it('edit schedule success', async () => {
|
||||
createComponent(mountExtended, true, [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
[updatePipelineScheduleMutation, updateMutationHandlerSuccess],
|
||||
]);
|
||||
createComponent({
|
||||
editing: true,
|
||||
requestHandlers: [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
[updatePipelineScheduleMutation, updateMutationHandlerSuccess],
|
||||
],
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findDescription().element.value = 'Updated schedule';
|
||||
findDescription().trigger('change');
|
||||
findDescription().vm.$emit('input', 'Updated schedule');
|
||||
|
||||
findIntervalComponent().vm.$emit('cronValue', '0 22 16 * *');
|
||||
|
||||
|
|
@ -405,10 +430,13 @@ describe('Pipeline schedules form', () => {
|
|||
});
|
||||
|
||||
it('edit schedule failure', async () => {
|
||||
createComponent(shallowMountExtended, true, [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
[updatePipelineScheduleMutation, updateMutationHandlerFailed],
|
||||
]);
|
||||
createComponent({
|
||||
editing: true,
|
||||
requestHandlers: [
|
||||
[getPipelineSchedulesQuery, querySuccessHandler],
|
||||
[updatePipelineScheduleMutation, updateMutationHandlerFailed],
|
||||
],
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import personalProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your
|
|||
import membershipProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/membership_projects.query.graphql.json';
|
||||
import contributedProjectsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/contributed_projects.query.graphql.json';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import TabView from '~/projects/your_work/components/tab_view.vue';
|
||||
import TabView from '~/groups_projects/components/tab_view.vue';
|
||||
import { formatProjects } from '~/projects/your_work/utils';
|
||||
import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
|
||||
import ProjectsListEmptyState from '~/vue_shared/components/projects_list/projects_list_empty_state.vue';
|
||||
|
|
@ -19,9 +19,11 @@ import {
|
|||
MEMBER_TAB,
|
||||
STARRED_TAB,
|
||||
INACTIVE_TAB,
|
||||
} from '~/projects/your_work/constants';
|
||||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '~/projects/your_work/constants';
|
||||
} from '~/groups_projects/constants';
|
||||
import { FILTERED_SEARCH_TERM_KEY } from '~/projects/filtered_search_and_sort/constants';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER, ACCESS_LEVEL_OWNER_STRING } from '~/access_level/constants';
|
||||
import { TIMESTAMP_TYPE_CREATED_AT } from '~/vue_shared/components/resource_lists/constants';
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import VueRouter from 'vue-router';
|
||||
import { GlBadge, GlTabs, GlFilteredSearchToken } from '@gitlab/ui';
|
||||
import projectCountsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/project_counts.query.graphql.json';
|
||||
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
|
||||
import TabView from '~/groups_projects/components/tab_view.vue';
|
||||
import { createRouter } from '~/projects/your_work';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import {
|
||||
ROOT_ROUTE_NAME,
|
||||
DASHBOARD_ROUTE_NAME,
|
||||
PROJECTS_DASHBOARD_ROUTE_NAME,
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
CONTRIBUTED_TAB,
|
||||
STARRED_TAB,
|
||||
PERSONAL_TAB,
|
||||
MEMBER_TAB,
|
||||
INACTIVE_TAB,
|
||||
} from '~/projects/your_work/constants';
|
||||
import {
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '~/groups_projects/constants';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_OPTION_CREATED,
|
||||
SORT_OPTION_UPDATED,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_DIRECTION_ASC,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
|
||||
import userPreferencesUpdateMutation from '~/groups_projects/graphql/mutations/user_preferences_update.mutation.graphql';
|
||||
import { createAlert } from '~/alert';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
|
||||
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
|
||||
} from '~/vue_shared/components/resource_lists/constants';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { programmingLanguages } from './mock_data';
|
||||
|
||||
jest.mock('~/alert');
|
||||
jest.mock('~/sentry/sentry_browser_wrapper');
|
||||
|
||||
Vue.use(VueRouter);
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const defaultRoute = {
|
||||
name: ROOT_ROUTE_NAME,
|
||||
};
|
||||
|
||||
const defaultProvide = {
|
||||
initialSort: 'created_desc',
|
||||
programmingLanguages,
|
||||
};
|
||||
|
||||
const searchTerm = 'foo bar';
|
||||
const mockEndCursor = 'mockEndCursor';
|
||||
const mockStartCursor = 'mockStartCursor';
|
||||
|
||||
describe('TabsWithList', () => {
|
||||
let wrapper;
|
||||
let router;
|
||||
let mockApollo;
|
||||
|
||||
const successHandler = jest.fn().mockResolvedValue(projectCountsGraphQlResponse);
|
||||
const userPreferencesUpdateSuccessHandler = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
userPreferencesUpdate: {
|
||||
userPreferences: {
|
||||
projectsSort: 'NAME_DESC',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createComponent = async ({
|
||||
provide = {},
|
||||
projectsCountHandler = successHandler,
|
||||
userPreferencesUpdateHandler = userPreferencesUpdateSuccessHandler,
|
||||
route = defaultRoute,
|
||||
} = {}) => {
|
||||
mockApollo = createMockApollo([
|
||||
[projectCountsQuery, projectsCountHandler],
|
||||
[userPreferencesUpdateMutation, userPreferencesUpdateHandler],
|
||||
]);
|
||||
router = createRouter();
|
||||
await router.push(route);
|
||||
|
||||
wrapper = mountExtended(TabsWithList, {
|
||||
apolloProvider: mockApollo,
|
||||
router,
|
||||
stubs: {
|
||||
TabView: stubComponent(TabView),
|
||||
},
|
||||
provide: { ...defaultProvide, ...provide },
|
||||
});
|
||||
};
|
||||
|
||||
const findGlTabs = () => wrapper.findComponent(GlTabs);
|
||||
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
|
||||
const findTabByName = (name) =>
|
||||
wrapper.findAllByRole('tab').wrappers.find((tab) => tab.text().includes(name));
|
||||
const getTabCount = (tabName) =>
|
||||
extendedWrapper(findTabByName(tabName)).findByTestId('tab-counter-badge').text();
|
||||
const findFilteredSearchAndSort = () => wrapper.findComponent(FilteredSearchAndSort);
|
||||
const findTabView = () => wrapper.findComponent(TabView);
|
||||
|
||||
afterEach(() => {
|
||||
router = null;
|
||||
mockApollo = null;
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
describe('when project counts are loading', () => {
|
||||
it('does not show count badges', async () => {
|
||||
await createComponent();
|
||||
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when project counts are successfully retrieved', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('shows count badges', () => {
|
||||
expect(getTabCount('Contributed')).toBe('2');
|
||||
expect(getTabCount('Starred')).toBe('0');
|
||||
expect(getTabCount('Personal')).toBe('0');
|
||||
expect(getTabCount('Member')).toBe('2');
|
||||
expect(getTabCount('Inactive')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when project counts are not successfully retrieved', () => {
|
||||
const error = new Error();
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent({ projectsCountHandler: jest.fn().mockRejectedValue(error) });
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('displays error alert', () => {
|
||||
expect(createAlert).toHaveBeenCalledWith({
|
||||
message: 'An error occurred loading the project counts.',
|
||||
error,
|
||||
captureError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show count badges', () => {
|
||||
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to Contributed tab as active', () => {
|
||||
expect(findActiveTab().text()).toContain('Contributed');
|
||||
});
|
||||
|
||||
it('renders filtered search bar with correct props', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(findFilteredSearchAndSort().props()).toMatchObject({
|
||||
filteredSearchTokens: [
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
icon: 'code',
|
||||
title: 'Language',
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: [{ value: '=', description: 'is' }],
|
||||
options: [
|
||||
{ value: '5', title: 'CSS' },
|
||||
{ value: '8', title: 'CoffeeScript' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
icon: 'user',
|
||||
title: 'Role',
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: [
|
||||
{
|
||||
value: '50',
|
||||
title: 'Owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
filteredSearchQuery: {},
|
||||
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
|
||||
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
sortOptions: SORT_OPTIONS,
|
||||
activeSortOption: SORT_OPTION_CREATED,
|
||||
isAscending: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when filtered search bar is submitted', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('filter', {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
});
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({ [FILTERED_SEARCH_TERM_KEY]: searchTerm });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort is changed', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_DESC}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
|
||||
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
|
||||
input: { projectsSort: 'LATEST_ACTIVITY_DESC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call Sentry.captureException', () => {
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort direction is changed', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('sort-direction-change', true);
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_ASC}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
|
||||
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
|
||||
input: { projectsSort: 'CREATED_ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call Sentry.captureException', () => {
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `userPreferencesUpdate` mutation fails', () => {
|
||||
const error = new Error();
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent({ userPreferencesUpdateHandler: jest.fn().mockRejectedValue(error) });
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('captures error in Sentry', () => {
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
name | expectedTab
|
||||
${ROOT_ROUTE_NAME} | ${CONTRIBUTED_TAB}
|
||||
${DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
|
||||
${PROJECTS_DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
|
||||
${CONTRIBUTED_TAB.value} | ${CONTRIBUTED_TAB}
|
||||
${STARRED_TAB.value} | ${STARRED_TAB}
|
||||
${PERSONAL_TAB.value} | ${PERSONAL_TAB}
|
||||
${MEMBER_TAB.value} | ${MEMBER_TAB}
|
||||
${INACTIVE_TAB.value} | ${INACTIVE_TAB}
|
||||
`('onMount when route name is $name', ({ name, expectedTab }) => {
|
||||
const query = {
|
||||
sort: 'name_desc',
|
||||
[FILTERED_SEARCH_TERM_KEY]: 'foo',
|
||||
[FILTERED_SEARCH_TOKEN_LANGUAGE]: '8',
|
||||
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: ACCESS_LEVEL_OWNER_INTEGER,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
[QUERY_PARAM_START_CURSOR]: mockStartCursor,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: { name, query },
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes to the correct tab', () => {
|
||||
expect(findActiveTab().text()).toContain(expectedTab.text);
|
||||
});
|
||||
|
||||
it('renders `TabView` component and passes `tab` prop', () => {
|
||||
expect(findTabView().props('tab')).toMatchObject(expectedTab);
|
||||
});
|
||||
|
||||
it('passes sorting, filtering, and pagination props', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: query.sort,
|
||||
filters: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: query[FILTERED_SEARCH_TERM_KEY],
|
||||
[FILTERED_SEARCH_TOKEN_LANGUAGE]: query[FILTERED_SEARCH_TOKEN_LANGUAGE],
|
||||
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: query[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
|
||||
},
|
||||
endCursor: mockEndCursor,
|
||||
startCursor: mockStartCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort query param is invalid', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
sort: 'foo_bar',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to initial sort', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_DESC}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort query param and initial sort are invalid', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
provide: { initialSort: 'foo_bar' },
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
sort: 'foo_bar',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to updated in ascending order', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTabUpdate', () => {
|
||||
describe('when tab is already active', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('does not push new route', async () => {
|
||||
findGlTabs().vm.$emit('input', 0);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tab is a valid tab', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('pushes new route correctly', async () => {
|
||||
findGlTabs().vm.$emit('input', 2);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[2].value });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tab is an invalid tab', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('pushes new route with default Contributed tab', async () => {
|
||||
findGlTabs().vm.$emit('input', 100);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: CONTRIBUTED_TAB.value });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when gon.relative_url_root is set', () => {
|
||||
beforeEach(async () => {
|
||||
gon.relative_url_root = '/gitlab';
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('pushes new route correctly and respects relative url', async () => {
|
||||
findGlTabs().vm.$emit('input', 3);
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (router.options.base) {
|
||||
// Vue router 3
|
||||
expect(router.options.base).toBe('/gitlab');
|
||||
} else {
|
||||
// Vue router 4
|
||||
expect(router.currentRoute.href).toBe('/gitlab/');
|
||||
}
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[3].value });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when page is changed', () => {
|
||||
describe('when going to next page', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: defaultRoute,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
findTabView().vm.$emit('page-change', {
|
||||
endCursor: mockEndCursor,
|
||||
startCursor: null,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('sets `end_cursor` query string', () => {
|
||||
expect(router.currentRoute.query).toMatchObject({
|
||||
end_cursor: mockEndCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when going to previous page', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
start_cursor: mockStartCursor,
|
||||
end_cursor: mockEndCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
findTabView().vm.$emit('page-change', {
|
||||
endCursor: null,
|
||||
startCursor: mockStartCursor,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets `start_cursor` query string', () => {
|
||||
expect(router.currentRoute.query).toMatchObject({
|
||||
start_cursor: mockStartCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
sort | expectedTimestampType
|
||||
${'name_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'name_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'created_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'created_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'latest_activity_asc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
|
||||
${'latest_activity_desc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
|
||||
${'stars_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'stars_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
`('when sort is $sort', ({ sort, expectedTimestampType }) => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
sort,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly passes timestampType prop to TabView component', () => {
|
||||
expect(findTabView().props('timestampType')).toBe(expectedTimestampType);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,549 +1,19 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import VueRouter from 'vue-router';
|
||||
import { GlBadge, GlTabs, GlFilteredSearchToken } from '@gitlab/ui';
|
||||
import projectCountsGraphQlResponse from 'test_fixtures/graphql/projects/your_work/project_counts.query.graphql.json';
|
||||
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import YourWorkProjectsApp from '~/projects/your_work/components/app.vue';
|
||||
import TabView from '~/projects/your_work/components/tab_view.vue';
|
||||
import { createRouter } from '~/projects/your_work';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import {
|
||||
ROOT_ROUTE_NAME,
|
||||
DASHBOARD_ROUTE_NAME,
|
||||
PROJECTS_DASHBOARD_ROUTE_NAME,
|
||||
PROJECT_DASHBOARD_TABS,
|
||||
CONTRIBUTED_TAB,
|
||||
STARRED_TAB,
|
||||
PERSONAL_TAB,
|
||||
MEMBER_TAB,
|
||||
INACTIVE_TAB,
|
||||
FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
} from '~/projects/your_work/constants';
|
||||
import { RECENT_SEARCHES_STORAGE_KEY_PROJECTS } from '~/filtered_search/recent_searches_storage_keys';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SORT_OPTION_CREATED,
|
||||
SORT_OPTION_UPDATED,
|
||||
SORT_DIRECTION_DESC,
|
||||
SORT_DIRECTION_ASC,
|
||||
FILTERED_SEARCH_TERM_KEY,
|
||||
FILTERED_SEARCH_NAMESPACE,
|
||||
} from '~/projects/filtered_search_and_sort/constants';
|
||||
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
|
||||
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
|
||||
import projectCountsQuery from '~/projects/your_work/graphql/queries/project_counts.query.graphql';
|
||||
import userPreferencesUpdateMutation from '~/projects/your_work/graphql/mutations/user_preferences_update.mutation.graphql';
|
||||
import { createAlert } from '~/alert';
|
||||
import { ACCESS_LEVEL_OWNER_INTEGER } from '~/access_level/constants';
|
||||
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import {
|
||||
TIMESTAMP_TYPE_CREATED_AT,
|
||||
TIMESTAMP_TYPE_LAST_ACTIVITY_AT,
|
||||
} from '~/vue_shared/components/resource_lists/constants';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { programmingLanguages } from './mock_data';
|
||||
|
||||
jest.mock('~/alert');
|
||||
jest.mock('~/sentry/sentry_browser_wrapper');
|
||||
|
||||
Vue.use(VueRouter);
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const defaultRoute = {
|
||||
name: ROOT_ROUTE_NAME,
|
||||
};
|
||||
|
||||
const defaultProvide = {
|
||||
initialSort: 'created_desc',
|
||||
programmingLanguages,
|
||||
};
|
||||
|
||||
const searchTerm = 'foo bar';
|
||||
const mockEndCursor = 'mockEndCursor';
|
||||
const mockStartCursor = 'mockStartCursor';
|
||||
import TabsWithList from '~/groups_projects/components/tabs_with_list.vue';
|
||||
|
||||
describe('YourWorkProjectsApp', () => {
|
||||
let wrapper;
|
||||
let router;
|
||||
let mockApollo;
|
||||
|
||||
const successHandler = jest.fn().mockResolvedValue(projectCountsGraphQlResponse);
|
||||
const userPreferencesUpdateSuccessHandler = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
userPreferencesUpdate: {
|
||||
userPreferences: {
|
||||
projectsSort: 'NAME_DESC',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createComponent = async ({
|
||||
provide = {},
|
||||
projectsCountHandler = successHandler,
|
||||
userPreferencesUpdateHandler = userPreferencesUpdateSuccessHandler,
|
||||
route = defaultRoute,
|
||||
} = {}) => {
|
||||
mockApollo = createMockApollo([
|
||||
[projectCountsQuery, projectsCountHandler],
|
||||
[userPreferencesUpdateMutation, userPreferencesUpdateHandler],
|
||||
]);
|
||||
router = createRouter();
|
||||
await router.push(route);
|
||||
|
||||
wrapper = mountExtended(YourWorkProjectsApp, {
|
||||
apolloProvider: mockApollo,
|
||||
router,
|
||||
stubs: {
|
||||
TabView: stubComponent(TabView),
|
||||
},
|
||||
provide: { ...defaultProvide, ...provide },
|
||||
});
|
||||
const createComponent = () => {
|
||||
wrapper = shallowMountExtended(YourWorkProjectsApp);
|
||||
};
|
||||
|
||||
const findGlTabs = () => wrapper.findComponent(GlTabs);
|
||||
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
|
||||
const findTabByName = (name) =>
|
||||
wrapper.findAllByRole('tab').wrappers.find((tab) => tab.text().includes(name));
|
||||
const getTabCount = (tabName) =>
|
||||
extendedWrapper(findTabByName(tabName)).findByTestId('tab-counter-badge').text();
|
||||
const findFilteredSearchAndSort = () => wrapper.findComponent(FilteredSearchAndSort);
|
||||
const findTabView = () => wrapper.findComponent(TabView);
|
||||
|
||||
afterEach(() => {
|
||||
router = null;
|
||||
mockApollo = null;
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
describe('when project counts are loading', () => {
|
||||
it('does not show count badges', async () => {
|
||||
await createComponent();
|
||||
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when project counts are successfully retrieved', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('shows count badges', () => {
|
||||
expect(getTabCount('Contributed')).toBe('2');
|
||||
expect(getTabCount('Starred')).toBe('0');
|
||||
expect(getTabCount('Personal')).toBe('0');
|
||||
expect(getTabCount('Member')).toBe('2');
|
||||
expect(getTabCount('Inactive')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when project counts are not successfully retrieved', () => {
|
||||
const error = new Error();
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent({ projectsCountHandler: jest.fn().mockRejectedValue(error) });
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('displays error alert', () => {
|
||||
expect(createAlert).toHaveBeenCalledWith({
|
||||
message: 'An error occurred loading the project counts.',
|
||||
error,
|
||||
captureError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show count badges', () => {
|
||||
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to Contributed tab as active', () => {
|
||||
expect(findActiveTab().text()).toContain('Contributed');
|
||||
});
|
||||
|
||||
it('renders filtered search bar with correct props', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(findFilteredSearchAndSort().props()).toMatchObject({
|
||||
filteredSearchTokens: [
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_LANGUAGE,
|
||||
icon: 'code',
|
||||
title: 'Language',
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: [{ value: '=', description: 'is' }],
|
||||
options: [
|
||||
{ value: '5', title: 'CSS' },
|
||||
{ value: '8', title: 'CoffeeScript' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
|
||||
icon: 'user',
|
||||
title: 'Role',
|
||||
token: GlFilteredSearchToken,
|
||||
unique: true,
|
||||
operators: OPERATORS_IS,
|
||||
options: [
|
||||
{
|
||||
value: '50',
|
||||
title: 'Owner',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
filteredSearchQuery: {},
|
||||
filteredSearchTermKey: FILTERED_SEARCH_TERM_KEY,
|
||||
filteredSearchNamespace: FILTERED_SEARCH_NAMESPACE,
|
||||
filteredSearchRecentSearchesStorageKey: RECENT_SEARCHES_STORAGE_KEY_PROJECTS,
|
||||
sortOptions: SORT_OPTIONS,
|
||||
activeSortOption: SORT_OPTION_CREATED,
|
||||
isAscending: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when filtered search bar is submitted', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('filter', {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
});
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({ [FILTERED_SEARCH_TERM_KEY]: searchTerm });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort is changed', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_DESC}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
|
||||
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
|
||||
input: { projectsSort: 'LATEST_ACTIVITY_DESC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call Sentry.captureException', () => {
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort direction is changed', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('sort-direction-change', true);
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('updates query string', () => {
|
||||
expect(router.currentRoute.query).toEqual({
|
||||
[FILTERED_SEARCH_TERM_KEY]: searchTerm,
|
||||
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_ASC}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls `userPreferencesUpdate` mutation with correct variables', () => {
|
||||
expect(userPreferencesUpdateSuccessHandler).toHaveBeenCalledWith({
|
||||
input: { projectsSort: 'CREATED_ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call Sentry.captureException', () => {
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `userPreferencesUpdate` mutation fails', () => {
|
||||
const error = new Error();
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent({ userPreferencesUpdateHandler: jest.fn().mockRejectedValue(error) });
|
||||
|
||||
findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED.value);
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('captures error in Sentry', () => {
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
name | expectedTab
|
||||
${ROOT_ROUTE_NAME} | ${CONTRIBUTED_TAB}
|
||||
${DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
|
||||
${PROJECTS_DASHBOARD_ROUTE_NAME} | ${CONTRIBUTED_TAB}
|
||||
${CONTRIBUTED_TAB.value} | ${CONTRIBUTED_TAB}
|
||||
${STARRED_TAB.value} | ${STARRED_TAB}
|
||||
${PERSONAL_TAB.value} | ${PERSONAL_TAB}
|
||||
${MEMBER_TAB.value} | ${MEMBER_TAB}
|
||||
${INACTIVE_TAB.value} | ${INACTIVE_TAB}
|
||||
`('onMount when route name is $name', ({ name, expectedTab }) => {
|
||||
const query = {
|
||||
sort: 'name_desc',
|
||||
[FILTERED_SEARCH_TERM_KEY]: 'foo',
|
||||
[FILTERED_SEARCH_TOKEN_LANGUAGE]: '8',
|
||||
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: ACCESS_LEVEL_OWNER_INTEGER,
|
||||
[QUERY_PARAM_END_CURSOR]: mockEndCursor,
|
||||
[QUERY_PARAM_START_CURSOR]: mockStartCursor,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: { name, query },
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes to the correct tab', () => {
|
||||
expect(findActiveTab().text()).toContain(expectedTab.text);
|
||||
});
|
||||
|
||||
it('renders `TabView` component and passes `tab` prop', () => {
|
||||
expect(findTabView().props('tab')).toMatchObject(expectedTab);
|
||||
});
|
||||
|
||||
it('passes sorting, filtering, and pagination props', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: query.sort,
|
||||
filters: {
|
||||
[FILTERED_SEARCH_TERM_KEY]: query[FILTERED_SEARCH_TERM_KEY],
|
||||
[FILTERED_SEARCH_TOKEN_LANGUAGE]: query[FILTERED_SEARCH_TOKEN_LANGUAGE],
|
||||
[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: query[FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL],
|
||||
},
|
||||
endCursor: mockEndCursor,
|
||||
startCursor: mockStartCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort query param is invalid', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
sort: 'foo_bar',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to initial sort', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: `${SORT_OPTION_CREATED.value}_${SORT_DIRECTION_DESC}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sort query param and initial sort are invalid', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
provide: { initialSort: 'foo_bar' },
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
sort: 'foo_bar',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to updated in ascending order', () => {
|
||||
expect(findTabView().props()).toMatchObject({
|
||||
sort: `${SORT_OPTION_UPDATED.value}_${SORT_DIRECTION_ASC}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTabUpdate', () => {
|
||||
describe('when tab is already active', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('does not push new route', async () => {
|
||||
findGlTabs().vm.$emit('input', 0);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tab is a valid tab', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('pushes new route correctly', async () => {
|
||||
findGlTabs().vm.$emit('input', 2);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[2].value });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when tab is an invalid tab', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('pushes new route with default Contributed tab', async () => {
|
||||
findGlTabs().vm.$emit('input', 100);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: CONTRIBUTED_TAB.value });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when gon.relative_url_root is set', () => {
|
||||
beforeEach(async () => {
|
||||
gon.relative_url_root = '/gitlab';
|
||||
await createComponent();
|
||||
router.push = jest.fn();
|
||||
});
|
||||
|
||||
it('pushes new route correctly and respects relative url', async () => {
|
||||
findGlTabs().vm.$emit('input', 3);
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (router.options.base) {
|
||||
// Vue router 3
|
||||
expect(router.options.base).toBe('/gitlab');
|
||||
} else {
|
||||
// Vue router 4
|
||||
expect(router.currentRoute.href).toBe('/gitlab/');
|
||||
}
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ name: PROJECT_DASHBOARD_TABS[3].value });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when page is changed', () => {
|
||||
describe('when going to next page', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: defaultRoute,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
findTabView().vm.$emit('page-change', {
|
||||
endCursor: mockEndCursor,
|
||||
startCursor: null,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('sets `end_cursor` query string', () => {
|
||||
expect(router.currentRoute.query).toMatchObject({
|
||||
end_cursor: mockEndCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when going to previous page', () => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
start_cursor: mockStartCursor,
|
||||
end_cursor: mockEndCursor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
findTabView().vm.$emit('page-change', {
|
||||
endCursor: null,
|
||||
startCursor: mockStartCursor,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets `start_cursor` query string', () => {
|
||||
expect(router.currentRoute.query).toMatchObject({
|
||||
start_cursor: mockStartCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
sort | expectedTimestampType
|
||||
${'name_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'name_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'created_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'created_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'latest_activity_asc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
|
||||
${'latest_activity_desc'} | ${TIMESTAMP_TYPE_LAST_ACTIVITY_AT}
|
||||
${'stars_asc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
${'stars_desc'} | ${TIMESTAMP_TYPE_CREATED_AT}
|
||||
`('when sort is $sort', ({ sort, expectedTimestampType }) => {
|
||||
beforeEach(async () => {
|
||||
await createComponent({
|
||||
route: {
|
||||
...defaultRoute,
|
||||
query: {
|
||||
sort,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly passes timestampType prop to TabView component', () => {
|
||||
expect(findTabView().props('timestampType')).toBe(expectedTimestampType);
|
||||
});
|
||||
it('renders TabsWithList component', () => {
|
||||
expect(wrapper.findComponent(TabsWithList).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import WorkItemNoteBody from '~/work_items/components/notes/work_item_note_body.vue';
|
||||
import NoteEditedText from '~/notes/components/note_edited_text.vue';
|
||||
|
|
@ -17,16 +18,59 @@ describe('Work Item Note Body', () => {
|
|||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('should have the wrapper to show the note body', () => {
|
||||
expect(findNoteBody().exists()).toBe(true);
|
||||
createComponent();
|
||||
|
||||
expect(findNoteBody().html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should not show the edited text when the value is not present', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findNoteEditedText().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('emits "updateNote" event to update markdown when toggling checkbox', () => {
|
||||
const markdownBefore = `beginning
|
||||
|
||||
- [ ] one
|
||||
- [ ] two
|
||||
- [ ] three
|
||||
|
||||
end`;
|
||||
const markdownAfter = `beginning
|
||||
|
||||
- [x] one
|
||||
- [ ] two
|
||||
- [ ] three
|
||||
|
||||
end`;
|
||||
const note = {
|
||||
...mockWorkItemCommentNote,
|
||||
body: markdownBefore,
|
||||
bodyHtml:
|
||||
'<p data-sourcepos="1:1-1:9" dir="auto">beginning</p>
<ul data-sourcepos="3:1-6:0" class="task-list" dir="auto">
<li data-sourcepos="3:1-3:9" class="task-list-item">
<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> one</li>
<li data-sourcepos="4:1-4:9" class="task-list-item">
<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> two</li>
<li data-sourcepos="5:1-6:0" class="task-list-item">
<task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> three</li>
</ul>
<p data-sourcepos="7:1-7:3" dir="auto">end</p>',
|
||||
};
|
||||
createComponent({ note });
|
||||
const checkbox = wrapper.find('.task-list-item-checkbox').element;
|
||||
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new CustomEvent('change', { bubbles: true }));
|
||||
|
||||
expect(wrapper.emitted('updateNote')).toEqual([
|
||||
[{ commentText: markdownAfter, executeOptimisticResponse: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates checkbox state when "isUpdating" watcher updates', async () => {
|
||||
createComponent();
|
||||
await nextTick();
|
||||
const checkboxes = Array.from(wrapper.element.querySelectorAll('.task-list-item-checkbox'));
|
||||
|
||||
expect(checkboxes.every((checkbox) => checkbox.disabled === false)).toBe(true);
|
||||
|
||||
await wrapper.setProps({ isUpdating: true });
|
||||
|
||||
expect(checkboxes.every((checkbox) => checkbox.disabled === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -219,6 +219,28 @@ describe('Work Item Note', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when toggling a checkbox in a note', () => {
|
||||
it('calls mutation to update the description', () => {
|
||||
const commentText = `beginning
|
||||
|
||||
- [x] one
|
||||
- [ ] two
|
||||
- [ ] three
|
||||
|
||||
end`;
|
||||
createComponent();
|
||||
|
||||
findNoteBody().vm.$emit('updateNote', { commentText });
|
||||
|
||||
expect(successHandler).toHaveBeenCalledWith({
|
||||
input: {
|
||||
id: mockWorkItemCommentNote.id,
|
||||
body: commentText,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when not editing', () => {
|
||||
it('should not render a comment form', () => {
|
||||
createComponent();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,31 @@ RSpec.describe Ci::PipelineSchedulesHelper, feature_category: :continuous_integr
|
|||
|
||||
describe '#js_pipeline_schedules_form_data' do
|
||||
before do
|
||||
allow(helper).to receive(:timezone_data).and_return(timezones)
|
||||
allow(helper).to receive_messages(timezone_data: timezones, current_user: user)
|
||||
allow(project.team).to receive(:human_max_access).with(user.id).and_return('Owner')
|
||||
end
|
||||
|
||||
describe 'user_role' do
|
||||
context 'when there is no current user' do
|
||||
before do
|
||||
allow(helper).to receive(:current_user).and_return(nil)
|
||||
end
|
||||
|
||||
it 'is nil' do
|
||||
expect(helper.js_pipeline_schedules_form_data(project, pipeline_schedule)[:user_role]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there is a current_user' do
|
||||
before do
|
||||
allow(helper).to receive(:current_user).and_return(user)
|
||||
allow(project.team).to receive(:human_max_access).with(user.id).and_return('Developer')
|
||||
end
|
||||
|
||||
it "returns the human readable access level that the current user has in the project" do
|
||||
expect(helper.js_pipeline_schedules_form_data(project, pipeline_schedule)[:user_role]).to eq('Developer')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns pipeline schedule form data' do
|
||||
|
|
@ -24,7 +48,8 @@ RSpec.describe Ci::PipelineSchedulesHelper, feature_category: :continuous_integr
|
|||
project_id: project.id,
|
||||
schedules_path: pipeline_schedules_path(project),
|
||||
settings_link: project_settings_ci_cd_path(project),
|
||||
timezone_data: timezones.to_json
|
||||
timezone_data: timezones.to_json,
|
||||
user_role: 'Owner'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -131,29 +131,31 @@ RSpec.describe Ci::PipelinesHelper, feature_category: :continuous_integration do
|
|||
:project_refs_endpoint,
|
||||
:settings_link,
|
||||
:max_warnings,
|
||||
:is_maintainer
|
||||
:user_role
|
||||
)
|
||||
end
|
||||
|
||||
describe 'is_maintainer' do
|
||||
subject(:data) { helper.new_pipeline_data(project)[:is_maintainer] }
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
describe 'user_role' do
|
||||
context 'when there is no current user' do
|
||||
it 'is nil' do
|
||||
expect(helper.new_pipeline_data(project)[:user_role]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is signed in but not a maintainer' do
|
||||
it { expect(subject).to be_falsy }
|
||||
end
|
||||
context 'when there is a current_user' do
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
context 'when user is signed in with a role that is maintainer or above' do
|
||||
before_all do
|
||||
project.add_maintainer(user)
|
||||
project.add_developer(user)
|
||||
end
|
||||
|
||||
it { expect(subject).to be_truthy }
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it "returns the human readable access level that the current user has in the pipeline's project" do
|
||||
expect(helper.new_pipeline_data(project)[:user_role]).to eq('Developer')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Blame do
|
||||
RSpec.describe Gitlab::Blame, feature_category: :source_code_management do
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
|
||||
let(:path) { 'files/ruby/popen.rb' }
|
||||
let(:commit) { project.commit('master') }
|
||||
let(:blob) { project.repository.blob_at(commit.id, path) }
|
||||
let(:range) { nil }
|
||||
let(:ignore_revs) { nil }
|
||||
|
||||
subject(:blame) { described_class.new(blob, commit, range: range) }
|
||||
subject(:blame) { described_class.new(blob, commit, range: range, ignore_revs: ignore_revs) }
|
||||
|
||||
describe '#first_line' do
|
||||
subject { blame.first_line }
|
||||
|
|
@ -114,5 +115,22 @@ RSpec.describe Gitlab::Blame do
|
|||
expect(subject[1][:lines]).to match_array(['Renamed as "filename"'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with ignore_revs' do
|
||||
let(:ignore_revs) { true }
|
||||
let(:git_blame_double) { instance_double(Gitlab::Git::Blame, each: nil) }
|
||||
|
||||
it 'requests for a blame with the default ignore revs file' do
|
||||
expect(Gitlab::Git::Blame).to receive(:new).with(
|
||||
project.repository,
|
||||
commit.id,
|
||||
blob.path,
|
||||
range: range,
|
||||
ignore_revisions_blob: 'refs/heads/master:.git-blame-ignore-revs'
|
||||
).and_return(git_blame_double)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ RSpec.describe Gitlab::Git::Blame, feature_category: :source_code_management do
|
|||
let(:sha) { TestEnv::BRANCH_SHA['master'] }
|
||||
let(:path) { 'CONTRIBUTING.md' }
|
||||
let(:range) { nil }
|
||||
let(:ignore_revisions_blob) { nil }
|
||||
|
||||
subject(:blame) { described_class.new(repository, sha, path, range: range) }
|
||||
subject(:blame) do
|
||||
described_class.new(repository, sha, path, range: range, ignore_revisions_blob: ignore_revisions_blob)
|
||||
end
|
||||
|
||||
let(:result) do
|
||||
[].tap do |data|
|
||||
|
|
@ -119,5 +122,25 @@ RSpec.describe Gitlab::Git::Blame, feature_category: :source_code_management do
|
|||
expect(result[4]).to include(line: 'Last edit, no rename', previous_path: path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with ignore_revisions_blob' do
|
||||
let(:ignore_revisions_blob) { 'reference_to_a_blob' }
|
||||
let(:commit_client_double) { instance_double(Gitlab::GitalyClient::CommitService, list_commits_by_oid: []) }
|
||||
|
||||
before do
|
||||
allow(repository).to receive(:gitaly_commit_client).and_return(commit_client_double)
|
||||
end
|
||||
|
||||
it 'requests raw blame with ignore_revisions_blob' do
|
||||
expect(commit_client_double).to receive(:raw_blame).with(
|
||||
sha,
|
||||
path,
|
||||
range: range,
|
||||
ignore_revisions_blob: ignore_revisions_blob
|
||||
).and_return('')
|
||||
|
||||
blame
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2248,6 +2248,54 @@ RSpec.describe Ci::Runner, type: :model, factory_default: :keep, feature_categor
|
|||
end
|
||||
end
|
||||
|
||||
describe '#registration_available?' do
|
||||
subject { runner.registration_available? }
|
||||
|
||||
let(:runner) { build(:ci_runner, *runner_traits, registration_type: registration_type) }
|
||||
|
||||
context 'with runner created in UI' do
|
||||
let(:registration_type) { :authenticated_user }
|
||||
|
||||
context 'with runner created within registration deadline' do
|
||||
let(:runner_traits) { [:online, :created_before_registration_deadline] + extra_runner_traits }
|
||||
|
||||
context 'with runner creation not finished' do
|
||||
let(:extra_runner_traits) { [:unregistered] }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context 'with runner creation finished' do
|
||||
let(:extra_runner_traits) { [] }
|
||||
|
||||
it { is_expected.to be_falsy }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with runner created almost too long ago' do
|
||||
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context 'with runner created too long ago' do
|
||||
let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }
|
||||
|
||||
it { is_expected.to be_falsy }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with runner registered from command line' do
|
||||
let(:registration_type) { :registration_token }
|
||||
|
||||
context 'with runner created within registration deadline' do
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it { is_expected.to be_falsy }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#dot_com_gitlab_hosted?' do
|
||||
subject(:dot_com_gitlab_hosted) { runner.dot_com_gitlab_hosted? }
|
||||
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
|
||||
describe 'ephemeralRegisterUrl' do
|
||||
let(:runner_args) { { registration_type: :authenticated_user, creator: creator } }
|
||||
let(:runner_traits) { [] }
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
|
|
@ -598,24 +599,24 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
|
||||
context 'with an instance runner' do
|
||||
let(:creator) { user }
|
||||
let(:runner) { create(:ci_runner, **runner_args) }
|
||||
let(:runner) { create(:ci_runner, *runner_traits, **runner_args) }
|
||||
|
||||
context 'with valid ephemeral registration' do
|
||||
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'has register url' do
|
||||
let(:expected_url) { "http://localhost/admin/runners/#{runner.id}/register" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when runner ephemeral registration has expired' do
|
||||
let(:runner) do
|
||||
create(:ci_runner, created_at: Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago, **runner_args)
|
||||
end
|
||||
let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }
|
||||
|
||||
it_behaves_like 'has no register url'
|
||||
end
|
||||
|
||||
context 'when runner has already been registered' do
|
||||
let(:runner) { create(:ci_runner, :with_runner_manager, **runner_args) }
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'has no register url'
|
||||
end
|
||||
|
|
@ -623,43 +624,47 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
|
||||
context 'with a group runner' do
|
||||
let(:creator) { user }
|
||||
let(:runner) { create(:ci_runner, :group, groups: [group], **runner_args) }
|
||||
let(:runner) { create(:ci_runner, *runner_traits, :group, groups: [group], **runner_args) }
|
||||
|
||||
context 'with valid ephemeral registration' do
|
||||
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'has register url' do
|
||||
let(:expected_url) { "http://localhost/groups/#{group.path}/-/runners/#{runner.id}/register" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request not from creator' do
|
||||
let(:creator) { another_admin }
|
||||
context 'when request not from creator' do
|
||||
let(:creator) { another_admin }
|
||||
|
||||
before do
|
||||
group.add_owner(another_admin)
|
||||
before do
|
||||
group.add_owner(another_admin)
|
||||
end
|
||||
|
||||
it_behaves_like 'has no register url'
|
||||
end
|
||||
|
||||
it_behaves_like 'has no register url'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a project runner' do
|
||||
let(:creator) { user }
|
||||
let(:runner) { create(:ci_runner, :project, projects: [project1], **runner_args) }
|
||||
let(:runner) { create(:ci_runner, *runner_traits, :project, projects: [project1], **runner_args) }
|
||||
|
||||
context 'with valid ephemeral registration' do
|
||||
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'has register url' do
|
||||
let(:expected_url) { "http://localhost/#{project1.full_path}/-/runners/#{runner.id}/register" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request not from creator' do
|
||||
let(:creator) { another_admin }
|
||||
context 'when request not from creator' do
|
||||
let(:creator) { another_admin }
|
||||
|
||||
before do
|
||||
project1.add_owner(another_admin)
|
||||
before do
|
||||
project1.add_owner(another_admin)
|
||||
end
|
||||
|
||||
it_behaves_like 'has no register url'
|
||||
end
|
||||
|
||||
it_behaves_like 'has no register url'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -844,10 +849,11 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
describe 'ephemeralAuthenticationToken' do
|
||||
subject(:request) { post_graphql(query, current_user: user) }
|
||||
|
||||
let_it_be(:creator) { create(:user) }
|
||||
let_it_be(:creator) { create(:user, owner_of: group) }
|
||||
|
||||
let(:created_at) { Time.current }
|
||||
let(:token_prefix) { registration_type == :authenticated_user ? 'glrt-' : '' }
|
||||
let(:runner_traits) { [] }
|
||||
let(:registration_type) {}
|
||||
let(:query) do
|
||||
%(
|
||||
|
|
@ -864,18 +870,14 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
create(
|
||||
:ci_runner,
|
||||
:group,
|
||||
*runner_traits,
|
||||
groups: [group],
|
||||
creator: creator,
|
||||
created_at: created_at,
|
||||
registration_type: registration_type,
|
||||
token: "#{token_prefix}abc123"
|
||||
)
|
||||
end
|
||||
|
||||
before_all do
|
||||
group.add_owner(creator) # Allow creating runners in the group
|
||||
end
|
||||
|
||||
shared_examples 'an ephemeral_authentication_token' do
|
||||
it 'returns token in ephemeral_authentication_token field' do
|
||||
request
|
||||
|
|
@ -902,28 +904,30 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
context 'with runner created in UI' do
|
||||
let(:registration_type) { :authenticated_user }
|
||||
|
||||
context 'with runner created in last hour' do
|
||||
let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
|
||||
context 'with runner created within registration deadline' do
|
||||
let(:runner_traits) { [:created_before_registration_deadline] + extra_runner_traits }
|
||||
|
||||
context 'with runner creation not finished' do
|
||||
let(:runner_traits) { [:unregistered] }
|
||||
|
||||
context 'with no runner manager registered yet' do
|
||||
it_behaves_like 'an ephemeral_authentication_token'
|
||||
end
|
||||
|
||||
context 'with first runner manager already registered' do
|
||||
let!(:runner_manager) { create(:ci_runner_machine, runner: runner) }
|
||||
context 'with runner creation finished' do
|
||||
let(:runner_traits) { [] }
|
||||
|
||||
it_behaves_like 'a protected ephemeral_authentication_token'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with runner created almost too long ago' do
|
||||
let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
|
||||
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'an ephemeral_authentication_token'
|
||||
end
|
||||
|
||||
context 'with runner created too long ago' do
|
||||
let(:created_at) { Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago }
|
||||
let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }
|
||||
|
||||
it_behaves_like 'a protected ephemeral_authentication_token'
|
||||
end
|
||||
|
|
@ -932,8 +936,8 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
context 'with runner registered from command line' do
|
||||
let(:registration_type) { :registration_token }
|
||||
|
||||
context 'with runner created in last 1 hour' do
|
||||
let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago }
|
||||
context 'with runner created within registration deadline' do
|
||||
let(:runner_traits) { [:created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'a protected ephemeral_authentication_token'
|
||||
end
|
||||
|
|
@ -945,6 +949,7 @@ RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibi
|
|||
|
||||
context 'with runner created in UI' do
|
||||
let(:registration_type) { :authenticated_user }
|
||||
let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }
|
||||
|
||||
it_behaves_like 'a protected ephemeral_authentication_token'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -893,6 +893,7 @@ module GraphqlHelpers
|
|||
allow(selection).to receive(:selection).and_return(selection)
|
||||
allow(selection).to receive(:selections).and_return(selection)
|
||||
allow(selection).to receive(:map).and_return(double(include?: true))
|
||||
allow(selection).to receive_message_chain(:field, :type, :list?).and_return(false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ RSpec.describe 'Every Sidekiq worker', feature_category: :shared do
|
|||
'Search::Zoekt::TaskFailedEventWorker' => 1,
|
||||
'Search::Zoekt::UpdateIndexUsedStorageBytesEventWorker' => 1,
|
||||
'Search::Zoekt::SaasRolloutEventWorker' => 1,
|
||||
'Security::ProcessScanEventsWorker' => 3,
|
||||
'Security::StoreScansWorker' => 3,
|
||||
'Security::TrackSecureScansWorker' => 1,
|
||||
'ServiceDeskEmailReceiverWorker' => 3,
|
||||
|
|
|
|||
|
|
@ -1437,10 +1437,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.123.0.tgz#1fa3b1a709755ff7c8ef67e18c0442101655ebf0"
|
||||
integrity sha512-yjVn+utOTIKk8d9JlvGo6EgJ4TQ+CKpe3RddflAqtsQqQuL/2MlVdtaUePybxYzWIaumFuh5LouQ6BrWyw1niQ==
|
||||
|
||||
"@gitlab/ui@108.10.0":
|
||||
version "108.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-108.10.0.tgz#9434054c9e44b395604b2d55416c83c84fdd09ac"
|
||||
integrity sha512-xfrDcA/UKlhVEGJAhvdiK1qLy7GEABiRWhJmjkoBZRMjr4Q6dpjqVlr8s7hQf9FMdI6tMVbbV3bGBuMPsGNzLA==
|
||||
"@gitlab/ui@109.0.0":
|
||||
version "109.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-109.0.0.tgz#349f26cc90b57c066ab8c00017545270fc843b4f"
|
||||
integrity sha512-WHJeEoigQWbSz0NCwZj9KGRhWZsSGBgabMZmeGkuGsxzIvNP6oIV8roCt8EbeVYbvXmjWA6D6wtxIU2t6RE1iw==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "1.4.3"
|
||||
echarts "^5.3.2"
|
||||
|
|
|
|||
Loading…
Reference in New Issue