Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-07-24 21:07:24 +00:00
parent 329f63356a
commit 92bcd7dce0
68 changed files with 795 additions and 382 deletions

View File

@ -73,6 +73,18 @@ export default {
value: { data: queryStringValue, operator: OPERATOR_IS },
},
];
case 'name':
if (!this.glFeatures.populateAndUseBuildNamesTable) {
return acc;
}
return [
...acc,
{
type: 'filtered-search-term',
value: { data: queryStringValue },
},
];
default:
return acc;
}
@ -91,6 +103,16 @@ export default {
<template>
<gl-filtered-search
v-if="glFeatures.populateAndUseBuildNamesTable"
:placeholder="s__('Jobs|Search or filter jobs...')"
:available-tokens="tokens"
:value="filteredSearchValue"
:search-text-option-label="__('Search for this text')"
terms-as-tokens
@submit="onSubmit"
/>
<gl-filtered-search
v-else
:placeholder="s__('Jobs|Filter jobs')"
:available-tokens="tokens"
:value="filteredSearchValue"

View File

@ -15,6 +15,9 @@ export const validateQueryString = (queryStringObj) => {
const runnerTypesValueValid = jobRunnerTypeValues.includes(runnerTypesValue);
return runnerTypesValueValid ? { ...acc, runnerTypes: runnerTypesValue } : acc;
}
case 'name': {
return { ...acc, name: queryStringValue };
}
default:
return acc;
}

View File

@ -1,7 +1,13 @@
query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) {
query getJobs(
$fullPath: ID!
$after: String
$first: Int = 30
$statuses: [CiJobStatus!]
$name: String
) {
project(fullPath: $fullPath) {
id
jobs(after: $after, first: $first, statuses: $statuses) {
jobs(after: $after, first: $first, statuses: $statuses, name: $name) {
pageInfo {
endCursor
hasNextPage

View File

@ -1,7 +1,7 @@
query getJobsCount($fullPath: ID!, $statuses: [CiJobStatus!]) {
query getJobsCount($fullPath: ID!, $statuses: [CiJobStatus!], $name: String) {
project(fullPath: $fullPath) {
id
jobs(statuses: $statuses) {
jobs(statuses: $statuses, name: $name) {
count
}
}

View File

@ -6,6 +6,7 @@ import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_util
import JobsSkeletonLoader from '~/ci/admin/jobs_table/components/jobs_skeleton_loader.vue';
import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue';
import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import GetJobsCount from './graphql/queries/get_jobs_count.query.graphql';
import JobsTable from './components/jobs_table.vue';
@ -31,6 +32,7 @@ export default {
GlLoadingIcon,
JobsSkeletonLoader,
},
mixins: [glFeatureFlagsMixin()],
inject: {
fullPath: {
default: '',
@ -83,6 +85,7 @@ export default {
filterSearchTriggered: false,
jobsCount: null,
count: 0,
requestData: {},
};
},
computed: {
@ -127,11 +130,18 @@ export default {
},
},
methods: {
updateHistoryAndFetchCount(status = null) {
this.$apollo.queries.jobsCount.refetch({ statuses: status });
resetRequestData() {
if (this.glFeatures.populateAndUseBuildNamesTable) {
this.requestData = { statuses: null, name: null };
} else {
this.requestData = { statuses: null };
}
},
updateHistoryAndFetchCount() {
this.$apollo.queries.jobsCount.refetch(this.requestData);
updateHistory({
url: setUrlParams({ statuses: status }, window.location.href, true),
url: setUrlParams(this.requestData, window.location.href, true),
});
},
fetchJobsByStatus(scope) {
@ -141,6 +151,8 @@ export default {
this.scope = scope;
this.resetRequestData();
if (!this.scope) this.updateHistoryAndFetchCount();
this.$apollo.queries.jobs.refetch({ statuses: scope });
@ -149,33 +161,31 @@ export default {
this.infiniteScrollingTriggered = false;
this.filterSearchTriggered = true;
// all filters have been cleared reset query param
// and refetch jobs/count with defaults
if (!filters.length) {
this.updateHistoryAndFetchCount();
this.$apollo.queries.jobs.refetch({ statuses: null });
return;
}
// Eventually there will be more tokens available
// this code is written to scale for those tokens
filters.forEach((filter) => {
// Raw text input in filtered search does not have a type
// when a user enters raw text we alert them that it is
// not supported and we do not make an additional API call
if (!filter.type) {
createAlert({
message: RAW_TEXT_WARNING,
variant: 'warning',
});
if (this.glFeatures.populateAndUseBuildNamesTable) {
this.requestData.name = filter;
} else {
createAlert({
message: RAW_TEXT_WARNING,
variant: 'warning',
});
}
}
if (filter.type === 'status') {
this.updateHistoryAndFetchCount(filter.value.data);
this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
this.requestData.statuses = filter.value.data;
}
});
// all filters have been cleared reset query params
// and refetch jobs/count with defaults
if (!filters.length) {
this.resetRequestData();
}
this.$apollo.queries.jobs.refetch(this.requestData);
this.updateHistoryAndFetchCount();
},
fetchMoreJobs() {
if (!this.loading) {

View File

@ -280,7 +280,11 @@ export default {
>
</div>
</div>
<node-view-content ref="nodeViewContent" as="code" class="gl-relative gl-z-1" />
<node-view-content
ref="nodeViewContent"
as="code"
class="gl-relative gl-z-1 !gl-break-words"
/>
</node-view-wrapper>
</editor-state-observer>
</template>

View File

@ -76,7 +76,7 @@ export default {
'Environments|Are you sure you want to delete %{podName}? This action cannot be undone.',
),
buttonPrimary: s__('Environments|Delete pod'),
buttonCancel: s__('Environments|Cancel'),
buttonCancel: __('Cancel'),
success: s__('Environments|Pod deleted successfully'),
error: __('Error: '),
},
@ -84,7 +84,12 @@ export default {
};
</script>
<template>
<gl-modal v-model="visible" :modal-id="$options.DELETE_POD_MODAL_ID" @hide="hideModal">
<gl-modal
v-model="visible"
:modal-id="$options.DELETE_POD_MODAL_ID"
:aria-label="$options.i18n.buttonPrimary"
@hide="hideModal"
>
<template #modal-title>
<gl-sprintf :message="$options.i18n.title">
<template #podName>

View File

@ -106,6 +106,7 @@ export default {
fluxApiError: '',
selectedItem: {},
showDetailsDrawer: false,
focusedElement: null,
podToDelete: {},
};
},
@ -173,6 +174,7 @@ export default {
openDetailsDrawer(item) {
this.selectedItem = item;
this.showDetailsDrawer = true;
this.focusedElement = document.activeElement;
this.$nextTick(() => {
this.$refs.drawer?.$el?.querySelector('button')?.focus();
});
@ -181,7 +183,7 @@ export default {
this.showDetailsDrawer = false;
this.selectedItem = {};
this.$nextTick(() => {
this.$refs.status_bar?.$refs?.flux_status_badge?.$el?.focus();
this.focusedElement?.focus();
});
},
onDeletePod(pod) {
@ -261,7 +263,7 @@ export default {
</h2>
</template>
<template #default>
<workload-details v-if="hasSelectedItem" :item="selectedItem" />
<workload-details v-if="hasSelectedItem" :item="selectedItem" @delete-pod="onDeletePod" />
</template>
</gl-drawer>
</div>

View File

@ -48,8 +48,9 @@ export default {
actions: [
{
name: 'delete-pod',
text: s__('KubernetesDashboard|Delete Pod'),
text: s__('KubernetesDashboard|Delete pod'),
icon: 'remove',
variant: 'danger',
class: '!gl-text-red-500',
},
],

View File

@ -227,7 +227,6 @@ export default {
<template v-else>
<gl-badge
:id="fluxBadgeId"
ref="flux_status_badge"
:icon="syncStatusBadge.icon"
:variant="syncStatusBadge.variant"
data-testid="sync-badge"

View File

@ -1,5 +1,5 @@
<script>
import { GlBadge, GlTruncate } from '@gitlab/ui';
import { GlBadge, GlTruncate, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { stringify } from 'yaml';
import { s__ } from '~/locale';
import PodLogsButton from '~/environments/environment_details/components/kubernetes/pod_logs_button.vue';
@ -10,9 +10,13 @@ export default {
components: {
GlBadge,
GlTruncate,
GlButton,
WorkloadDetailsItem,
PodLogsButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
item: {
type: Object,
@ -44,6 +48,9 @@ export default {
hasContainers() {
return Boolean(this.item.containers);
},
hasActions() {
return Boolean(this.item.actions?.length);
},
},
methods: {
getLabelBadgeText([key, value]) {
@ -65,6 +72,7 @@ export default {
},
i18n: {
name: s__('KubernetesDashboard|Name'),
actions: s__('KubernetesDashboard|Actions'),
kind: s__('KubernetesDashboard|Kind'),
labels: s__('KubernetesDashboard|Labels'),
status: s__('KubernetesDashboard|Status'),
@ -82,6 +90,20 @@ export default {
<workload-details-item :label="$options.i18n.name">
<span class="gl-break-anywhere"> {{ item.name }}</span>
</workload-details-item>
<workload-details-item v-if="hasActions" :label="$options.i18n.actions">
<span v-for="action of item.actions" :key="action.name">
<gl-button
v-gl-tooltip
:title="action.text"
:aria-label="action.text"
:variant="action.variant"
:icon="action.icon"
category="secondary"
class="gl-mr-3"
@click="$emit(action.name, item)"
/>
</span>
</workload-details-item>
<workload-details-item :label="$options.i18n.kind">
{{ item.kind }}
</workload-details-item>

View File

@ -425,12 +425,19 @@ export function relativePathToAbsolute(path, basePath) {
}
/**
* Checks if the provided URL is a safe URL (absolute http(s) or root-relative URL)
* Checks if the provided URL is a valid URL. Valid URLs are
* - absolute URLs (`http(s)://...`)
* - root-relative URLs (`/path/...`)
* - parsable by the `URL` constructor
* - has http or https protocol
*
* Relative URLs (`../path`), queries (`?...`), and hashes (`#...`) are not
* considered valid.
*
* @param {String} url that will be checked
* @returns {Boolean}
*/
export function isSafeURL(url) {
export function isValidURL(url) {
if (!isAbsoluteOrRootRelative(url)) {
return false;
}
@ -450,7 +457,7 @@ export function isSafeURL(url) {
* @returns {String}
*/
export function sanitizeUrl(url) {
if (!isSafeURL(url)) {
if (!isValidURL(url)) {
return 'about:blank';
}
return url;
@ -721,7 +728,7 @@ export const removeLastSlashInUrlPath = (url) =>
* Navigates to a URL.
*
* If destination is a querystring, it will be automatically transformed into a fully qualified URL.
* If the URL is not a safe URL (see isSafeURL implementation), this function will log an exception into Sentry.
* If the URL is not valid (see isValidURL implementation), this function will log an exception into Sentry.
* If the URL is external it calls window.open so it has no referrer header or reference to its opener.
*
* @param {*} destination - url to navigate to. This can be a fully qualified URL or a querystring.
@ -736,7 +743,7 @@ export function visitUrl(destination, openWindow = false) {
url = currentUrl.toString();
}
if (!isSafeURL(url)) {
if (!isValidURL(url)) {
throw new RangeError(`Only http and https protocols are allowed: ${url}`);
}
@ -757,7 +764,7 @@ export function visitUrl(destination, openWindow = false) {
* Navigates to a URL and display alerts.
*
* If destination is a querystring, it will be automatically transformed into a fully qualified URL.
* If the URL is not a safe URL (see isSafeURL implementation), this function will log an exception into Sentry.
* If the URL is not valid (see isValidURL implementation), this function will log an exception into Sentry.
*
* @param {*} destination - url to navigate to. This can be a fully qualified URL or a querystring.
* @param {{id: String, title?: String, message: String, variant: String, dismissible?: Boolean, persistOnPages?: String[]}[]} alerts - Alerts to display

View File

@ -1,7 +1,9 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { s__, sprintf } from '~/locale';
import importSourceUsersQuery from '../graphql/queries/import_source_users.query.graphql';
import PlaceholdersTable from './placeholders_table.vue';
@ -21,6 +23,8 @@ export default {
data() {
return {
selectedTabIndex: 0,
unassignedCount: null,
reassignedCount: null,
cursor: {
before: null,
after: null,
@ -50,10 +54,7 @@ export default {
},
computed: {
tabCount() {
// WIP: https://gitlab.com/groups/gitlab-org/-/epics/12378
return 0;
},
...mapState('placeholder', ['pagination']),
isLoading() {
return Boolean(this.$apollo.queries.sourceUsers.loading);
},
@ -65,7 +66,23 @@ export default {
},
},
mounted() {
this.unassignedCount = this.pagination.awaitingReassignmentItems;
this.reassignedCount = this.pagination.reassignedItems;
},
methods: {
onConfirm(item) {
this.$toast.show(
sprintf(s__('UserMapping|Placeholder %{name} (@%{username}) kept as placeholder.'), {
name: item.placeholderUser.name,
username: item.placeholderUser.username,
}),
);
this.reassignedCount += 1;
this.unassignedCount -= 1;
},
onPrevPage() {
this.cursor = {
before: this.sourceUsers.pageInfo.startCursor,
@ -88,7 +105,7 @@ export default {
<gl-tab>
<template #title>
<span>{{ s__('UserMapping|Awaiting reassignment') }}</span>
<gl-badge class="gl-tab-counter-badge">{{ tabCount }}</gl-badge>
<gl-badge class="gl-tab-counter-badge">{{ unassignedCount || 0 }}</gl-badge>
</template>
<placeholders-table
@ -96,6 +113,7 @@ export default {
:items="nodes"
:page-info="pageInfo"
:is-loading="isLoading"
@confirm="onConfirm"
@prev="onPrevPage"
@next="onNextPage"
/>
@ -104,11 +122,11 @@ export default {
<gl-tab>
<template #title>
<span>{{ s__('UserMapping|Reassigned') }}</span>
<gl-badge class="gl-tab-counter-badge">{{ tabCount }}</gl-badge>
<gl-badge class="gl-tab-counter-badge">{{ reassignedCount || 0 }}</gl-badge>
</template>
<placeholders-table
key="assigned"
key="reassigned"
reassigned
:items="nodes"
:page-info="pageInfo"

View File

@ -12,6 +12,7 @@ import {
PLACEHOLDER_STATUS_AWAITING_APPROVAL,
PLACEHOLDER_STATUS_REASSIGNING,
} from '~/import_entities/import_groups/constants';
import importSourceUsersQuery from '../graphql/queries/import_source_users.query.graphql';
import importSourceUserReassignMutation from '../graphql/mutations/reassign.mutation.graphql';
import importSourceUserKeepAsPlaceholderMutation from '../graphql/mutations/keep_as_placeholder.mutation.graphql';
import importSourceUseResendNotificationMutation from '../graphql/mutations/resend_notification.mutation.graphql';
@ -241,21 +242,26 @@ export default {
onConfirm() {
this.isValidated = true;
if (!this.userSelectInvalid) {
const hasSelectedUser = Boolean(this.selectedUser.id);
this.isConfirmLoading = true;
this.$apollo
.mutate({
mutation: this.selectedUser.id
mutation: hasSelectedUser
? importSourceUserReassignMutation
: importSourceUserKeepAsPlaceholderMutation,
variables: {
id: this.sourceUser.id,
...(this.selectedUser.id ? { userId: this.selectedUser.id } : {}),
...(hasSelectedUser ? { userId: this.selectedUser.id } : {}),
},
// importSourceUsersQuery used in app.vue
refetchQueries: [hasSelectedUser ? {} : importSourceUsersQuery],
})
.then(({ data }) => {
const { errors } = getFirstPropertyValue(data);
if (errors?.length) {
createAlert({ message: errors.join() });
} else if (!hasSelectedUser) {
this.$emit('confirm');
}
})
.catch(() => {

View File

@ -98,6 +98,10 @@ export default {
return {};
},
onConfirm(item) {
this.$emit('confirm', item);
},
},
};
</script>
@ -142,7 +146,7 @@ export default {
:label="reassginedUser(item).name"
:sub-label="`@${reassginedUser(item).username}`"
/>
<placeholder-actions v-else :source-user="item" />
<placeholder-actions v-else :source-user="item" @confirm="onConfirm(item)" />
</template>
</gl-table>

View File

@ -6,7 +6,7 @@ import {
convertToSentenceCase,
splitCamelCase,
} from '~/lib/utils/text_utility';
import { isSafeURL } from '~/lib/utils/url_utility';
import { isValidURL } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
@ -97,7 +97,7 @@ export default {
return allowedFields.includes(fieldName);
},
isValidLink(value) {
return typeof value === 'string' && isSafeURL(value);
return typeof value === 'string' && isValidURL(value);
},
},
};

View File

@ -3,7 +3,7 @@ import { GlButton, GlForm, GlFormGroup, GlFormInput, GlModal } from '@gitlab/ui'
// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions } from 'vuex';
import { __, s__, sprintf } from '~/locale';
import { isSafeURL } from '~/lib/utils/url_utility';
import { isValidURL } from '~/lib/utils/url_utility';
export default {
components: {
@ -63,7 +63,7 @@ export default {
: this.$options.i18n.uploadTitle;
},
isUrlValid() {
return this.modalUrl === '' || isSafeURL(this.modalUrl);
return this.modalUrl === '' || isValidURL(this.modalUrl);
},
},
methods: {

View File

@ -205,7 +205,7 @@ export default {
data-testid="user-popover"
>
<template v-if="userCannotMerge" #title>
<div class="gl-pb-3 gl-display-flex gl-align-items-center" data-testid="cannot-merge">
<div class="gl-flex gl-items-center gl-pb-3" data-testid="cannot-merge">
<gl-icon name="warning-solid" class="gl-mr-2 gl-text-orange-400" />
<span class="gl-font-normal">{{ __('Cannot merge') }}</span>
</div>
@ -232,7 +232,7 @@ export default {
<template v-else>
<gl-button
v-if="shouldRenderToggleFollowButton"
class="gl-mt-3 gl-align-self-start"
class="gl-mt-3 gl-self-start"
:variant="toggleFollowButtonVariant"
:loading="toggleFollowLoading"
size="small"
@ -245,7 +245,7 @@ export default {
<template #meta>
<span
v-if="hasPronouns"
class="gl-text-gray-500 gl-font-sm gl-font-normal gl-p-1"
class="gl-p-1 gl-text-sm gl-font-normal gl-text-gray-500"
data-testid="user-popover-pronouns"
>({{ user.pronouns }})</span
>
@ -267,25 +267,25 @@ export default {
<template v-else>
<template v-if="!isBlocked">
<div class="gl-text-gray-500">
<div v-if="user.email" class="gl-display-flex gl-mb-2">
<div v-if="user.email" class="gl-mb-2 gl-flex">
<gl-icon name="mail" class="gl-flex-shrink-0" />
<span ref="email" class="gl-ml-2">{{ user.email }}</span>
</div>
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
<div v-if="user.bio" class="gl-mb-2 gl-flex">
<gl-icon name="profile" class="gl-flex-shrink-0" />
<span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
<div v-if="user.workInformation" class="gl-mb-2 gl-flex">
<gl-icon name="work" class="gl-flex-shrink-0" />
<span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
</div>
<div v-if="user.location" class="gl-display-flex gl-mb-2">
<div v-if="user.location" class="gl-mb-2 gl-flex">
<gl-icon name="location" class="gl-flex-shrink-0" />
<span class="gl-ml-2">{{ user.location }}</span>
</div>
<div
v-if="user.localTime && !user.bot"
class="gl-display-flex gl-mb-2"
class="gl-mb-2 gl-flex"
data-testid="user-popover-local-time"
>
<gl-icon name="clock" class="gl-flex-shrink-0" />

View File

@ -311,7 +311,7 @@ export default {
<template>
<gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch">
<template #header>
<p class="gl-font-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<p class="gl-mb-4 gl-mt-2 gl-text-center gl-font-bold">{{ headerText }}</p>
<gl-dropdown-divider />
<gl-search-box-by-type
ref="search"
@ -325,7 +325,7 @@ export default {
v-if="isLoading"
data-testid="loading-participants"
size="md"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
class="gl-absolute gl-left-0 gl-right-0 gl-top-0"
/>
<template v-else>
<template v-if="shouldShowParticipants">
@ -364,7 +364,7 @@ export default {
<sidebar-participant
:user="currentUser"
:issuable-type="issuableType"
class="gl-pl-6!"
class="!gl-pl-6"
/>
</gl-dropdown-item>
</template>
@ -376,7 +376,7 @@ export default {
<sidebar-participant
:user="issuableAuthor"
:issuable-type="issuableType"
class="gl-pl-6!"
class="!gl-pl-6"
/>
</gl-dropdown-item>
<gl-dropdown-item
@ -391,10 +391,10 @@ export default {
<sidebar-participant
:user="unselectedUser"
:issuable-type="issuableType"
class="gl-pl-6!"
class="!gl-pl-6"
/>
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="!gl-pl-6">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>

View File

@ -217,7 +217,7 @@ export default {
@keydown.esc.stop="cancelEditing"
/>
</comment-field-layout>
<div class="note-form-actions">
<div class="note-form-actions" data-testid="work-item-comment-form-actions">
<gl-form-checkbox
v-if="isNewDiscussion"
v-model="isNoteInternal"

View File

@ -5,7 +5,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isNumeric } from '~/lib/utils/number_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { isSafeURL } from '~/lib/utils/url_utility';
import { isValidURL } from '~/lib/utils/url_utility';
import { highlighter } from 'ee_else_ce/gfm_auto_complete';
@ -114,7 +114,7 @@ export default {
return this.isSearchingByReference ? this.workItemsByReference : this.workspaceWorkItems;
},
isSearchingByReference() {
return isReference(this.searchTerm) || isSafeURL(this.searchTerm);
return isReference(this.searchTerm) || isValidURL(this.searchTerm);
},
workItemsToAdd: {
get() {

View File

@ -211,6 +211,7 @@ export default {
<gl-modal
modal-id="time-tracking-report"
data-testid="time-tracking-report-modal"
hide-footer
size="lg"
:title="__('Time tracking report')"

View File

@ -61,7 +61,7 @@ hr {
code {
padding: 2px 4px;
color: $code-color;
background-color: $gray-50;
background-color: var(--gl-background-color-strong);
border-radius: $gl-border-radius-base;
.code > &,

View File

@ -23,6 +23,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase]
before_action :push_ai_build_failure_cause, only: [:show]
before_action :push_filter_by_name, only: [:index]
layout 'project'
feature_category :continuous_integration
@ -281,6 +282,10 @@ class Projects::JobsController < Projects::ApplicationController
def push_ai_build_failure_cause
push_frontend_feature_flag(:ai_build_failure_cause, @project)
end
def push_filter_by_name
push_frontend_feature_flag(:populate_and_use_build_names_table, @project)
end
end
Projects::JobsController.prepend_mod_with('Projects::JobsController')

View File

@ -63,6 +63,7 @@ class Project < ApplicationRecord
BoardLimitExceeded = Class.new(StandardError)
ExportLimitExceeded = Class.new(StandardError)
EPOCH_CACHE_EXPIRATION = 30.days
STATISTICS_ATTRIBUTE = 'repositories_count'
UNKNOWN_IMPORT_URL = 'http://unknown.git'
# Hashed Storage versions handle rolling out new storage to project and dependents models:
@ -3371,8 +3372,36 @@ class Project < ApplicationRecord
false
end
def lfs_file_locks_changed_epoch
get_epoch_from(lfs_file_locks_changed_epoch_cache_key)
end
def refresh_lfs_file_locks_changed_epoch
refresh_epoch_cache(lfs_file_locks_changed_epoch_cache_key)
end
private
def with_redis(&block)
Gitlab::Redis::Cache.with(&block)
end
def lfs_file_locks_changed_epoch_cache_key
"project:#{id}:lfs_file_locks_changed_epoch"
end
def get_epoch_from(cache_key)
with_redis { |redis| redis.get(cache_key) }&.to_i || refresh_epoch_cache(cache_key)
end
def refresh_epoch_cache(cache_key)
# %s = seconds since the Unix Epoch
# %L = milliseconds of the second
Time.current.strftime('%s%L').to_i.tap do |epoch|
with_redis { |redis| redis.set(cache_key, epoch, ex: EPOCH_CACHE_EXPIRATION) }
end
end
# overridden in EE
def project_group_links_with_preload
project_group_links

View File

@ -25,8 +25,9 @@ module Lfs
# rubocop: enable CodeReuse/ActiveRecord
def create_lock!
lock = project.lfs_file_locks.create!(user: current_user,
path: params[:path])
lock = project.lfs_file_locks.create!(user: current_user, path: params[:path])
project.refresh_lfs_file_locks_changed_epoch
success(http_status: 201, lock: lock)
end

View File

@ -24,6 +24,8 @@ module Lfs
if lock.can_be_unlocked_by?(current_user, forced)
lock.destroy!
project.refresh_lfs_file_locks_changed_epoch
success(lock: lock, http_status: :ok)
elsif forced
error(_('You must have maintainer access to force delete a lock'), 403)

View File

@ -22,7 +22,7 @@
- if can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
.row.gl-pt-4
%div{ class: (has_due_date ? "col-lg-6" : "col-12") }
.form-group.row.merge-request-assignee
.form-group.row{ data: { testid: 'merge-request-assignee' } }
= render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
- if issuable.allows_reviewers?

View File

@ -486,7 +486,7 @@ References:
##### Vue
- [isSafeURL](https://gitlab.com/gitlab-org/gitlab/-/blob/v12.7.5-ee/app/assets/javascripts/lib/utils/url_utility.js#L190-207)
- [isValidURL](https://gitlab.com/gitlab-org/gitlab/-/blob/v17.3.0-ee/app/assets/javascripts/lib/utils/url_utility.js#L427-451)
- [GlSprintf](https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/utilities-sprintf--sentence-with-link)
#### Content Security Policy

View File

@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# GitLab Advisory Database
The [GitLab Advisory Database](https://gitlab.com/gitlab-org/security-products/gemnasium-db) serves as a repository for security advisories related to software dependencies.
The [GitLab Advisory Database](https://gitlab.com/gitlab-org/security-products/gemnasium-db) serves as a repository for security advisories related to software dependencies. It is updated on an hourly basis with the latest security advisories.
The database is an essential component of both [Dependency Scanning](../dependency_scanning/index.md) and [Container Scanning](../container_scanning/index.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -53,9 +53,9 @@ This project provides you with:
To create a GitLab agent for Kubernetes:
1. On the left sidebar, select **Operate > Kubernetes clusters**.
1. Select **Connect a cluster (agent)**.
1. From the **Select an agent** dropdown list, select `civo-agent` and select **Register an agent**.
1. GitLab generates a registration token for the agent. Securely store this secret token, as you will need it later.
1. Select **Connect a cluster**.
1. From the **Select an agent** dropdown list, select `civo-agent` and select **Register**.
1. GitLab generates an agent access token for the agent. Securely store this secret token, as you will need it later.
1. GitLab provides an address for the agent server (KAS), which you will also need later.
## Configure your project
@ -66,7 +66,7 @@ Use CI/CD environment variables to configure your project.
1. On the left sidebar, select **Settings > CI/CD**.
1. Expand **Variables**.
1. Set the variable `BASE64_CIVO_TOKEN` to the token from your Civo account.
1. Set the variable `CIVO_TOKEN` to the token from your Civo account.
1. Set the variable `TF_VAR_agent_token` to the agent token you received in the previous task.
1. Set the variable `TF_VAR_kas_address` to the agent server address in the previous task.
@ -92,14 +92,17 @@ Refer to the [Civo Terraform provider](https://registry.terraform.io/providers/c
After configuring your project, manually trigger the provisioning of your cluster. In GitLab:
1. On the left sidebar, go to **Build > Pipelines**.
1. Next to **Play** (**{play}**), select the dropdown list icon (**{chevron-lg-down}**).
1. Select **Deploy** to manually trigger the deployment job.
1. On the left sidebar, select **Build > Pipelines**.
1. Select **Run pipeline**, and then select the newly created pipeline from the list.
1. Next to the **deploy** job, select **Manual action** (**{status_manual}**).
When the pipeline finishes successfully, you can see your new cluster:
- In Civo dashboard: on your Kubernetes tab.
- In GitLab: from your project's sidebar, select **Operate > Kubernetes clusters**.
If you didn't set the `TF_VAR_civo_region` variable, the cluster will be created in the 'lon1' region.
## Use your cluster
After you provision the cluster, it is connected to GitLab and is ready for deployments. To check the connection:
@ -111,28 +114,12 @@ For more information about the capabilities of the connection, see [the GitLab a
## Remove the cluster
A cleanup job is not included in your pipeline by default. To remove all created resources, you
must modify your GitLab CI/CD template before running the cleanup job.
A cleanup job is included in your pipeline by default.
To remove all resources:
To remove all created resources:
1. Add the following to your `.gitlab-ci.yml` file:
```yaml
stages:
- init
- validate
- build
- deploy
- cleanup
destroy:
extends: .destroy
needs: []
```
1. On the left sidebar, select **Build > Pipelines** and select the most recent pipeline.
1. For the `destroy` job, select **Play** (**{play}**).
1. On the left sidebar, select **Build > Pipelines**, and then select the most recent pipeline.
1. Next to the **destroy-environment** job, select **Manual action** (**{status_manual}**).
## Civo support

View File

@ -37,41 +37,6 @@ Provide feedback on this feature in [issue 443236](https://gitlab.com/gitlab-org
**Data usage**: The diff of changes between the source branch's head and the target branch is sent to the large language model.
## Generate a description from a template
DETAILS:
**Tier:** For a limited time, Ultimate. In the future, [GitLab Duo Enterprise](../../../subscriptions/subscription-add-ons.md).
**Offering:** GitLab.com
**Status:** Beta
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/10591) in GitLab 16.3 as an [experiment](../../../policy/experiment-beta-support.md#experiment).
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/429882) to beta in GitLab 16.10.
Many projects include [templates](../description_templates.md#create-a-merge-request-template)
that you populate when you create a merge request. These templates help populate the description
of the merge request. They can help the team conform to standards, and help reviewers
and others understand the purpose and changes proposed in the merge request.
When you create a merge request, GitLab Duo Merge request template population
can generate a description for your merge request, based on the contents of the template.
GitLab Duo fills in the template and replaces the contents of the description.
To use GitLab Duo to generate a merge request description:
1. [Create a new merge request](creating_merge_requests.md) and go to the **Description** field.
1. Select **GitLab Duo** (**{tanuki-ai}**).
1. Select **Fill in merge request template**.
The updated description is applied. You can edit or revise the description before you finish creating your merge request.
Provide feedback on this experimental feature in [issue 416537](https://gitlab.com/gitlab-org/gitlab/-/issues/416537).
**Data usage**: When you use this feature, the following data is sent to the large language model referenced above:
- Title of the merge request
- Contents of the description
- Diff of changes between the source branch's head and the target branch
## Summarize a code review
DETAILS:

View File

@ -207,6 +207,12 @@ check in directly to a protected branch:
1. From the **Allowed to push and merge** list, select **No one**.
1. Select **Protect**.
Alternatively, you can [create](repository/branches/index.md#create-a-branch-rule) or [edit](repository/branches/index.md#edit-a-branch-rule) a branch rule. Then:
1. Select **Edit** in the **Allowed to merge** section.
1. Select **Developers and Maintainers**.
1. Select **Save changes**.
## Allow everyone to push directly to a protected branch
You can allow everyone with write access to push to the protected branch.
@ -219,6 +225,12 @@ You can allow everyone with write access to push to the protected branch.
1. From the **Allowed to push and merge** list, select **Developers + Maintainers**.
1. Select **Protect**.
Alternatively, you can [create](repository/branches/index.md#create-a-branch-rule) or [edit](repository/branches/index.md#edit-a-branch-rule) a branch rule. Then:
1. Select **Edit** in the **Allowed to push and merge** section.
1. Select **Developers and Maintainers**.
1. Select **Save changes**.
## Allow deploy keys to push to a protected branch
> - More restrictions on deploy keys [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/425926) in GitLab 16.5 [with a flag](../../administration/feature_flags.md) named `check_membership_in_protected_ref_access`. Disabled by default.
@ -273,6 +285,10 @@ To enable force pushes on branches that are already protected:
1. Select **Add protected branch**.
1. In the list of protected branches, next to the branch, turn on the **Allowed to force push** toggle.
Alternatively, you can [create](repository/branches/index.md#create-a-branch-rule) or [edit](repository/branches/index.md#edit-a-branch-rule) a branch rule. Then:
1. In the list of protected branches, next to the branch, turn on the **Allowed to force push** toggle.
Members who can push to this branch can now also force push.
### When a branch matches multiple rules
@ -328,6 +344,10 @@ To enable Code Owner's approval on branches that are already protected:
1. Select **Add protected branch**.
1. In the list of protected branches, next to the branch, turn on the **Code owner approval** toggle.
Alternatively, you can [create](repository/branches/index.md#create-a-branch-rule) or [edit](repository/branches/index.md#edit-a-branch-rule) a branch rule.
Then, in the list of protected branches, next to the branch,
turn on the **Code owner approval** toggle.
When enabled, all merge requests for these branches require approval
by a Code Owner per matched rule before they can be merged.
Additionally, direct pushes to the protected branch are denied if a rule is matched.

View File

@ -101,14 +101,14 @@ To create a branch from an issue:
GitLab provides multiple methods to protect individual branches. These methods
ensure your branches receive oversight and quality checks from their creation to their deletion:
- The [default branch](default.md) in your project receives extra protection.
- Configure [protected branches](../../protected_branches.md)
to restrict who can commit to a branch, merge other branches into it, or merge
the branch itself into another branch.
- Configure [approval rules](../../merge_requests/approvals/rules.md) to set review
requirements, including [security-related approvals](../../merge_requests/approvals/rules.md#security-approvals), before a branch can merge.
- Apply enhanced security and protection to your project's [default branch](default.md).
- Configure [protected branches](../../protected_branches.md) to:
- Limit who can push and merge to a branch.
- Manage if users can force push to the branch.
- Manage if changes to files listed in the `CODEOWNERS` file can be pushed directly to the branch.
- Configure [approval rules](../../merge_requests/approvals/rules.md#approvals-for-protected-branches) to manage review requirements and implement [security-related approvals](../../merge_requests/approvals/rules.md#security-approvals).
- Integrate with third-party [status checks](../../merge_requests/status_checks.md)
to ensure your branch contents meet your standards of quality.
to ensure the contents of your branch meets your defined quality standards.
You can manage your branches:
@ -133,20 +133,13 @@ On this page, you can:
- [Compare branches](#compare-branches).
- Delete merged branches.
### View branches with configured protections
### View branch rules
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88279) in GitLab 15.1 with a flag named `branch_rules`. Disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/363170) in GitLab 15.10.
> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/363170) in GitLab 15.11.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123368) in GitLab 16.1. Feature flag `branch_rules` removed.
Branches in your repository can be [protected](../../protected_branches.md) in multiple ways. You can:
- Limit who can push to the branch.
- Limit who can merge the branch.
- Require approval of all changes.
- Require external tests to pass.
The **Branch rules overview** page shows all branches with any configured protections,
and their protection methods:
@ -168,7 +161,7 @@ To view the **Branch rules overview** list:
1. Identify the branch you want more information about.
1. Select **View details** to see information about its:
- [Branch protections](../../protected_branches.md).
- [Approval rules](../../merge_requests/approvals/rules.md).
- [Approval rules](../../merge_requests/approvals/rules.md#approvals-for-protected-branches).
- [Status checks](../../merge_requests/status_checks.md).
#### Create a branch rule
@ -223,7 +216,7 @@ To edit a branch rule:
1. Expand **Branch rules**.
1. Next to a rule you want to edit, select **View details**.
1. In the upper-right corner, select **Edit**.
1. In the dialog, from the **Create branch rule** dropdown list, select a branch name or create a wildcard by typing `*`.
1. Edit the information as needed.
1. Select **Update**.
#### Delete a branch rule

View File

@ -1,15 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Area Panel Example'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Server Statistics'
panels:
- title: Average amount of time spent by the CPU
type: area-chart
metrics:
- query_range: 'rate(node_cpu_seconds_total[15m])'
unit: 'Seconds'
label: "Time in Seconds"

View File

@ -1,24 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Single Stat'
# This is where all of the variables that can be manipulated via the UI
# are initialized
# Check out: https://docs.gitlab.com/ee/operations/metrics/dashboards/templating_variables.html#templating-variables-for-metrics-dashboards-core
templating:
variables:
job: 'prometheus'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Memory'
panels:
- title: Prometheus
type: single-stat
metrics:
# Queries that make use of variables need to have double curly brackets {}
# set to the variables, per the example below
- query: 'max(go_memstats_alloc_bytes{job="{{job}}"}) / 1024 /1024'
unit: '%'
label: "Max"

View File

@ -1,23 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Gauge Panel Example'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Server Statistics'
panels:
- title: "Memory usage"
# More information about gauge panel types can be found here:
# https://docs.gitlab.com/ee/operations/metrics/dashboards/panel_types.html#gauge
type: "gauge-chart"
min_value: 0
max_value: 1024
split: 10
thresholds:
mode: "percentage"
values: [60, 90]
format: "megabytes"
metrics:
- query: '(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) / 1024 / 1024'
unit: 'MB'

View File

@ -1,3 +0,0 @@
# Development guide for Metrics Dashboard templates
Please follow [the development guideline](../../../../doc/development/operations/metrics/templates.md)

View File

@ -1,15 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Area Panel Example'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Server Statistics'
panels:
- title: "Core Usage (Pod Average)"
type: area-chart
metrics:
- query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod)) OR avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}[15m])) by (pod_name))'
unit: 'cores'
label: "Pod Average (in seconds)"

View File

@ -1,23 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Gauge K8s Panel Example'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Server Statistics'
panels:
- title: "Memory usage"
# More information about gauge panel types can be found here:
# https://docs.gitlab.com/ee/operations/metrics/dashboards/panel_types.html#gauge
type: "gauge-chart"
min_value: 0
max_value: 1024
split: 10
thresholds:
mode: "percentage"
values: [60, 90]
format: "megabytes"
metrics:
- query: 'avg(sum(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024 OR avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024'
unit: 'MB'

View File

@ -1,17 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Single Stat Panel Example'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Server Statistics'
panels:
- title: "Memory usage"
# More information about heatmap panel types can be found here:
# https://docs.gitlab.com/ee/operations/metrics/dashboards/panel_types.html#single-stat
type: "single-stat"
metrics:
- query: 'avg(sum(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container!="POD",pod=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024 OR avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^{{ci_environment_slug}}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="{{kube_namespace}}"}) without (job)) /1024/1024'
unit: 'MB'
label: "Used memory"

View File

@ -1,17 +0,0 @@
# Only one dashboard should be defined per file
# More info: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html
dashboard: 'Heatmap Panel Example'
# For more information about the required properties of panel_groups
# please visit: https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-group-panel_groups-properties
panel_groups:
- group: 'Server Statistics'
panels:
- title: "Memory usage"
# More information about heatmap panel types can be found here:
# https://docs.gitlab.com/ee/operations/metrics/dashboards/panel_types.html#single-stat
type: "single-stat"
metrics:
- query: '(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) / 1024 / 1024'
unit: 'MB'
label: "Used memory"

View File

@ -20483,9 +20483,6 @@ msgstr ""
msgid "Environments|Auto stops %{autoStopAt}"
msgstr ""
msgid "Environments|Cancel"
msgstr ""
msgid "Environments|Clean up"
msgstr ""
@ -29972,6 +29969,9 @@ msgstr ""
msgid "Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens."
msgstr ""
msgid "Jobs|Search or filter jobs..."
msgstr ""
msgid "Jobs|Stage"
msgstr ""
@ -30323,6 +30323,9 @@ msgstr ""
msgid "Kubernetes deployment not found"
msgstr ""
msgid "KubernetesDashboard|Actions"
msgstr ""
msgid "KubernetesDashboard|Age"
msgstr ""
@ -30356,7 +30359,7 @@ msgstr ""
msgid "KubernetesDashboard|Dashboard"
msgstr ""
msgid "KubernetesDashboard|Delete Pod"
msgid "KubernetesDashboard|Delete pod"
msgstr ""
msgid "KubernetesDashboard|Deployment"
@ -57809,6 +57812,9 @@ msgstr ""
msgid "UserMapping|Pending approval"
msgstr ""
msgid "UserMapping|Placeholder %{name} (@%{username}) kept as placeholder."
msgstr ""
msgid "UserMapping|Placeholder user"
msgstr ""

View File

@ -31,7 +31,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
expect_to_be_on_explore_projects_page
find('body.page-initialised .js-dismiss-current-broadcast-notification').click
find(".js-dismiss-current-broadcast-notification[data-id='#{broadcast_message.id}']").click
expect_message_dismissed
end
@ -41,7 +41,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
expect_to_be_on_explore_projects_page
find('body.page-initialised .js-dismiss-current-broadcast-notification').click
find(".js-dismiss-current-broadcast-notification[data-id='#{broadcast_message.id}']").click
expect_message_dismissed
@ -57,7 +57,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
expect_to_be_on_explore_projects_page
find('body.page-initialised .js-dismiss-current-broadcast-notification').click
find(".js-dismiss-current-broadcast-notification[data-id='#{broadcast_message.id}']").click
expect_message_dismissed
@ -79,7 +79,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
it 'is not dismissible' do
visit path
expect(page).not_to have_selector('.js-dismiss-current-broadcast-notification')
expect(page).not_to have_selector(".js-dismiss-current-broadcast-notification[data-id=#{broadcast_message.id}]")
end
it 'does not replace placeholders' do
@ -127,7 +127,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
visit path
expect_broadcast_message(text)
expect_broadcast_message(message.id, text)
# seed the other cache
original_strategy_value = Gitlab::Cache::JsonCache::STRATEGY_KEY_COMPONENTS
@ -135,7 +135,7 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
page.refresh
expect_broadcast_message(text)
expect_broadcast_message(message.id, text)
# delete on original cache
stub_const('Gitlab::Cache::JsonCaches::JsonKeyed::STRATEGY_KEY_COMPONENTS', original_strategy_value)
@ -153,27 +153,27 @@ RSpec.describe 'Broadcast Messages', feature_category: :notifications do
visit path
expect_no_broadcast_message
expect_no_broadcast_message(message.id)
# other revision of GitLab does gets cache destroyed
stub_const('Gitlab::Cache::JsonCaches::JsonKeyed::STRATEGY_KEY_COMPONENTS', new_strategy_value)
page.refresh
expect_no_broadcast_message
expect_no_broadcast_message(message.id)
end
end
def expect_broadcast_message(text)
within_testid('banner-broadcast-message') do
def expect_broadcast_message(id, text)
within(".js-broadcast-notification-#{id}") do
expect(page).to have_content text
end
end
def expect_no_broadcast_message
def expect_no_broadcast_message(id)
expect_to_be_on_explore_projects_page
expect(page).not_to have_selector('[data-testid="banner-broadcast-message"]')
expect(page).not_to have_selector(".js-broadcast-notification-#{id}")
end
def expect_to_be_on_explore_projects_page

View File

@ -353,7 +353,7 @@ RSpec.describe 'GFM autocomplete', :js, feature_category: :team_planning do
end
end
context 'issues' do
context 'issues', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/471790' do
let(:object) { issue }
let(:expected_body) { object.to_reference }

View File

@ -50,8 +50,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js, :with_license, feature_categor
wait_for_all_requests
click_button label.name
click_button 'Close'
page.within(labels_widget) do
click_button label.name
click_button 'Close'
end
wait_for_requests

View File

@ -59,7 +59,7 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do
end
it 'adds a new child task', :aggregate_failures do
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(104)
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(108)
within_testid('work-item-links') do
click_button 'Add'
@ -79,7 +79,7 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do
end
it 'removes a child task and undoing', :aggregate_failures do
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(104)
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(108)
within_testid('work-item-links') do
click_button 'Add'
click_button 'New task'

View File

@ -28,7 +28,7 @@ describe('Jobs filtered search', () => {
...props,
},
provide: {
glFeatures: { adminJobsFilterRunnerType: true },
glFeatures: { adminJobsFilterRunnerType: true, populateAndUseBuildNamesTable: true },
...provideOptions,
},
});
@ -40,6 +40,19 @@ describe('Jobs filtered search', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
it('displays filtered search placeholder', () => {
createComponent();
expect(findFilteredSearch().props('placeholder')).toBe('Search or filter jobs...');
});
it('displays filtered search text label', () => {
createComponent();
expect(findFilteredSearch().props('searchTextOptionLabel')).toBe('Search for this text');
expect(findFilteredSearch().props('termsAsTokens')).toBe(true);
});
it('displays status token', () => {
createComponent();
@ -82,7 +95,11 @@ describe('Jobs filtered search', () => {
const tokenRunnerTypesValue = 'INSTANCE_VALUE';
createComponent({
queryString: { statuses: tokenStatusesValue, runnerTypes: tokenRunnerTypesValue },
queryString: {
statuses: tokenStatusesValue,
runnerTypes: tokenRunnerTypesValue,
name: 'rspec',
},
});
expect(findFilteredSearch().props('value')).toEqual([
@ -91,6 +108,12 @@ describe('Jobs filtered search', () => {
type: TOKEN_TYPE_JOBS_RUNNER_TYPE,
value: { data: tokenRunnerTypesValue, operator: '=' },
},
{
type: 'filtered-search-term',
value: {
data: 'rspec',
},
},
]);
});
});
@ -120,4 +143,37 @@ describe('Jobs filtered search', () => {
});
});
});
describe('when feature flag `populateAndUseBuildNamesTable` is disabled', () => {
const provideOptions = { glFeatures: { populateAndUseBuildNamesTable: false } };
describe('with query string passed', () => {
it('filtered search returns only data shape for search token `status`', () => {
const tokenStatusesValue = 'SUCCESS';
const tokenRunnerTypesValue = 'INSTANCE_VALUE';
createComponent(
{
queryString: {
statuses: tokenStatusesValue,
runnerTypes: tokenRunnerTypesValue,
name: 'rspec',
},
},
provideOptions,
);
expect(findFilteredSearch().props('value')).toEqual([
{ type: TOKEN_TYPE_STATUS, value: { data: tokenStatusesValue, operator: '=' } },
]);
});
});
it('displays legacy filtered search attributes', () => {
createComponent({}, provideOptions);
expect(findFilteredSearch().props('placeholder')).toBe('Filter jobs');
expect(findFilteredSearch().props('termsAsTokens')).toBe(false);
});
});
});

View File

@ -12,6 +12,7 @@ describe('Filtered search utils', () => {
${{ wrong: 'SUCCESS' }} | ${null}
${{ statuses: 'wrong' }} | ${null}
${{ wrong: 'wrong' }} | ${null}
${{ name: 'rspec' }} | ${{ name: 'rspec' }}
`(
'when provided $queryStringObject, the expected result is $expected',
({ queryStringObject, expected }) => {

View File

@ -27,6 +27,8 @@ Vue.use(VueApollo);
jest.mock('~/alert');
const mockJobName = 'rspec-job';
describe('Job table app', () => {
let wrapper;
@ -60,10 +62,14 @@ describe('Job table app', () => {
handler = successHandler,
countHandler = countSuccessHandler,
mountFn = shallowMount,
flagState = false,
} = {}) => {
wrapper = mountFn(JobsTableApp, {
provide: {
fullPath: projectPath,
glFeatures: {
populateAndUseBuildNamesTable: flagState,
},
},
apolloProvider: createMockApolloProvider(handler, countHandler),
});
@ -246,6 +252,22 @@ describe('Job table app', () => {
},
);
it('filters jobs by status', async () => {
createComponent();
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
expect(successHandler).toHaveBeenCalledWith({
first: 30,
fullPath: 'gitlab-org/gitlab',
statuses: 'FAILED',
});
expect(countSuccessHandler).toHaveBeenCalledWith({
fullPath: 'gitlab-org/gitlab',
statuses: 'FAILED',
});
});
it('refetches jobs query when filtering', async () => {
createComponent();
@ -334,5 +356,87 @@ describe('Job table app', () => {
statuses: null,
});
});
describe('with feature flag populateAndUseBuildNamesTable enabled', () => {
beforeEach(() => {
createComponent({ flagState: true });
});
it('filters jobs by name', async () => {
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockJobName]);
expect(successHandler).toHaveBeenCalledWith({
first: 30,
fullPath: 'gitlab-org/gitlab',
name: mockJobName,
});
expect(countSuccessHandler).toHaveBeenCalledWith({
fullPath: 'gitlab-org/gitlab',
name: mockJobName,
});
});
it('updates URL query string when filtering jobs by name', async () => {
jest.spyOn(urlUtils, 'updateHistory');
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockJobName]);
expect(urlUtils.updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/?name=${mockJobName}`,
});
});
it('updates URL query string when filtering jobs by name and status', async () => {
jest.spyOn(urlUtils, 'updateHistory');
await findFilteredSearch().vm.$emit('filterJobsBySearch', [
mockFailedSearchToken,
mockJobName,
]);
expect(urlUtils.updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/?statuses=FAILED&name=${mockJobName}`,
});
});
it('resets query param after clearing tokens', () => {
jest.spyOn(urlUtils, 'updateHistory');
findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken, mockJobName]);
expect(successHandler).toHaveBeenCalledWith({
first: 30,
fullPath: 'gitlab-org/gitlab',
statuses: 'FAILED',
name: mockJobName,
});
expect(countSuccessHandler).toHaveBeenCalledWith({
fullPath: 'gitlab-org/gitlab',
statuses: 'FAILED',
name: mockJobName,
});
expect(urlUtils.updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/?statuses=FAILED&name=${mockJobName}`,
});
findFilteredSearch().vm.$emit('filterJobsBySearch', []);
expect(urlUtils.updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/`,
});
expect(successHandler).toHaveBeenCalledWith({
first: 30,
fullPath: 'gitlab-org/gitlab',
statuses: null,
name: null,
});
expect(countSuccessHandler).toHaveBeenCalledWith({
fullPath: 'gitlab-org/gitlab',
statuses: null,
name: null,
});
});
});
});
});

View File

@ -377,6 +377,16 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
expect(findDeletePodModal().props('pod')).toEqual(podToDelete);
});
it('provides correct pod when emitted from the details drawer', async () => {
const podToDelete = mockPodsTableItems[1];
findKubernetesTabs().vm.$emit('show-resource-details', mockPodsTableItems[1]);
await nextTick();
findWorkloadDetails().vm.$emit('delete-pod', podToDelete);
await nextTick();
expect(findDeletePodModal().props('pod')).toEqual(podToDelete);
});
});
describe('on child component error', () => {

View File

@ -106,8 +106,9 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_po
const actions = [
{
name: 'delete-pod',
text: 'Delete Pod',
text: 'Delete pod',
icon: 'remove',
variant: 'danger',
class: '!gl-text-red-500',
},
];

View File

@ -374,9 +374,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
});
describe('deleteKubernetesPod', () => {
const mockPodsDeleteFn = jest.fn().mockImplementation(() => {
return Promise.resolve({ errors: [] });
});
const mockPodsDeleteFn = jest.fn().mockResolvedValue({ errors: [] });
const podToDelete = 'my-pod';
it('should request delete pod API from the cluster_client library', async () => {

View File

@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
import { GlBadge, GlTruncate } from '@gitlab/ui';
import { GlBadge, GlTruncate, GlButton } from '@gitlab/ui';
import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
import WorkloadDetailsItem from '~/kubernetes_dashboard/components/workload_details_item.vue';
import { WORKLOAD_STATUS_BADGE_VARIANTS } from '~/kubernetes_dashboard/constants';
@ -25,6 +25,8 @@ const findAllBadges = () => wrapper.findAllComponents(GlBadge);
const findBadge = (at) => findAllBadges().at(at);
const findAllPodLogsButtons = () => wrapper.findAllComponents(PodLogsButton);
const findPodLogsButton = (at) => findAllPodLogsButtons().at(at);
const findAllButtons = () => wrapper.findAllComponents(GlButton);
const findButton = (at) => findAllButtons().at(at);
describe('Workload details component', () => {
describe('when minimal fields are provided', () => {
@ -74,6 +76,48 @@ describe('Workload details component', () => {
expect(findWorkloadDetailsItem(index).props('collapsible')).toBe(true);
});
describe('when actions are provided', () => {
const actions = [
{
name: 'delete-pod',
text: 'Delete pod',
icon: 'remove',
variant: 'danger',
},
];
const mockTableItemsWithActions = {
...mockPodsTableItems[0],
actions,
};
beforeEach(() => {
createWrapper(mockTableItemsWithActions);
});
it('renders a non-collapsible list item for containers', () => {
expect(findWorkloadDetailsItem(1).props('label')).toBe('Actions');
expect(findWorkloadDetailsItem(1).props('collapsible')).toBe(false);
});
it('renders a button for each action', () => {
expect(findAllButtons()).toHaveLength(1);
});
it.each(actions)('renders a button with the correct props', (action) => {
const currentIndex = actions.indexOf(action);
expect(findButton(currentIndex).props()).toMatchObject({
variant: action.variant,
icon: action.icon,
});
expect(findButton(currentIndex).attributes()).toMatchObject({
title: action.text,
'aria-label': action.text,
});
});
});
describe('when containers are provided', () => {
const mockTableItemsWithContainers = {
...mockPodsTableItems[0],

View File

@ -157,7 +157,7 @@ describe('Workload table component', () => {
});
it('renders correct props for each dropdown', () => {
expect(findAllActionsDropdowns().at(0).attributes('title')).toEqual('Actions');
expect(findAllActionsDropdowns().at(0).attributes('title')).toBe('Actions');
expect(findAllActionsDropdowns().at(0).props('items')).toMatchObject([
{
text: 'Delete Pod',

View File

@ -38,8 +38,8 @@ const encodedJavaScriptUrls = [
'\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029',
];
export const safeUrls = [...absoluteUrls, ...rootRelativeUrls];
export const unsafeUrls = [
export const validURLs = [...absoluteUrls, ...rootRelativeUrls];
export const invalidURLs = [
...relativeUrls,
...urlsWithoutHost,
...nonHttpUrls,

View File

@ -2,7 +2,7 @@ import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as urlUtils from '~/lib/utils/url_utility';
import { setGlobalAlerts } from '~/lib/utils/global_alerts';
import { safeUrls, unsafeUrls } from './mock_data';
import { validURLs, invalidURLs } from './mock_data';
jest.mock('~/lib/utils/global_alerts', () => ({
getGlobalAlerts: jest.fn().mockImplementation(() => [
@ -781,24 +781,24 @@ describe('URL utility', () => {
);
});
describe('isSafeUrl', () => {
describe('isValidURL', () => {
describe('with URL constructor support', () => {
it.each(safeUrls)('returns true for %s', (url) => {
expect(urlUtils.isSafeURL(url)).toBe(true);
it.each(validURLs)('returns true for %s', (url) => {
expect(urlUtils.isValidURL(url)).toBe(true);
});
it.each(unsafeUrls)('returns false for %s', (url) => {
expect(urlUtils.isSafeURL(url)).toBe(false);
it.each(invalidURLs)('returns false for %s', (url) => {
expect(urlUtils.isValidURL(url)).toBe(false);
});
});
});
describe('sanitizeUrl', () => {
it.each(safeUrls)('returns the url for %s', (url) => {
it.each(validURLs)('returns the url for %s', (url) => {
expect(urlUtils.sanitizeUrl(url)).toBe(url);
});
it.each(unsafeUrls)('returns `about:blank` for %s', (url) => {
it.each(invalidURLs)('returns `about:blank` for %s', (url) => {
expect(urlUtils.sanitizeUrl(url)).toBe('about:blank');
});
});

View File

@ -1,7 +1,9 @@
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import { GlTabs } from '@gitlab/ui';
import { GlTab, GlTabs } from '@gitlab/ui';
import { createAlert } from '~/alert';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@ -9,13 +11,16 @@ import waitForPromises from 'helpers/wait_for_promises';
import PlaceholdersTabApp from '~/members/placeholders/components/app.vue';
import PlaceholdersTable from '~/members/placeholders/components/placeholders_table.vue';
import importSourceUsersQuery from '~/members/placeholders/graphql/queries/import_source_users.query.graphql';
import { mockSourceUsersQueryResponse } from '../mock_data';
import { MEMBERS_TAB_TYPES } from '~/members/constants';
import { mockSourceUsersQueryResponse, mockSourceUsers, pagination } from '../mock_data';
Vue.use(Vuex);
Vue.use(VueApollo);
jest.mock('~/alert');
describe('PlaceholdersTabApp', () => {
let wrapper;
let store;
let mockApollo;
const mockGroup = {
@ -23,19 +28,37 @@ describe('PlaceholdersTabApp', () => {
name: 'Imported group',
};
const sourceUsersQueryHandler = jest.fn().mockResolvedValue(mockSourceUsersQueryResponse());
const $toast = {
show: jest.fn(),
};
const createComponent = ({ queryHandler = sourceUsersQueryHandler } = {}) => {
store = new Vuex.Store({
modules: {
[MEMBERS_TAB_TYPES.placeholder]: {
namespaced: true,
state: {
pagination,
},
},
},
});
mockApollo = createMockApollo([[importSourceUsersQuery, queryHandler]]);
wrapper = shallowMount(PlaceholdersTabApp, {
apolloProvider: mockApollo,
store,
provide: {
group: mockGroup,
},
mocks: { $toast },
stubs: { GlTab },
});
};
const findTabs = () => wrapper.findComponent(GlTabs);
const findTabAt = (index) => wrapper.findAllComponents(GlTab).at(index);
const findPlaceholdersTable = () => wrapper.findComponent(PlaceholdersTable);
it('renders tabs', () => {
@ -44,6 +67,41 @@ describe('PlaceholdersTabApp', () => {
expect(findTabs().exists()).toBe(true);
});
it('renders tab titles with counts', async () => {
createComponent();
await nextTick();
expect(findTabAt(0).text()).toBe(
`Awaiting reassignment ${pagination.awaitingReassignmentItems}`,
);
expect(findTabAt(1).text()).toBe(`Reassigned ${pagination.reassignedItems}`);
});
describe('on table "confirm" event', () => {
const mockSourceUser = mockSourceUsers[1];
beforeEach(async () => {
createComponent();
await nextTick();
findPlaceholdersTable().vm.$emit('confirm', mockSourceUser);
await nextTick();
});
it('updates tab counts', () => {
expect(findTabAt(0).text()).toBe(
`Awaiting reassignment ${pagination.awaitingReassignmentItems - 1}`,
);
expect(findTabAt(1).text()).toBe(`Reassigned ${pagination.reassignedItems + 1}`);
});
it('shows toast', () => {
expect($toast.show).toHaveBeenCalledWith(
'Placeholder Placeholder 2 (@placeholder_2) kept as placeholder.',
);
});
});
describe('when sourceUsers query is loading', () => {
it('renders placeholders table as loading', () => {
createComponent();
@ -86,12 +144,12 @@ describe('PlaceholdersTabApp', () => {
});
it('renders placeholders table', () => {
const mockSourceUsers = mockSourceUsersQueryResponse().data.namespace.importSourceUsers;
const sourceUsers = mockSourceUsersQueryResponse().data.namespace.importSourceUsers;
expect(findPlaceholdersTable().props()).toMatchObject({
isLoading: false,
items: mockSourceUsers.nodes,
pageInfo: mockSourceUsers.pageInfo,
items: sourceUsers.nodes,
pageInfo: sourceUsers.pageInfo,
});
});
});

View File

@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import PlaceholderActions from '~/members/placeholders/components/placeholder_actions.vue';
import searchUsersQuery from '~/graphql_shared/queries/users_search_all_paginated.query.graphql';
import importSourceUsersQuery from '~/members/placeholders/graphql/queries/import_source_users.query.graphql';
import importSourceUserReassignMutation from '~/members/placeholders/graphql/mutations/reassign.mutation.graphql';
import importSourceUserKeepAsPlaceholderMutation from '~/members/placeholders/graphql/mutations/keep_as_placeholder.mutation.graphql';
import importSourceUserResendNotificationMutation from '~/members/placeholders/graphql/mutations/resend_notification.mutation.graphql';
@ -15,6 +16,7 @@ import importSourceUserCancelReassignmentMutation from '~/members/placeholders/g
import {
mockSourceUsers,
mockSourceUsersQueryResponse,
mockReassignMutationResponse,
mockKeepAsPlaceholderMutationResponse,
mockResendNotificationMutationResponse,
@ -36,6 +38,7 @@ describe('PlaceholderActions', () => {
sourceUser: mockSourceUsers[0],
};
const usersQueryHandler = jest.fn().mockResolvedValue(mockUsersQueryResponse);
const sourceUsersQueryHandler = jest.fn().mockResolvedValue(mockSourceUsersQueryResponse());
const reassignMutationHandler = jest.fn().mockResolvedValue(mockReassignMutationResponse);
const keepAsPlaceholderMutationHandler = jest
.fn()
@ -53,11 +56,22 @@ describe('PlaceholderActions', () => {
const createComponent = ({ seachUsersQueryHandler = usersQueryHandler, props = {} } = {}) => {
mockApollo = createMockApollo([
[searchUsersQuery, seachUsersQueryHandler],
[importSourceUsersQuery, sourceUsersQueryHandler],
[importSourceUserReassignMutation, reassignMutationHandler],
[importSourceUserKeepAsPlaceholderMutation, keepAsPlaceholderMutationHandler],
[importSourceUserResendNotificationMutation, resendNotificationMutationHandler],
[importSourceUserCancelReassignmentMutation, cancelReassignmentMutationHandler],
]);
// refetchQueries will only refetch active queries, so simply registering a query handler is not enough.
// We need to call `subscribe()` to make the query observable and avoid "Unknown query" errors.
// This simulates what the actual code in VueApollo is doing when adding a smart query.
// Docs: https://www.apollographql.com/docs/react/api/core/ApolloClient/#watchquery
mockApollo.clients.defaultClient
.watchQuery({
query: importSourceUsersQuery,
variables: { fullPath: 'test' },
})
.subscribe();
wrapper = shallowMountExtended(PlaceholderActions, {
apolloProvider: mockApollo,
@ -158,6 +172,15 @@ describe('PlaceholderActions', () => {
id: mockSourceUsers[0].id,
});
});
it('refetches sourceUsersQuery', () => {
expect(sourceUsersQueryHandler).toHaveBeenCalledTimes(2);
});
it('emits "confirm" event', async () => {
await waitForPromises();
expect(wrapper.emitted('confirm')[0]).toEqual([]);
});
});
});
@ -194,6 +217,15 @@ describe('PlaceholderActions', () => {
userId: mockUser1.id,
});
});
it('does not refetch sourceUsersQuery', () => {
expect(sourceUsersQueryHandler).toHaveBeenCalledTimes(1);
});
it('does not emit "confirm" event', async () => {
await waitForPromises();
expect(wrapper.emitted('confirm')).toBeUndefined();
});
});
});
});

View File

@ -206,4 +206,18 @@ describe('PlaceholdersTable', () => {
expect(wrapper.emitted('next')[0]).toEqual([]);
});
});
describe('actions events', () => {
beforeEach(() => {
createComponent({ mountFn: mount });
});
it('emits "confirm" event with item', () => {
const actions = findTableRows().at(2).findComponent(PlaceholderActions);
actions.vm.$emit('confirm');
expect(wrapper.emitted('confirm')[0]).toEqual([mockSourceUsers[2]]);
});
});
});

View File

@ -186,3 +186,9 @@ export const mockUsersWithPaginationQueryResponse = {
},
},
};
export const pagination = {
awaitingReassignmentItems: 5,
reassignedItems: 2,
totalItems: 7,
};

View File

@ -10,7 +10,9 @@ import UsersSelect from '~/users_select';
const getUserSearchHTML = memoize((fixture) => {
const parser = new DOMParser();
const el = parser.parseFromString(fixture, 'text/html').querySelector('.merge-request-assignee');
const el = parser
.parseFromString(fixture, 'text/html')
.querySelector('[data-testid=merge-request-assignee]');
return el.outerHTML;
});

View File

@ -9559,4 +9559,49 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr
it { expect(project.merge_trains_enabled?).to eq(false) }
end
describe '#lfs_file_locks_changed_epoch', :clean_gitlab_redis_cache do
let(:project) { build(:project, id: 1) }
let(:epoch) { Time.current.strftime('%s%L').to_i }
it 'returns a cached epoch value in milliseconds', :aggregate_failures, :freeze_time do
cold_cache_control = RedisCommands::Recorder.new do
expect(project.lfs_file_locks_changed_epoch).to eq epoch
end
expect(cold_cache_control.by_command('get').count).to eq 1
expect(cold_cache_control.by_command('set').count).to eq 1
warm_cache_control = RedisCommands::Recorder.new do
expect(project.lfs_file_locks_changed_epoch).to eq epoch
end
expect(warm_cache_control.by_command('get').count).to eq 1
expect(warm_cache_control.by_command('set').count).to eq 0
end
end
describe '#refresh_lfs_file_locks_changed_epoch' do
let(:project) { build(:project, id: 1) }
let(:original_time) { Time.current }
let(:refresh_time) { original_time + 1.second }
let(:original_epoch) { original_time.strftime('%s%L').to_i }
let(:refreshed_epoch) { original_epoch + 1.second.in_milliseconds }
it 'refreshes the cache and returns the new epoch value', :aggregate_failures, :freeze_time do
expect(project.lfs_file_locks_changed_epoch).to eq(original_epoch)
travel_to(refresh_time)
expect(project.lfs_file_locks_changed_epoch).to eq(original_epoch)
control = RedisCommands::Recorder.new do
expect(project.refresh_lfs_file_locks_changed_epoch).to eq(refreshed_epoch)
end
expect(control.by_command('get').count).to eq 0
expect(control.by_command('set').count).to eq 1
expect(project.lfs_file_locks_changed_epoch).to eq(refreshed_epoch)
end
end
end

View File

@ -6,19 +6,21 @@ RSpec.describe Lfs::LockFileService, feature_category: :source_code_management d
let(:project) { create(:project) }
let(:current_user) { create(:user) }
subject { described_class.new(project, current_user, params) }
describe '#execute' do
subject(:execute) { described_class.new(project, current_user, params).execute }
let(:params) { { path: 'README.md' } }
context 'when not authorized' do
it "doesn't succeed" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:error)
expect(result[:http_status]).to eq(403)
expect(result[:message]).to eq('You have no permissions')
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
context 'when authorized' do
@ -30,36 +32,40 @@ RSpec.describe Lfs::LockFileService, feature_category: :source_code_management d
let!(:lock) { create(:lfs_file_lock, project: project) }
it "doesn't succeed" do
expect(subject.execute[:status]).to eq(:error)
expect(execute[:status]).to eq(:error)
end
it "doesn't create the Lock" do
expect do
subject.execute
end.not_to change { LfsFileLock.count }
expect { execute }.not_to change { LfsFileLock.count }
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
context 'without an existent lock' do
it "succeeds" do
expect(subject.execute[:status]).to eq(:success)
expect(execute[:status]).to eq(:success)
end
it "creates the Lock" do
expect do
subject.execute
end.to change { LfsFileLock.count }.by(1)
expect { execute }.to change { LfsFileLock.count }.by(1)
end
it_behaves_like 'refreshes project.lfs_file_locks_changed_epoch value'
end
context 'when an error is raised' do
it "doesn't succeed" do
before do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:create_lock!).and_raise(StandardError)
end
expect(subject.execute[:status]).to eq(:error)
end
it "doesn't succeed" do
expect(execute[:status]).to eq(:error)
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
end
end

View File

@ -9,17 +9,19 @@ RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management
let!(:lock) { create(:lfs_file_lock, user: lock_author, project: project) }
let(:params) { {} }
subject { described_class.new(project, current_user, params) }
describe '#execute' do
subject(:execute) { described_class.new(project, current_user, params).execute }
context 'when not authorized' do
it "doesn't succeed" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:error)
expect(result[:http_status]).to eq(403)
expect(result[:message]).to eq(_('You have no permissions'))
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
context 'when authorized' do
@ -31,11 +33,13 @@ RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management
let(:params) { { id: 123 } }
it "doesn't succeed" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:error)
expect(result[:http_status]).to eq(404)
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
context 'when unlocked by the author' do
@ -43,11 +47,13 @@ RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management
let(:params) { { id: lock.id } }
it "succeeds" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:success)
expect(result[:lock]).to be_present
end
it_behaves_like 'refreshes project.lfs_file_locks_changed_epoch value'
end
context 'when unlocked by a different user' do
@ -55,12 +61,14 @@ RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management
let(:params) { { id: lock.id } }
it "doesn't succeed" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:error)
expect(result[:message]).to match(/'README.md' is locked by @#{lock_author.username}/)
expect(result[:http_status]).to eq(403)
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
context 'when forced' do
@ -80,12 +88,14 @@ RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management
end
it "doesn't succeed" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq(_('You must have maintainer access to force delete a lock'))
expect(result[:http_status]).to eq(403)
end
it_behaves_like 'does not refresh project.lfs_file_locks_changed_epoch'
end
context 'by a maintainer user' do
@ -96,11 +106,13 @@ RSpec.describe Lfs::UnlockFileService, feature_category: :source_code_management
end
it "succeeds" do
result = subject.execute
result = execute
expect(result[:status]).to eq(:success)
expect(result[:lock]).to be_present
end
it_behaves_like 'refreshes project.lfs_file_locks_changed_epoch value'
end
end
end

View File

@ -27,9 +27,12 @@ end
RSpec.shared_examples 'work items toggle status button' do
it 'successfully shows and changes the status of the work item' do
click_button 'Close', match: :first
within_testid 'work-item-comment-form-actions' do
# Depending of the context, the button's text could be `Close issue`, `Close key result`, `Close objective`, etc.
click_button 'Close', match: :first
expect(page).to have_button 'Reopen'
expect(page).to have_button 'Reopen'
end
expect(work_item.reload.state).to eq('closed')
end
end
@ -624,7 +627,9 @@ RSpec.shared_examples 'work items time tracking' do
expect(page).to be_axe_clean.within('[role="dialog"]')
click_button 'Close'
within_testid 'set-time-estimate-modal' do
click_button 'Close'
end
click_button 'time spent'
expect(page).to be_axe_clean.within('[role="dialog"]')
@ -632,15 +637,19 @@ RSpec.shared_examples 'work items time tracking' do
it 'adds and removes an estimate', :aggregate_failures do
click_button 'estimate'
fill_in 'Estimate', with: '5d'
click_button 'Save'
within_testid 'set-time-estimate-modal' do
fill_in 'Estimate', with: '5d'
click_button 'Save'
end
expect(page).to have_text 'Estimate 5d'
expect(page).to have_button '5d'
expect(page).not_to have_button 'estimate'
click_button '5d'
click_button 'Remove'
within_testid 'set-time-estimate-modal' do
click_button 'Remove'
end
expect(page).not_to have_text 'Estimate 5d'
expect(page).not_to have_button '5d'
@ -648,31 +657,39 @@ RSpec.shared_examples 'work items time tracking' do
end
it 'adds and deletes time entries and view report', :aggregate_failures do
click_button 'time entry'
fill_in 'Time spent', with: '1d'
fill_in 'Summary', with: 'First summary'
click_button 'Save'
click_button 'Add time entry'
within_testid 'create-timelog-modal' do
fill_in 'Time spent', with: '1d'
fill_in 'Summary', with: 'First summary'
click_button 'Save'
end
click_button 'Add time entry'
fill_in 'Time spent', with: '2d'
fill_in 'Summary', with: 'Second summary'
click_button 'Save'
within_testid 'create-timelog-modal' do
fill_in 'Time spent', with: '2d'
fill_in 'Summary', with: 'Second summary'
click_button 'Save'
end
expect(page).to have_text 'Spent 3d'
expect(page).to have_button '3d'
click_button '3d'
expect(page).to have_css 'h2', text: 'Time tracking report'
expect(page).to have_text "1d #{user.name} First summary"
expect(page).to have_text "2d #{user.name} Second summary"
within_testid 'time-tracking-report-modal' do
expect(page).to have_css 'h2', text: 'Time tracking report'
expect(page).to have_text "1d #{user.name} First summary"
expect(page).to have_text "2d #{user.name} Second summary"
click_button 'Delete time spent', match: :first
click_button 'Delete time spent', match: :first
expect(page).to have_text "1d #{user.name} First summary"
expect(page).not_to have_text "2d #{user.name} Second summary"
expect(page).to have_text "1d #{user.name} First summary"
expect(page).not_to have_text "2d #{user.name} Second summary"
click_button 'Close'
click_button 'Close'
end
expect(page).to have_text 'Spent 1d'
expect(page).to have_button '1d'

View File

@ -60,3 +60,27 @@ RSpec.shared_examples 'checks parent group feature flag' do
it { is_expected.to be_truthy }
end
end
RSpec.shared_examples 'refreshes project.lfs_file_locks_changed_epoch value' do
it 'updates the lfs_file_locks_changed_epoch value', :clean_gitlab_redis_cache do
travel_to(1.hour.ago) { project.refresh_lfs_file_locks_changed_epoch }
original_epoch = project.lfs_file_locks_changed_epoch
subject
expect(project.lfs_file_locks_changed_epoch).to be > original_epoch
end
end
RSpec.shared_examples 'does not refresh project.lfs_file_locks_changed_epoch' do
it 'does not update the lfs_file_locks_changed_epoch value', :clean_gitlab_redis_cache do
travel_to(1.hour.ago) { project.refresh_lfs_file_locks_changed_epoch }
original_epoch = project.lfs_file_locks_changed_epoch
subject
expect(project.lfs_file_locks_changed_epoch).to eq original_epoch
end
end