Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-04-05 18:08:51 +00:00
parent d4e0452ed9
commit 9c05a84cac
62 changed files with 1667 additions and 174 deletions

View File

@ -765,6 +765,7 @@
when: never
- <<: *if-merge-request-targeting-stable-branch
- <<: *if-merge-request-labels-run-review-app
- <<: *if-merge-request-labels-run-all-e2e
- <<: *if-auto-deploy-branches
- <<: *if-ruby2-branch
- <<: *if-default-refs
@ -930,6 +931,7 @@
when: never
- <<: *if-merge-request-targeting-stable-branch
- <<: *if-merge-request-labels-run-review-app
- <<: *if-merge-request-labels-run-all-e2e
- <<: *if-auto-deploy-branches
- <<: *if-ruby2-branch
- <<: *if-default-refs

View File

@ -2,6 +2,18 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 15.10.2 (2023-04-05)
### Fixed (3 changes)
- [Fix openapi viewer for relative url instances](gitlab-org/gitlab@28c94e7f0e0c29651383212e16422e0b384cddb9) ([merge request](gitlab-org/gitlab!115480))
- [Update mail gem to v2.8.1](gitlab-org/gitlab@1ec987737d7a3ee96bb1ef8efa3f06fcd32c31e4) ([merge request](gitlab-org/gitlab!116173))
- [Move ldap option sync_name to ldap server and fix bugs](gitlab-org/gitlab@e56f6d11f76ae858f602b23ea1e2875eb8754fe5) by @zhzhang93 ([merge request](gitlab-org/gitlab!115820)) **GitLab Enterprise Edition**
### Changed (1 change)
- [Migrate the existing RedisHLL keys to default slot](gitlab-org/gitlab@5fa90b0ef485aee29f62c500fb48c19278099ef0) ([merge request](gitlab-org/gitlab!116604))
## 15.10.1 (2023-03-30)
### Fixed (2 changes)

View File

@ -1 +1 @@
ccdfef925ac6fd2264d456f438faa0ca7adaffc2
d7ad67347247776ec267d4f2056e2c4cffcf4ebd

View File

@ -142,7 +142,7 @@ export default {
{{ $options.i18n.columns.fallbackKeyTitle }}
<gl-icon
v-gl-tooltip
name="question"
name="question-o"
class="gl-text-gray-500"
:title="$options.i18n.fallbackTooltip"
/>

View File

@ -164,7 +164,7 @@ export default {
:href="$options.emptyHelpLink"
:title="$options.i18n.emptyTooltip"
:aria-label="$options.i18n.emptyTooltip"
><gl-icon name="question" :size="14"
><gl-icon name="question-o" :size="14"
/></gl-link>
</template>
</gl-empty-state>

View File

@ -305,7 +305,7 @@ export default {
:title="$options.i18n.defaultConfigTooltip"
:aria-label="$options.i18n.defaultConfigTooltip"
class="gl-vertical-align-middle"
><gl-icon name="question" :size="14" /></gl-link
><gl-icon name="question-o" :size="14" /></gl-link
></span>
</span>
</template>

View File

@ -158,7 +158,7 @@ export default {
>{{ instanceTitle }} ({{ instanceCount }})</span
>
<span ref="legend-icon" data-testid="legend-tooltip-target">
<gl-icon class="gl-text-blue-500 gl-ml-2" name="question" />
<gl-icon class="gl-text-blue-500 gl-ml-2" name="question-o" />
</span>
<gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body">
<div class="deploy-board-legend gl-display-flex gl-flex-direction-column">

View File

@ -138,7 +138,7 @@ export default {
<template #description>
{{ $options.i18n.strategyTypeDescription }}
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
<gl-icon name="question" />
<gl-icon name="question-o" />
</gl-link>
</template>
<gl-form-select
@ -202,7 +202,7 @@ export default {
{{ $options.i18n.environmentsSelectDescription }}
</span>
<gl-link :href="environmentsScopeDocsPath" target="_blank">
<gl-icon name="question" />
<gl-icon name="question-o" />
</gl-link>
</div>
</div>

View File

@ -82,7 +82,7 @@ export default {
{{ __('Commit Message') }}
<div id="ide-commit-message-popover-container">
<span id="ide-commit-message-question" class="form-text text-muted gl-ml-3">
<gl-icon name="question" />
<gl-icon name="question-o" />
</span>
<gl-popover
target="ide-commit-message-question"

View File

@ -82,7 +82,7 @@ export default {
<div>{{ __('Commit Message') }}</div>
<div id="commit-message-popover-container">
<span id="commit-message-question" class="gl-gray-700 gl-ml-3">
<gl-icon name="question" />
<gl-icon name="question-o" />
</span>
<gl-popover
target="commit-message-question"

View File

@ -5,6 +5,7 @@ export const STATUS_CLOSED = 'closed';
export const STATUS_MERGED = 'merged';
export const STATUS_OPEN = 'opened';
export const STATUS_REOPENED = 'reopened';
export const STATUS_LOCKED = 'locked';
export const TITLE_LENGTH_MAX = 255;
@ -22,4 +23,6 @@ export const IssuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),
[STATUS_REOPENED]: __('Open'),
[STATUS_MERGED]: __('Merged'),
[STATUS_LOCKED]: __('Open'),
};

View File

@ -55,7 +55,7 @@ export default {
rel="noopener noreferrer nofollow"
data-testid="artifact-expired-help-link"
>
<gl-icon name="question" />
<gl-icon name="question-o" />
</gl-link>
</p>
<p v-else-if="isLocked" class="build-detail-row">

View File

@ -0,0 +1,13 @@
import { stringifyTime, parseSeconds } from './date_format_utility';
/**
* Formats seconds into a human readable value of elapsed time,
* optionally limiting it to hours.
* @param {Number} seconds Seconds to format
* @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours
* @return {String} Provided seconds in human readable elapsed time format
*/
export const formatTimeSpent = (seconds, limitToHours) => {
const negative = seconds < 0;
return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours }));
};

View File

@ -2,3 +2,4 @@ export * from './datetime/timeago_utility';
export * from './datetime/date_format_utility';
export * from './datetime/date_calculation_utility';
export * from './datetime/pikaday_utility';
export * from './datetime/time_spent_utility';

View File

@ -63,6 +63,7 @@ function getPreviousDiscussion() {
function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
const discussion = getDiscussion();
if (!isOverviewPage() && !discussion) {
window.mrTabs?.eventHub.$once('NotesAppReady', () => {
handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions);
@ -71,9 +72,12 @@ function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
window.mrTabs?.tabShown('show', undefined, false);
return;
}
const id = discussion.dataset.discussionId;
ctx.expandDiscussion({ discussionId: id });
scrollToElement(discussion, scrollOptions);
if (discussion) {
const id = discussion.dataset.discussionId;
ctx.expandDiscussion({ discussionId: id });
scrollToElement(discussion, scrollOptions);
}
}
export default {

View File

@ -176,7 +176,7 @@ export default {
<gl-icon
v-if="showDailyLimitMessage(option)"
v-gl-tooltip.hover
name="question"
name="question-o"
:title="scheduleDailyLimitMsg"
/>
</gl-form-radio>

View File

@ -0,0 +1,3 @@
import initTimelogsApp from '~/time_tracking';
initTimelogsApp();

View File

@ -1,4 +1,5 @@
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
export default class PerformanceBarStore {
constructor() {
@ -6,7 +7,9 @@ export default class PerformanceBarStore {
}
addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
if (!this.findRequest(requestId)) {
if (this.findRequest(requestId)) {
this.updateRequestBatchedQueriesCount(requestId);
} else {
let displayName = '';
if (methodVerb) {
@ -25,12 +28,28 @@ export default class PerformanceBarStore {
fullUrl: mergeUrlParams(requestParams, requestUrl),
method: methodVerb,
details: {},
queriesInBatch: 1, // only for GraphQL
displayName,
});
}
return this.requests;
}
updateRequestBatchedQueriesCount(requestId) {
const existingRequest = this.findRequest(requestId);
existingRequest.queriesInBatch += 1;
const oldDisplayName = existingRequest.displayName;
const regex = /\d+ queries batched/;
if (regex.test(oldDisplayName)) {
existingRequest.displayName = oldDisplayName.replace(
regex,
`${existingRequest.queriesInBatch} queries batched`,
);
} else {
existingRequest.displayName += __(` [${existingRequest.queriesInBatch} queries batched]`);
}
}
findRequest(requestId) {
return this.requests.find((request) => request.id === requestId);

View File

@ -42,7 +42,7 @@ export default {
<label>
{{ __('Visibility level') }}
<gl-link v-if="helpLink" :href="helpLink" target="_blank"
><gl-icon :size="12" name="question"
><gl-icon :size="12" name="question-o"
/></gl-link>
</label>
<gl-form-group id="visibility-level-setting" class="gl-mb-0">

View File

@ -0,0 +1,69 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query timeTrackingReport(
$startDate: Time
$endDate: Time
$projectId: ProjectID
$groupId: GroupID
$username: String
$first: Int
$last: Int
$before: String
$after: String
) {
timelogs(
startDate: $startDate
endDate: $endDate
projectId: $projectId
groupId: $groupId
username: $username
first: $first
last: $last
after: $after
before: $before
sort: SPENT_AT_DESC
) {
count
totalSpentTime
nodes {
id
project {
id
webUrl
fullPath
nameWithNamespace
}
timeSpent
user {
id
name
username
avatarUrl
webPath
}
spentAt
note {
id
body
}
summary
issue {
id
title
webUrl
state
reference
}
mergeRequest {
id
title
webUrl
state
reference
}
}
pageInfo {
...PageInfo
}
}
}

View File

@ -0,0 +1,50 @@
<script>
import { GlLink } from '@gitlab/ui';
import { IssuableStatusText } from '~/issues/constants';
export default {
components: {
GlLink,
},
props: {
timelog: {
type: Object,
required: true,
},
},
computed: {
subject() {
const { issue, mergeRequest } = this.timelog;
return issue || mergeRequest;
},
issuableStatus() {
return IssuableStatusText[this.subject.state];
},
issuableFullReference() {
return this.timelog.project.fullPath + this.subject.reference;
},
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-gap-2 gl-text-left!">
<gl-link
:href="subject.webUrl"
class="gl-text-gray-900 gl-hover-text-gray-900 gl-font-weight-bold"
data-testid="title-container"
>
{{ subject.title }}
</gl-link>
<span>
<gl-link
:href="subject.webUrl"
class="gl-text-gray-900 gl-hover-text-gray-900"
data-testid="reference-container"
>
{{ issuableFullReference }}
</gl-link>
<span data-testid="state-container">{{ issuableStatus }}</span>
</span>
</div>
</template>

View File

@ -0,0 +1,229 @@
<script>
import * as Sentry from '@sentry/browser';
import {
GlButton,
GlFormGroup,
GlFormInput,
GlLoadingIcon,
GlKeysetPagination,
GlDatepicker,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { formatTimeSpent } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
import getTimelogsQuery from './queries/get_timelogs.query.graphql';
import TimelogsTable from './timelogs_table.vue';
const ENTRIES_PER_PAGE = 20;
// Define initial dates to current date and time
const INITIAL_TO_DATE = new Date();
const INITIAL_FROM_DATE = new Date();
// Set the initial 'from' date to 30 days before the current date
INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30);
export default {
components: {
GlButton,
GlFormGroup,
GlFormInput,
GlLoadingIcon,
GlKeysetPagination,
GlDatepicker,
TimelogsTable,
},
props: {
limitToHours: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
projectId: null,
groupId: null,
username: null,
timeSpentFrom: INITIAL_FROM_DATE,
timeSpentTo: INITIAL_TO_DATE,
cursor: {
first: ENTRIES_PER_PAGE,
after: null,
last: null,
before: null,
},
queryVariables: {
startDate: INITIAL_FROM_DATE,
endDate: INITIAL_TO_DATE,
projectId: null,
groupId: null,
username: null,
},
pageInfo: {},
report: [],
totalSpentTime: 0,
};
},
apollo: {
report: {
query: getTimelogsQuery,
variables() {
return {
...this.queryVariables,
...this.cursor,
};
},
update({ timelogs: { nodes = [], pageInfo = {}, totalSpentTime = 0 } = {} }) {
this.pageInfo = pageInfo;
this.totalSpentTime = totalSpentTime;
return nodes;
},
error(error) {
createAlert({ message: s__('TimeTrackingReport|Something went wrong. Please try again.') });
Sentry.captureException(error);
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.report.loading;
},
showPagination() {
return this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage;
},
formattedTotalSpentTime() {
return formatTimeSpent(this.totalSpentTime, this.limitToHours);
},
},
methods: {
nullIfBlank(value) {
return value === '' ? null : value;
},
runReport() {
this.cursor = {
first: ENTRIES_PER_PAGE,
after: null,
last: null,
before: null,
};
this.queryVariables = {
startDate: this.nullIfBlank(this.timeSpentFrom),
endDate: this.nullIfBlank(this.timeSpentTo),
projectId: this.nullIfBlank(this.projectId),
groupId: this.nullIfBlank(this.groupId),
username: this.nullIfBlank(this.username),
};
},
nextPage(item) {
this.cursor = {
first: ENTRIES_PER_PAGE,
after: item,
last: null,
before: null,
};
},
prevPage(item) {
this.cursor = {
first: null,
after: null,
last: ENTRIES_PER_PAGE,
before: item,
};
},
clearTimeSpentFromDate() {
this.timeSpentFrom = null;
},
clearTimeSpentToDate() {
this.timeSpentTo = null;
},
},
i18n: {
username: s__('TimeTrackingReport|Username'),
from: s__('TimeTrackingReport|From'),
to: s__('TimeTrackingReport|To'),
runReport: s__('TimeTrackingReport|Run report'),
totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '),
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-gap-5 gl-mt-5">
<form
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
@submit.prevent="runReport"
>
<gl-form-group
:label="$options.i18n.username"
label-for="timelog-form-username"
class="gl-mb-0 gl-md-form-input-md gl-w-full"
>
<gl-form-input
id="timelog-form-username"
v-model="username"
data-testid="form-username"
class="gl-w-full"
/>
</gl-form-group>
<gl-form-group
key="time-spent-from"
:label="$options.i18n.from"
class="gl-mb-0 gl-md-form-input-md gl-w-full"
>
<gl-datepicker
v-model="timeSpentFrom"
:target="null"
show-clear-button
autocomplete="off"
data-testid="form-from-date"
class="gl-max-w-full!"
@clear="clearTimeSpentFromDate"
/>
</gl-form-group>
<gl-form-group
key="time-spent-to"
:label="$options.i18n.to"
class="gl-mb-0 gl-md-form-input-md gl-w-full"
>
<gl-datepicker
v-model="timeSpentTo"
:target="null"
show-clear-button
autocomplete="off"
data-testid="form-to-date"
class="gl-max-w-full!"
@clear="clearTimeSpentToDate"
/>
</gl-form-group>
<gl-button
class="gl-align-self-end gl-w-full gl-md-w-auto"
variant="confirm"
@click="runReport"
>{{ $options.i18n.runReport }}</gl-button
>
</form>
<div
v-if="!isLoading"
data-testid="table-container"
class="gl-display-flex gl-flex-direction-column"
>
<div v-if="report.length" class="gl-display-flex gl-gap-2 gl-border-t gl-py-4">
<span class="gl-font-weight-bold">{{ $options.i18n.totalTimeSpentText }}</span>
<span data-testid="total-time-spent-container">{{ formattedTotalSpentTime }}</span>
</div>
<timelogs-table :limit-to-hours="limitToHours" :entries="report" />
<gl-keyset-pagination
v-if="showPagination"
v-bind="pageInfo"
class="gl-mt-3 gl-align-self-center"
@prev="prevPage"
@next="nextPage"
/>
</div>
<gl-loading-icon v-else size="lg" class="gl-mt-5" />
</div>
</template>

View File

@ -0,0 +1,105 @@
<script>
import { GlTable } from '@gitlab/ui';
import { formatDate, formatTimeSpent } from '~/lib/utils/datetime_utility';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { s__ } from '~/locale';
import TimelogSourceCell from './timelog_source_cell.vue';
const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
export default {
components: {
GlTable,
UserAvatarLink,
TimelogSourceCell,
},
props: {
entries: {
type: Array,
required: true,
},
limitToHours: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
fields: [
{
key: 'spentAt',
label: s__('TimeTrackingReport|Spent at'),
tdClass: 'gl-md-w-30',
},
{
key: 'source',
label: s__('TimeTrackingReport|Source'),
},
{
key: 'user',
label: s__('TimeTrackingReport|User'),
tdClass: 'gl-md-w-20',
},
{
key: 'timeSpent',
label: s__('TimeTrackingReport|Time spent'),
tdClass: 'gl-md-w-15',
},
{
key: 'summary',
label: s__('TimeTrackingReport|Summary'),
},
],
};
},
methods: {
formatDate(date) {
return formatDate(date, TIME_DATE_FORMAT);
},
formatTimeSpent(seconds) {
return formatTimeSpent(seconds, this.limitToHours);
},
extractTimelogSummary(timelog) {
const { note, summary } = timelog;
return note?.body || summary;
},
},
};
</script>
<template>
<gl-table :items="entries" :fields="fields" stacked="md" show-empty>
<template #cell(spentAt)="{ item: { spentAt } }">
<div data-testid="date-container" class="gl-text-left!">{{ formatDate(spentAt) }}</div>
</template>
<template #cell(source)="{ item }">
<timelog-source-cell :timelog="item" />
</template>
<template #cell(user)="{ item: { user } }">
<user-avatar-link
class="gl-display-flex gl-text-gray-900 gl-hover-text-gray-900"
:link-href="user.webPath"
:img-src="user.avatarUrl"
:img-size="16"
:img-alt="user.name"
:tooltip-text="user.name"
:username="user.name"
/>
</template>
<template #cell(timeSpent)="{ item: { timeSpent } }">
<div data-testid="time-spent-container" class="gl-text-left!">
{{ formatTimeSpent(timeSpent) }}
</div>
</template>
<template #cell(summary)="{ item }">
<div data-testid="summary-container" class="gl-text-left!">
{{ extractTimelogSummary(item) }}
</div>
</template>
</gl-table>
</template>

View File

@ -0,0 +1,32 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import TimelogsApp from './components/timelogs_app.vue';
Vue.use(VueApollo);
export default () => {
const el = document.getElementById('js-timelogs-app');
if (!el) {
return false;
}
const { limitToHours } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(TimelogsApp, {
props: {
limitToHours: parseBoolean(limitToHours),
},
});
},
});
};

View File

@ -90,7 +90,7 @@ export default {
:aria-label="helpLinkAriaLabel(item.storageType.name)"
:data-testid="`${item.storageType.id}-help-link`"
>
<gl-icon name="question" :size="12" />
<gl-icon name="question-o" :size="12" />
</gl-link>
</p>
<p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">

View File

@ -269,7 +269,7 @@ export default {
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-icon name="question-o" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="$options.I18N_USER_LEARN">
<template #name>{{ user.name }}</template>

View File

@ -41,6 +41,29 @@
}
.monaco-editor.gl-source-editor {
// Fix unreadable headings in tooltips for syntax highlighting themes that don't match general theme
&.vs-dark .markdown-hover {
h1,
h2,
h3,
h4,
h5,
h6 {
color: $source-editor-hover-light-text-color;
}
}
&.vs .markdown-hover {
h1,
h2,
h3,
h4,
h5,
h6 {
color: $source-editor-hover-dark-text-color;
}
}
.margin-view-overlays {
.line-numbers {
@include gl-display-flex;

View File

@ -921,6 +921,12 @@ Board Swimlanes
*/
$board-swimlanes-headers-height: 64px;
/*
Source Editor theme overrides
*/
$source-editor-hover-light-text-color: #ececef;
$source-editor-hover-dark-text-color: #333238;
/**
Bootstrap 4.2.0 introduced new icons for validating forms.
Our design system does not use those, so we are disabling them for now:

View File

@ -117,3 +117,21 @@
margin-bottom: $gl-spacing-scale-5;
}
}
.gl-md-w-15 {
@include gl-media-breakpoint-up(md) {
width: $gl-spacing-scale-15;
}
}
.gl-md-w-20 {
@include gl-media-breakpoint-up(md) {
width: $gl-spacing-scale-20;
}
}
.gl-md-w-30 {
@include gl-media-breakpoint-up(md) {
width: $gl-spacing-scale-30;
}
}

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
module TimeTracking
class TimelogsController < ApplicationController
feature_category :team_planning
urgency :low
def index
render_404 unless Feature.enabled?(:global_time_tracking_report, current_user)
end
end
end

View File

@ -121,7 +121,7 @@ module Resolvers
def apply_user_filter(timelogs, args)
return timelogs unless args[:username]
user = UserFinder.new(args[:username]).find_by_username!
user = UserFinder.new(args[:username]).find_by_username
timelogs.for_user(user)
end

View File

@ -49,6 +49,10 @@ module Types
null: true,
description: 'Summary of how the time was spent.'
field :project, Types::ProjectType,
null: false,
description: 'Target project of the timelog merge request or issue.'
def user
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
end

View File

@ -0,0 +1,7 @@
- @force_fluid_layout = true
- page_title _('Time tracking report')
.page-title-holder.gl-display-flex.gl-flex-align-items-center
%h1.page-title.gl-font-size-h-display= _('Time tracking report')
#js-timelogs-app{ data: { limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } }

View File

@ -0,0 +1,8 @@
---
name: global_time_tracking_report
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108368
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/394715
milestone: '15.11'
type: development
group: group::project management
default_enabled: false

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/396512
milestone: '15.11'
type: development
group: group::knowledge
default_enabled: false
default_enabled: true

View File

@ -224,6 +224,8 @@ InitializerConnections.raise_if_new_database_connection do
# Deprecated route for permanent failures
# https://gitlab.com/gitlab-org/gitlab/-/issues/362606
post '/members/mailgun/permanent_failures' => 'mailgun/webhooks#process_webhook'
get '/timelogs' => 'time_tracking/timelogs#index'
end
# End of the /-/ scope.

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
class Gitlab::Seeder::Timelogs
attr_reader :project, :issues, :merge_requests, :users
def initialize(project, users)
@project = project
@issues = project.issues
@merge_requests = project.merge_requests
@users = users
end
def seed!
ensure_users_are_reporters
print "\nGenerating time entries for issues and merge requests in '#{project.full_path}'\n"
seed_on_issuables(issues)
seed_on_issuables(merge_requests)
end
def self.find_or_create_reporters
password = SecureRandom.hex.slice(0, 16)
[
User.find_by_username("root"),
find_or_create_reporter_user("timelogs_reporter_user_1", password),
find_or_create_reporter_user("timelogs_reporter_user_2", password)
].compact
end
private
def ensure_users_are_reporters
team = ProjectTeam.new(project)
users.each do |user|
unless team.member?(user, Gitlab::Access::REPORTER)
print "\nAdding #{user.username} to #{project.full_path} reporters"
team.add_reporter(user)
end
end
end
def seed_on_issuables(issuables)
min_date = Time.now - 2.months
max_date = Time.now
issuables.each do |issuable|
rand(2..5).times do
timelog_author = users.sample
::Timelogs::CreateService.new(
issuable, rand(10..120) * 60, rand(min_date..max_date), FFaker::Lorem.sentence, timelog_author
).execute
print '.'
end
end
end
def self.find_or_create_reporter_user(username, password)
user = User.find_by_username(username)
if user.nil?
print "\nCreating user '#{username}' with password: '#{password}'"
user = User.create!(
username: username,
name: FFaker::Name.name,
email: FFaker::Internet.email,
confirmed_at: DateTime.now,
password: password
)
end
user
end
end
if ENV['SEED_TIMELOGS']
Gitlab::Seeder.quiet do
users = Gitlab::Seeder::Timelogs.find_or_create_reporters
# Seed timelogs for the first 5 projects
projects = Project.first(5)
# Always seed timelogs to the Flight project
flight_project = Project.find_by_full_path("flightjs/Flight")
projects |= [flight_project] unless flight_project.nil?
projects.each do |project|
Gitlab::Seeder::Timelogs.new(project, users).seed! unless project.nil?
end
rescue => e
warn "\nError seeding timelogs: #{e}"
end
else
puts "Skipped. Use the `SEED_TIMELOGS` environment variable to enable seeding timelogs data."
end

View File

@ -85,6 +85,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="queryciminutesusagedate"></a>`date` | [`Date`](#date) | Date for which to retrieve the usage data, should be the first day of a month. |
| <a id="queryciminutesusagenamespaceid"></a>`namespaceId` | [`NamespaceID`](#namespaceid) | Global ID of the Namespace for the monthly CI/CD minutes usage. |
### `Query.ciVariables`
@ -21215,6 +21216,7 @@ Describes an incident management timeline event.
| <a id="timelogissue"></a>`issue` | [`Issue`](#issue) | Issue that logged time was added to. |
| <a id="timelogmergerequest"></a>`mergeRequest` | [`MergeRequest`](#mergerequest) | Merge request that logged time was added to. |
| <a id="timelognote"></a>`note` | [`Note`](#note) | Note where the quick action was executed to add the logged time. |
| <a id="timelogproject"></a>`project` | [`Project!`](#project) | Target project of the timelog merge request or issue. |
| <a id="timelogspentat"></a>`spentAt` | [`Time`](#time) | Timestamp of when the time tracked was spent at. |
| <a id="timelogsummary"></a>`summary` | [`String`](#string) | Summary of how the time was spent. |
| <a id="timelogtimespent"></a>`timeSpent` | [`Int!`](#int) | Time spent displayed in seconds. |

View File

@ -811,9 +811,44 @@ Example response:
## Storage limit exclusions
The namespace storage limit exclusions endpoints manage storage limit exclusions on top-level namespaces on GitLab.com.
The namespace storage limit exclusion endpoints manage storage limit exclusions on top-level namespaces on GitLab.com.
These endpoints can only be consumed in the Admin Area of GitLab.com.
### Retrieve storage limit exclusions
Use a GET request to retrieve all `Namespaces::Storage::LimitExclusion` records.
```plaintext
GET /namespaces/storage/limit_exclusions
```
Example request:
```shell
curl --request GET \
--url "https://gitlab.com/v4/namespaces/storage/limit_exclusions" \
--header 'PRIVATE-TOKEN: <admin access token>'
```
Example response:
```json
[
{
"id": 1,
"namespace_id": 1234,
"namespace_name": "A Namespace Name",
"reason": "a reason to exclude the Namespace"
},
{
"id": 2,
"namespace_id": 4321,
"namespace_name": "Another Namespace Name",
"reason": "another reason to exclude the Namespace"
},
]
```
### Create a storage limit exclusion
Use a POST request to create an `Namespaces::Storage::LimitExclusion`.

View File

@ -89,6 +89,7 @@ GitLab can check your application for security vulnerabilities and that it meets
|-------|-------------|--------------------|
| [Set up dependency scanning](https://about.gitlab.com/blog/2021/01/14/try-dependency-scanning/) | Try out dependency scanning, which checks for known vulnerabilities in dependencies. | **{star}** |
| [Create a compliance pipeline](create_compliance_pipeline.md) | Learn how to create compliance pipelines for your groups. | **{star}** |
| [Set up a scan result policy](scan_result_policy.md) | Learn how to configure a scan result policy that takes action based on scan results. | **{star}** |
| [Get started with GitLab application security](../user/application_security/get-started-security.md) | Follow recommended steps to set up security tools. | |
| [GitLab Security Essentials](https://levelup.gitlab.com/courses/security-essentials) | Learn about the essential security capabilities of GitLab in this self-paced course. | |

View File

@ -101,7 +101,7 @@ The following vulnerability scanners and their databases are regularly updated:
| Secure scanning tool | Vulnerabilities database updates |
|:----------------------------------------------------------------|:---------------------------------|
| [Container Scanning](container_scanning/index.md) | A job runs on a daily basis to build new images with the latest vulnerability database updates from the upstream scanner. For more details, see [Vulnerabilities database update](container_scanning/index.md#vulnerabilities-database). |
| [Container Scanning](container_scanning/index.md) | A job runs on a daily basis to build new images with the latest vulnerability database updates from the upstream scanner. GitLab monitors this job through an internal alert that tells the engineering team when the database becomes more than 48 hours old. For more information, see the [Vulnerabilities database update](container_scanning/index.md#vulnerabilities-database). |
| [Dependency Scanning](dependency_scanning/index.md) | Relies on the [GitLab Advisory Database](https://gitlab.com/gitlab-org/security-products/gemnasium-db). It is updated on a daily basis using [data from NVD, the `ruby-advisory-db` and the GitHub Advisory Database as data sources](https://gitlab.com/gitlab-org/security-products/gemnasium-db/-/blob/master/SOURCES.md). See our [current measurement of time from CVE being issued to our product being updated](https://about.gitlab.com/handbook/engineering/development/performance-indicators/#cve-issue-to-update). |
| [Dynamic Application Security Testing (DAST)](dast/index.md) | The scanning engine is updated on a periodic basis. See the [version of the underlying tool `zaproxy`](https://gitlab.com/gitlab-org/security-products/dast/blob/main/Dockerfile#L1). The scanning rules are downloaded at scan runtime. |
| [Static Application Security Testing (SAST)](sast/index.md) | The source of scan rules depends on which [analyzer](sast/analyzers.md) is used for each [supported programming language](sast/index.md#supported-languages-and-frameworks). GitLab maintains a ruleset for the Semgrep-based analyzer and updates it regularly based on internal research and user feedback. For other analyzers, the ruleset is sourced from the upstream open-source scanner. Each analyzer is updated at least once per month if a relevant update is available. |

View File

@ -22,7 +22,7 @@ A user account is considered an enterprise account when:
- [SCIM](../group/saml_sso/scim_setup.md) creates the user account on behalf of
the group.
A user can also [manually connect an identity provider (IdP) to a GitLab account whose email address matches the subscribing organization's domain](../group/saml_sso/index.md#linking-saml-to-your-existing-gitlabcom-account).
A user can also [manually connect an identity provider (IdP) to a GitLab account whose email address matches the subscribing organization's domain](../group/saml_sso/index.md#link-saml-to-your-existing-gitlabcom-account).
By selecting **Authorize** when connecting these two accounts, the user account
with the matching email address is classified as an enterprise user. However, this
user account does not have an **Enterprise** badge in GitLab.

View File

@ -122,7 +122,7 @@ To ensure GitLab maps users and their contributions correctly:
1. Ensure that users have a public email on the source GitLab instance that matches any confirmed email address on the destination GitLab instance. Most
users receive an email asking them to confirm their email address.
1. If users already exist on the destination instance and you use [SAML SSO for GitLab.com groups](../../group/saml_sso/index.md), all users must
[link their SAML identity to their GitLab.com account](../../group/saml_sso/index.md#linking-saml-to-your-existing-gitlabcom-account).
[link their SAML identity to their GitLab.com account](../../group/saml_sso/index.md#link-saml-to-your-existing-gitlabcom-account).
### Connect the source GitLab instance

View File

@ -59,7 +59,7 @@ To set up SSO with Azure as your identity provider:
1. Make sure the identity provider is set to have provider-initiated calls
to link existing GitLab accounts.
1. Optional. If you use [Group Sync](#group-sync), customize the name of the
1. Optional. If you use [Group Sync](group_sync.md), customize the name of the
group claim to match the required attribute.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
@ -227,8 +227,6 @@ After you set up your identity provider to work with GitLab, you must configure
For more information, see the [SSO enforcement documentation](#sso-enforcement).
1. Select **Save changes**.
![Group SAML Settings for GitLab.com](img/group_saml_settings_v13_12.png)
NOTE:
The certificate [fingerprint algorithm](../../../integration/saml.md#configure-saml-on-your-idp) must be in SHA1. When configuring the identity provider (such as [Google Workspace](#set-up-google-workspace)), use a secure signature algorithm.
@ -260,7 +258,7 @@ You can pass user information to GitLab as attributes in the SAML assertion.
For more information, see the [attributes available for self-managed GitLab instances](../../../integration/saml.md#configure-assertions).
### Linking SAML to your existing GitLab.com account
### Link SAML to your existing GitLab.com account
> **Remember me** checkbox [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/121569) in GitLab 15.7.
@ -274,9 +272,9 @@ To link SAML to your existing GitLab.com account:
1. Enter your credentials on the identity provider if prompted.
1. You are then redirected back to GitLab.com and should now have access to the group. In the future, you can use SAML to sign in to GitLab.com.
On subsequent visits, you should be able to go [sign in to GitLab.com with SAML](#signing-in-to-gitlabcom-with-saml) or by visiting links directly. If the **enforce SSO** option is turned on, you are then redirected to sign in through the identity provider.
On subsequent visits, you should be able to go [sign in to GitLab.com with SAML](#sign-in-to-gitlabcom-with-saml) or by visiting links directly. If the **enforce SSO** option is turned on, you are then redirected to sign in through the identity provider.
### Signing in to GitLab.com with SAML
### Sign in to GitLab.com with SAML
1. Sign in to your identity provider.
1. From the list of apps, select the "GitLab.com" app. (The name is set by the administrator of the identity provider.)
@ -291,8 +289,8 @@ If [SCIM](scim_setup.md) is configured, group owners can update the SCIM identit
Alternatively, ask the users to reconnect their SAML account.
1. Ask relevant users to [unlink their account from the group](#unlinking-accounts).
1. Ask relevant users to [link their account to the new SAML app](#linking-saml-to-your-existing-gitlabcom-account).
1. Ask relevant users to [unlink their account from the group](#unlink-accounts).
1. Ask relevant users to [link their account to the new SAML app](#link-saml-to-your-existing-gitlabcom-account).
### Configure user settings from SAML response
@ -305,9 +303,9 @@ created via [SCIM](scim_setup.md) or by first sign-in with SAML SSO for GitLab.c
#### Supported user attributes
- `can_create_group` - 'true' or 'false' to indicate whether the user can create
- **can_create_group** - `true` or `false` to indicate whether the user can create
new groups. Default is `true`.
- `projects_limit` - The total number of personal projects a user can create.
- **projects_limit** - The total number of personal projects a user can create.
A value of `0` means the user cannot create new projects in their personal
namespace. Default is `10000`.
@ -365,7 +363,7 @@ If a user is already a member of the group, linking the SAML identity does not c
Users given a "minimal access" role have [specific restrictions](../../permissions.md#users-with-minimal-access).
### Blocking access
### Block user access
To rescind a user's access to the group when only SAML SSO is configured, either:
@ -376,7 +374,7 @@ To rescind a user's access to the group when only SAML SSO is configured, either
To rescind a user's access to the group when also using SCIM, refer to [Remove access](scim_setup.md#remove-access).
### Unlinking accounts
### Unlink accounts
Users can unlink SAML for a group from their profile page. This can be helpful if:
@ -398,10 +396,6 @@ For example, to unlink the `MyOrg` account:
1. On the left sidebar, select **Account**.
1. In the **Service sign-in** section, select **Disconnect** next to the connected account.
## Group Sync
For information on automatically managing GitLab group membership, see [SAML Group Sync](group_sync.md).
## NameID
GitLab.com uses the SAML **NameID** to identify users. The **NameID** is:
@ -518,6 +512,7 @@ immediately. If the user:
- [Glossary](../../../integration/saml.md#glossary)
- [Authentication comparison between SaaS and self-managed](../../../administration/auth/index.md#saas-vs-self-managed-comparison)
- [Passwords for users created through integrated authentication](../../../security/passwords_for_integrated_authentication_methods.md)
- [SAML Group Sync](group_sync.md)
## Troubleshooting

View File

@ -214,7 +214,7 @@ To link your SCIM and SAML identities:
1. Update the [primary email](../../profile/index.md#change-your-primary-email) address in your GitLab.com user account
to match the user profile email address in your identity provider.
1. [Link your SAML identity](index.md#linking-saml-to-your-existing-gitlabcom-account).
1. [Link your SAML identity](index.md#link-saml-to-your-existing-gitlabcom-account).
### Remove access

View File

@ -167,7 +167,7 @@ you must set `attribute_statements` in the SAML configuration to
This error suggests you are signed in as a GitLab user but have already linked your SAML identity to a different GitLab user. Sign out and then try to sign in again using SAML, which should log you into GitLab with the linked user account.
If you do not wish to use that GitLab user with the SAML login, you can [unlink the GitLab account from the SAML app](index.md#unlinking-accounts).
If you do not wish to use that GitLab user with the SAML login, you can [unlink the GitLab account from the SAML app](index.md#unlink-accounts).
### Message: "SAML authentication failed: User has already been taken"
@ -176,7 +176,7 @@ Here are possible causes and solutions:
| Cause | Solution |
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| You've tried to link multiple SAML identities to the same user, for a given identity provider. | Change the identity that you sign in with. To do so, [unlink the previous SAML identity](index.md#unlinking-accounts) from this GitLab account before attempting to sign in again. |
| You've tried to link multiple SAML identities to the same user, for a given identity provider. | Change the identity that you sign in with. To do so, [unlink the previous SAML identity](index.md#unlink-accounts) from this GitLab account before attempting to sign in again. |
| The `NameID` changes every time the user requests SSO identification | [Check the `NameID`](#verify-nameid) is not set with `Transient` format, or the `NameID` is not changing on subsequent requests.|
### Message: "SAML authentication failed: Email has already been taken"
@ -196,7 +196,7 @@ User accounts are created in one of the following ways:
Getting both of these errors at the same time suggests the `NameID` capitalization provided by the identity provider didn't exactly match the previous value for that user.
This can be prevented by configuring the `NameID` to return a consistent value. Fixing this for an individual user involves changing the identifier for the user. For GitLab.com, the user needs to [unlink their SAML from the GitLab account](index.md#unlinking-accounts).
This can be prevented by configuring the `NameID` to return a consistent value. Fixing this for an individual user involves changing the identifier for the user. For GitLab.com, the user needs to [unlink their SAML from the GitLab account](index.md#unlink-accounts).
### Message: "Request to link SAML account must be authorized"
@ -209,7 +209,7 @@ initiated by the service provider and not only the identity provider.
### Message: "There is already a GitLab account associated with this email address. Sign in with your existing credentials to connect your organization's account" **(PREMIUM SAAS)**
A user can see this message when they are trying to [manually link SAML to their existing GitLab.com account](index.md#linking-saml-to-your-existing-gitlabcom-account).
A user can see this message when they are trying to [manually link SAML to their existing GitLab.com account](index.md#link-saml-to-your-existing-gitlabcom-account).
To resolve this problem, the user should check they are using the correct GitLab password to sign in. The user first needs
to [reset their password](https://gitlab.com/users/password/new) if both:
@ -233,7 +233,7 @@ This can then be compared to the `NameID` being sent by the identity provider by
Ensure that the **GitLab single sign-on URL** (for GitLab.com) or the instance URL (for self-managed) has been configured as "Login URL" (or similarly named field) in the identity provider's SAML app.
For GitLab.com, alternatively, when users need to [link SAML to their existing GitLab.com account](index.md#linking-saml-to-your-existing-gitlabcom-account), provide the **GitLab single sign-on URL** and instruct users not to use the SAML app on first sign in.
For GitLab.com, alternatively, when users need to [link SAML to their existing GitLab.com account](index.md#link-saml-to-your-existing-gitlabcom-account), provide the **GitLab single sign-on URL** and instruct users not to use the SAML app on first sign in.
### Users receive a 404 **(PREMIUM SAAS)**
@ -245,7 +245,7 @@ If you receive a `404` during setup when using "verify configuration", make sure
[SHA-1 generated fingerprint](../../../integration/saml.md#configure-saml-on-your-idp).
If a user is trying to sign in for the first time and the GitLab single sign-on URL has not [been configured](index.md#set-up-identity-provider), they may see a 404.
As outlined in the [user access section](index.md#linking-saml-to-your-existing-gitlabcom-account), a group Owner needs to provide the URL to users.
As outlined in the [user access section](index.md#link-saml-to-your-existing-gitlabcom-account), a group Owner needs to provide the URL to users.
If all users are receiving a `404` after signing in to the identity provider (IdP):
@ -317,4 +317,4 @@ This error appears when you try to invite a user to a GitLab.com group (or subgr
If you see this message after trying to invite a user to a group:
1. Ensure the user has been [added to the SAML identity provider](index.md#user-access-and-management).
1. Ask the user to [link SAML to their existing GitLab.com account](index.md#linking-saml-to-your-existing-gitlabcom-account), if they have one. Otherwise, ask the user to create a GitLab.com account by [accessing GitLab.com through the identity provider's dashboard](index.md#user-access-and-management), or by [signing up manually](https://gitlab.com/users/sign_up) and linking SAML to their new account.
1. Ask the user to [link SAML to their existing GitLab.com account](index.md#link-saml-to-your-existing-gitlabcom-account), if they have one. Otherwise, ask the user to create a GitLab.com account by [accessing GitLab.com through the identity provider's dashboard](index.md#user-access-and-management), or by [signing up manually](https://gitlab.com/users/sign_up) and linking SAML to their new account.

View File

@ -390,15 +390,15 @@ the language declared as `math` is rendered on a separate line:
````markdown
This math is inline: $`a^2+b^2=c^2`$.
This math is on a separate line:
This math is on a separate line using a ```` ```math ```` block:
```math
a^2+b^2=c^2
```
This math is on a separate line: $$a^2+b^2=c^2$$
This math is on a separate line using inline `$$`: $$a^2+b^2=c^2$$
This math is on a separate line:
This math is on a separate line using a `$$...$$` block:
$$
a^2+b^2=c^2
@ -407,23 +407,15 @@ $$
This math is inline: $`a^2+b^2=c^2`$.
This math is on a separate line:
This math is on a separate line using a ```` ```math ```` block:
```math
a^2+b^2=c^2
```
This math is on a separate line: $$a^2+b^2=c^2$$
This math is on a separate line using inline `$$`: $$a^2+b^2=c^2$$
This math is on a separate line:
$$
a^2+b^2=c^2
$$
This math is on a separate line: $$a^2+b^2=c^2$$
This math is on a separate line:
This math is on a separate line using a `$$...$$` block:
$$
a^2+b^2=c^2

View File

@ -9023,6 +9023,9 @@ msgstr ""
msgid "CiCatalog|Learn more"
msgstr ""
msgid "CiCatalog|Released %{timeAgo} by %{author}"
msgstr ""
msgid "CiCatalog|Repositories of pipeline components available in this namespace."
msgstr ""
@ -45367,6 +45370,39 @@ msgstr ""
msgid "TimeTrackingEstimated|Est"
msgstr ""
msgid "TimeTrackingReport|From"
msgstr ""
msgid "TimeTrackingReport|Run report"
msgstr ""
msgid "TimeTrackingReport|Something went wrong. Please try again."
msgstr ""
msgid "TimeTrackingReport|Source"
msgstr ""
msgid "TimeTrackingReport|Spent at"
msgstr ""
msgid "TimeTrackingReport|Summary"
msgstr ""
msgid "TimeTrackingReport|Time spent"
msgstr ""
msgid "TimeTrackingReport|To"
msgstr ""
msgid "TimeTrackingReport|Total time spent: "
msgstr ""
msgid "TimeTrackingReport|User"
msgstr ""
msgid "TimeTrackingReport|Username"
msgstr ""
msgid "TimeTracking|%{spentStart}Spent: %{spentEnd}"
msgstr ""

View File

@ -50,7 +50,10 @@ module QA
runner&.remove_via_api!
end
it 'merges after pipeline succeeds' do
it 'merges after pipeline succeeds', quarantine: {
type: :flaky,
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/403017'
} do
transient_test = repeat > 1
repeat.times do |i|

View File

@ -49,7 +49,7 @@ describe('AlertMappingBuilder', () => {
const fallbackColumnIcon = findColumnInRow(0, 3).findComponent(GlIcon);
expect(fallbackColumnIcon.exists()).toBe(true);
expect(fallbackColumnIcon.attributes('name')).toBe('question');
expect(fallbackColumnIcon.attributes('name')).toBe('question-o');
expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip);
});

View File

@ -61,7 +61,7 @@ describe('Deploy Board', () => {
const icon = iconSpan.findComponent(GlIcon);
expect(tooltip.props('target')()).toBe(iconSpan.element);
expect(icon.props('name')).toBe('question');
expect(icon.props('name')).toBe('question-o');
});
it('renders the canary weight selector', () => {
@ -116,7 +116,7 @@ describe('Deploy Board', () => {
const icon = iconSpan.findComponent(GlIcon);
expect(tooltip.props('target')()).toBe(iconSpan.element);
expect(icon.props('name')).toBe('question');
expect(icon.props('name')).toBe('question-o');
});
it('renders the canary weight selector', () => {

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Timelogs (GraphQL fixtures)', feature_category: :team_planning do
describe GraphQL::Query, type: :request do
include ApiHelpers
include GraphqlHelpers
include JavaScriptFixturesHelpers
let_it_be(:guest) { create(:user) }
let_it_be(:developer) { create(:user) }
context 'for time tracking timelogs' do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let(:query_path) { 'time_tracking/components/queries/get_timelogs.query.graphql' }
let(:query) { get_graphql_query_as_string(query_path) }
before_all do
project.add_guest(guest)
project.add_developer(developer)
end
it "graphql/get_timelogs_empty_response.json" do
post_graphql(query, current_user: guest, variables: { username: guest.username })
expect_graphql_errors_to_be_empty
end
context 'with 20 or less timelogs' do
let_it_be(:timelogs) { create_list(:timelog, 6, user: developer, issue: issue, time_spent: 4 * 60 * 60) }
it "graphql/get_non_paginated_timelogs_response.json" do
post_graphql(query, current_user: guest, variables: { username: developer.username })
expect_graphql_errors_to_be_empty
end
end
context 'with more than 20 timelogs' do
let_it_be(:timelogs) { create_list(:timelog, 30, user: developer, issue: issue, time_spent: 4 * 60 * 60) }
it "graphql/get_paginated_timelogs_response.json" do
post_graphql(query, current_user: guest, variables: { username: developer.username, first: 25 })
expect_graphql_errors_to_be_empty
end
end
end
end
end

View File

@ -0,0 +1,25 @@
import { formatTimeSpent } from '~/lib/utils/datetime/time_spent_utility';
describe('Time spent utils', () => {
describe('formatTimeSpent', () => {
describe('with limitToHours false', () => {
it('formats 34500 seconds to `1d 1h 35m`', () => {
expect(formatTimeSpent(34500)).toEqual('1d 1h 35m');
});
it('formats -34500 seconds to `- 1d 1h 35m`', () => {
expect(formatTimeSpent(-34500)).toEqual('- 1d 1h 35m');
});
});
describe('with limitToHours true', () => {
it('formats 34500 seconds to `9h 35m`', () => {
expect(formatTimeSpent(34500, true)).toEqual('9h 35m');
});
it('formats -34500 seconds to `- 9h 35m`', () => {
expect(formatTimeSpent(-34500, true)).toEqual('- 9h 35m');
});
});
});
});

View File

@ -46,6 +46,14 @@ describe('PerformanceBarStore', () => {
store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation');
expect(findUrl('id')).toBe('graphql (someOperation)');
});
it('appends the number of batches queries when it is a GraphQL call', () => {
store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation');
store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOperation');
store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOne');
store.addRequest('anotherId', 'http://localhost:3001/api/graphql', 'operationName');
expect(findUrl('id')).toBe('graphql (someOperation) [3 queries batched]');
});
});
describe('setRequestDetailsData', () => {

View File

@ -13,7 +13,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
target="_blank"
>
<gl-icon-stub
name="question"
name="question-o"
size="12"
/>
</gl-link-stub>

View File

@ -0,0 +1,136 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue';
import {
IssuableStatusText,
STATUS_CLOSED,
STATUS_MERGED,
STATUS_OPEN,
STATUS_LOCKED,
STATUS_REOPENED,
} from '~/issues/constants';
const createIssuableTimelogMock = (
type,
{ title, state, webUrl, reference } = {
title: 'Issuable title',
state: STATUS_OPEN,
webUrl: 'https://example.com/issuable_url',
reference: '#111',
},
) => {
return {
timelog: {
project: {
fullPath: 'group/project',
},
[type]: {
title,
state,
webUrl,
reference,
},
},
};
};
describe('TimelogSourceCell component', () => {
Vue.use(VueApollo);
let wrapper;
const findTitleContainer = () => wrapper.findByTestId('title-container');
const findReferenceContainer = () => wrapper.findByTestId('reference-container');
const findStateContainer = () => wrapper.findByTestId('state-container');
const mountComponent = ({ timelog } = {}) => {
wrapper = shallowMountExtended(TimelogSourceCell, {
propsData: {
timelog,
},
});
};
describe('when the timelog is associated to an issue', () => {
it('shows the issue title as link to the issue', () => {
mountComponent(
createIssuableTimelogMock('issue', {
title: 'Issue title',
webUrl: 'https://example.com/issue_url',
}),
);
const titleContainer = findTitleContainer();
expect(titleContainer.text()).toBe('Issue title');
expect(titleContainer.attributes('href')).toBe('https://example.com/issue_url');
});
it('shows the issue full reference as link to the issue', () => {
mountComponent(
createIssuableTimelogMock('issue', {
reference: '#111',
webUrl: 'https://example.com/issue_url',
}),
);
const referenceContainer = findReferenceContainer();
expect(referenceContainer.text()).toBe('group/project#111');
expect(referenceContainer.attributes('href')).toBe('https://example.com/issue_url');
});
it.each`
state | stateDescription
${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]}
${STATUS_REOPENED} | ${IssuableStatusText[STATUS_REOPENED]}
${STATUS_LOCKED} | ${IssuableStatusText[STATUS_LOCKED]}
${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]}
`('shows $stateDescription when the state is $state', ({ state, stateDescription }) => {
mountComponent(createIssuableTimelogMock('issue', { state }));
expect(findStateContainer().text()).toBe(stateDescription);
});
});
describe('when the timelog is associated to a merge request', () => {
it('shows the merge request title as link to the merge request', () => {
mountComponent(
createIssuableTimelogMock('mergeRequest', {
title: 'MR title',
webUrl: 'https://example.com/mr_url',
}),
);
const titleContainer = findTitleContainer();
expect(titleContainer.text()).toBe('MR title');
expect(titleContainer.attributes('href')).toBe('https://example.com/mr_url');
});
it('shows the merge request full reference as link to the merge request', () => {
mountComponent(
createIssuableTimelogMock('mergeRequest', {
reference: '!111',
webUrl: 'https://example.com/mr_url',
}),
);
const referenceContainer = findReferenceContainer();
expect(referenceContainer.text()).toBe('group/project!111');
expect(referenceContainer.attributes('href')).toBe('https://example.com/mr_url');
});
it.each`
state | stateDescription
${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]}
${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]}
${STATUS_MERGED} | ${IssuableStatusText[STATUS_MERGED]}
`('shows $stateDescription when the state is $state', ({ state, stateDescription }) => {
mountComponent(createIssuableTimelogMock('mergeRequest', { state }));
expect(findStateContainer().text()).toBe(stateDescription);
});
});
});

View File

@ -0,0 +1,238 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
import { GlDatepicker, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
import getTimelogsEmptyResponse from 'test_fixtures/graphql/get_timelogs_empty_response.json';
import getPaginatedTimelogsResponse from 'test_fixtures/graphql/get_paginated_timelogs_response.json';
import getNonPaginatedTimelogsResponse from 'test_fixtures/graphql/get_non_paginated_timelogs_response.json';
import { createAlert } from '~/alert';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getTimelogsQuery from '~/time_tracking/components/queries/get_timelogs.query.graphql';
import TimelogsApp from '~/time_tracking/components/timelogs_app.vue';
import TimelogsTable from '~/time_tracking/components/timelogs_table.vue';
jest.mock('~/alert');
jest.mock('@sentry/browser');
describe('Timelogs app', () => {
Vue.use(VueApollo);
let wrapper;
let fakeApollo;
const findForm = () => wrapper.find('form');
const findUsernameInput = () => extendedWrapper(findForm()).findByTestId('form-username');
const findTableContainer = () => wrapper.findByTestId('table-container');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findTotalTimeSpentContainer = () => wrapper.findByTestId('total-time-spent-container');
const findTable = () => wrapper.findComponent(TimelogsTable);
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
const findFormDatePicker = (testId) =>
findForm()
.findAllComponents(GlDatepicker)
.filter((c) => c.attributes('data-testid') === testId);
const findFromDatepicker = () => findFormDatePicker('form-from-date').at(0);
const findToDatepicker = () => findFormDatePicker('form-to-date').at(0);
const submitForm = () => findForm().trigger('submit');
const resolvedEmptyListMock = jest.fn().mockResolvedValue(getTimelogsEmptyResponse);
const resolvedPaginatedListMock = jest.fn().mockResolvedValue(getPaginatedTimelogsResponse);
const resolvedNonPaginatedListMock = jest.fn().mockResolvedValue(getNonPaginatedTimelogsResponse);
const rejectedMock = jest.fn().mockRejectedValue({});
const mountComponent = ({ props, data } = {}, queryResolverMock = resolvedEmptyListMock) => {
fakeApollo = createMockApollo([[getTimelogsQuery, queryResolverMock]]);
wrapper = mountExtended(TimelogsApp, {
data() {
return {
...data,
};
},
propsData: {
limitToHours: false,
...props,
},
apolloProvider: fakeApollo,
});
};
beforeEach(() => {
createAlert.mockClear();
Sentry.captureException.mockClear();
});
afterEach(() => {
fakeApollo = null;
});
describe('the content', () => {
it('shows the form and the loading icon when loading', () => {
mountComponent();
expect(findForm().exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(true);
expect(findTableContainer().exists()).toBe(false);
});
it('shows the form and the table container when finished loading', async () => {
mountComponent();
await waitForPromises();
expect(findForm().exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(false);
expect(findTableContainer().exists()).toBe(true);
});
});
describe('the filter form', () => {
it('runs the query with the correct data', async () => {
mountComponent();
const username = 'johnsmith';
const fromDate = new Date('2023-02-28');
const toDate = new Date('2023-03-28');
findUsernameInput().vm.$emit('input', username);
findFromDatepicker().vm.$emit('input', fromDate);
findToDatepicker().vm.$emit('input', toDate);
resolvedEmptyListMock.mockClear();
submitForm();
await waitForPromises();
expect(resolvedEmptyListMock).toHaveBeenCalledWith({
username,
startDate: fromDate,
endDate: toDate,
groupId: null,
projectId: null,
first: 20,
last: null,
after: null,
before: null,
});
expect(createAlert).not.toHaveBeenCalled();
expect(Sentry.captureException).not.toHaveBeenCalled();
});
it('runs the query with the correct data after the date filters are cleared', async () => {
mountComponent();
const username = 'johnsmith';
findUsernameInput().vm.$emit('input', username);
findFromDatepicker().vm.$emit('clear');
findToDatepicker().vm.$emit('clear');
resolvedEmptyListMock.mockClear();
submitForm();
await waitForPromises();
expect(resolvedEmptyListMock).toHaveBeenCalledWith({
username,
startDate: null,
endDate: null,
groupId: null,
projectId: null,
first: 20,
last: null,
after: null,
before: null,
});
expect(createAlert).not.toHaveBeenCalled();
expect(Sentry.captureException).not.toHaveBeenCalled();
});
it('shows an alert an logs to sentry when the mutation is rejected', async () => {
mountComponent({}, rejectedMock);
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
message: 'Something went wrong. Please try again.',
});
expect(Sentry.captureException).toHaveBeenCalled();
});
});
describe('the total time spent container', () => {
it('is not visible when there are no timelogs', async () => {
mountComponent();
await waitForPromises();
expect(findTotalTimeSpentContainer().exists()).toBe(false);
});
it('shows the correct value when `limitToHours` is false', async () => {
mountComponent({}, resolvedNonPaginatedListMock);
await waitForPromises();
expect(findTotalTimeSpentContainer().exists()).toBe(true);
expect(findTotalTimeSpentContainer().text()).toBe('3d');
});
it('shows the correct value when `limitToHours` is true', async () => {
mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock);
await waitForPromises();
expect(findTotalTimeSpentContainer().exists()).toBe(true);
expect(findTotalTimeSpentContainer().text()).toBe('24h');
});
});
describe('the table', () => {
it('gets created with the right props when `limitToHours` is false', async () => {
mountComponent({}, resolvedNonPaginatedListMock);
await waitForPromises();
expect(findTable().props()).toMatchObject({
limitToHours: false,
entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes,
});
});
it('gets created with the right props when `limitToHours` is true', async () => {
mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock);
await waitForPromises();
expect(findTable().props()).toMatchObject({
limitToHours: true,
entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes,
});
});
});
describe('the pagination element', () => {
it('is not visible whene there is no pagination data', async () => {
mountComponent({}, resolvedNonPaginatedListMock);
await waitForPromises();
expect(findPagination().exists()).toBe(false);
});
it('is visible whene there is pagination data', async () => {
mountComponent({}, resolvedPaginatedListMock);
await waitForPromises();
await nextTick();
expect(findPagination().exists()).toBe(true);
});
});
});

View File

@ -0,0 +1,223 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlTable } from '@gitlab/ui';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import TimelogsTable from '~/time_tracking/components/timelogs_table.vue';
import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
const baseTimelogMock = {
timeSpent: 600,
project: {
fullPath: 'group/project',
},
user: {
name: 'John Smith',
avatarUrl: 'https://example.gitlab.com/john.jpg',
webPath: 'https://example.gitlab.com/john',
},
spentAt: '2023-03-27T21:00:00Z',
note: null,
summary: 'Summary from timelog field',
issue: {
title: 'Issue title',
webUrl: 'https://example.gitlab.com/issue_url_a',
state: STATUS_OPEN,
reference: '#111',
},
mergeRequest: null,
};
const timelogsMock = [
baseTimelogMock,
{
timeSpent: 3600,
project: {
fullPath: 'group/project_b',
},
user: {
name: 'Paul Reed',
avatarUrl: 'https://example.gitlab.com/paul.jpg',
webPath: 'https://example.gitlab.com/paul',
},
spentAt: '2023-03-28T16:00:00Z',
note: {
body: 'Summary from the body',
},
summary: null,
issue: {
title: 'Other issue title',
webUrl: 'https://example.gitlab.com/issue_url_b',
state: STATUS_CLOSED,
reference: '#112',
},
mergeRequest: null,
},
{
timeSpent: 27 * 60 * 60, // 27h or 3d 3h (3 days of 8 hours)
project: {
fullPath: 'group/project_b',
},
user: {
name: 'Les Gibbons',
avatarUrl: 'https://example.gitlab.com/les.jpg',
webPath: 'https://example.gitlab.com/les',
},
spentAt: '2023-03-28T18:00:00Z',
note: null,
summary: 'Other timelog summary',
issue: null,
mergeRequest: {
title: 'MR title',
webUrl: 'https://example.gitlab.com/mr_url',
state: STATUS_MERGED,
reference: '!99',
},
},
];
describe('TimelogsTable component', () => {
Vue.use(VueApollo);
let wrapper;
const findTable = () => wrapper.findComponent(GlTable);
const findTableRows = () => findTable().find('tbody').findAll('tr');
const findRowSpentAt = (rowIndex) =>
extendedWrapper(findTableRows().at(rowIndex)).findByTestId('date-container');
const findRowSource = (rowIndex) => findTableRows().at(rowIndex).findComponent(TimelogSourceCell);
const findRowUser = (rowIndex) => findTableRows().at(rowIndex).findComponent(UserAvatarLink);
const findRowTimeSpent = (rowIndex) =>
extendedWrapper(findTableRows().at(rowIndex)).findByTestId('time-spent-container');
const findRowSummary = (rowIndex) =>
extendedWrapper(findTableRows().at(rowIndex)).findByTestId('summary-container');
const mountComponent = (props = {}) => {
wrapper = mountExtended(TimelogsTable, {
propsData: {
entries: timelogsMock,
limitToHours: false,
...props,
},
stubs: { GlTable },
});
};
describe('when there are no entries', () => {
it('show the empty table message and no rows', () => {
mountComponent({ entries: [] });
expect(findTable().text()).toContain('There are no records to show');
expect(findTableRows()).toHaveLength(1);
});
});
describe('when there are some entries', () => {
it('does not show the empty table message and has the correct number of rows', () => {
mountComponent();
expect(findTable().text()).not.toContain('There are no records to show');
expect(findTableRows()).toHaveLength(3);
});
describe('Spent at column', () => {
it('shows the spent at value with in the correct format', () => {
mountComponent();
expect(findRowSpentAt(0).text()).toBe('March 27, 2023, 21:00 (UTC: +0000)');
});
});
describe('Source column', () => {
it('creates the source cell component passing the right props', () => {
mountComponent();
expect(findRowSource(0).props()).toMatchObject({
timelog: timelogsMock[0],
});
expect(findRowSource(1).props()).toMatchObject({
timelog: timelogsMock[1],
});
expect(findRowSource(2).props()).toMatchObject({
timelog: timelogsMock[2],
});
});
});
describe('User column', () => {
it('creates the user avatar component passing the right props', () => {
mountComponent();
expect(findRowUser(0).props()).toMatchObject({
linkHref: timelogsMock[0].user.webPath,
imgSrc: timelogsMock[0].user.avatarUrl,
imgSize: 16,
imgAlt: timelogsMock[0].user.name,
tooltipText: timelogsMock[0].user.name,
username: timelogsMock[0].user.name,
});
expect(findRowUser(1).props()).toMatchObject({
linkHref: timelogsMock[1].user.webPath,
imgSrc: timelogsMock[1].user.avatarUrl,
imgSize: 16,
imgAlt: timelogsMock[1].user.name,
tooltipText: timelogsMock[1].user.name,
username: timelogsMock[1].user.name,
});
expect(findRowUser(2).props()).toMatchObject({
linkHref: timelogsMock[2].user.webPath,
imgSrc: timelogsMock[2].user.avatarUrl,
imgSize: 16,
imgAlt: timelogsMock[2].user.name,
tooltipText: timelogsMock[2].user.name,
username: timelogsMock[2].user.name,
});
});
});
describe('Time spent column', () => {
it('shows the time spent value with the correct format when `limitToHours` is false', () => {
mountComponent();
expect(findRowTimeSpent(0).text()).toBe('10m');
expect(findRowTimeSpent(1).text()).toBe('1h');
expect(findRowTimeSpent(2).text()).toBe('3d 3h');
});
it('shows the time spent value with the correct format when `limitToHours` is true', () => {
mountComponent({ limitToHours: true });
expect(findRowTimeSpent(0).text()).toBe('10m');
expect(findRowTimeSpent(1).text()).toBe('1h');
expect(findRowTimeSpent(2).text()).toBe('27h');
});
});
describe('Summary column', () => {
it('shows the summary from the note when note body is present and not empty', () => {
mountComponent({
entries: [{ ...baseTimelogMock, note: { body: 'Summary from note body' } }],
});
expect(findRowSummary(0).text()).toBe('Summary from note body');
});
it('shows the summary from the timelog note body is present but empty', () => {
mountComponent({
entries: [{ ...baseTimelogMock, note: { body: '' } }],
});
expect(findRowSummary(0).text()).toBe('Summary from timelog field');
});
it('shows the summary from the timelog note body is not present', () => {
mountComponent({
entries: [baseTimelogMock],
});
expect(findRowSummary(0).text()).toBe('Summary from timelog field');
});
});
});
});

View File

@ -1,21 +1,28 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlDropdownItem } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import SidebarAssignee from '~/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue';
import SidebarAssignees from '~/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue';
import AlertSetAssignees from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar Assignees', () => {
let wrapper;
let requestHandlers;
let mock;
const mockPath = '/-/autocomplete/users.json';
const mockUrlRoot = '/gitlab';
const expectedUrl = `${mockUrlRoot}${mockPath}`;
const mockUsers = [
{
avatar_url:
@ -40,81 +47,64 @@ describe('Alert Details Sidebar Assignees', () => {
const findSidebarIcon = () => wrapper.findByTestId('assignees-icon');
const findUnassigned = () => wrapper.findByTestId('unassigned-users');
function mountComponent({
data,
users = [],
isDropdownSearching = false,
sidebarCollapsed = true,
loading = false,
stubs = {},
} = {}) {
wrapper = shallowMountExtended(SidebarAssignees, {
data() {
return {
users,
isDropdownSearching,
};
},
propsData: {
alert: { ...mockAlert },
...data,
sidebarCollapsed,
projectPath: 'projectPath',
projectId: '1',
},
mocks: {
$apollo: {
mutate: jest.fn(),
queries: {
alert: {
loading,
const mockDefaultHandler = (errors = []) =>
jest.fn().mockResolvedValue({
data: {
issuableSetAssignees: {
errors,
issuable: {
id: 'id',
iid: 'iid',
assignees: {
nodes: [],
},
notes: {
nodes: [],
},
},
},
},
stubs,
});
const createMockApolloProvider = (handlers) => {
Vue.use(VueApollo);
requestHandlers = handlers;
return createMockApollo([[AlertSetAssignees, handlers]]);
};
function mountComponent({
props,
sidebarCollapsed = true,
handlers = mockDefaultHandler(),
} = {}) {
wrapper = shallowMountExtended(SidebarAssignees, {
apolloProvider: createMockApolloProvider(handlers),
propsData: {
alert: { ...mockAlert },
...props,
sidebarCollapsed,
projectPath: 'projectPath',
projectId: '1',
},
});
}
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
mock.restore();
});
describe('sidebar expanded', () => {
const mockUpdatedMutationResult = {
data: {
alertSetAssignees: {
errors: [],
alert: {
assigneeUsernames: ['root'],
},
},
},
};
beforeEach(() => {
mock = new MockAdapter(axios);
window.gon = {
relative_url_root: mockUrlRoot,
};
mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers);
mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, mockUsers);
mountComponent({
data: { alert: mockAlert },
props: { alert: mockAlert },
sidebarCollapsed: false,
loading: false,
users: mockUsers,
stubs: {
SidebarAssignee,
},
});
});
it('renders a unassigned option', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ isDropdownSearching: false });
await nextTick();
await waitForPromises();
expect(findDropdown().text()).toBe('Unassigned');
});
@ -122,60 +112,38 @@ describe('Alert Details Sidebar Assignees', () => {
expect(findSidebarIcon().exists()).toBe(false);
});
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ isDropdownSearching: false });
await nextTick();
it('calls `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
await waitForPromises();
wrapper.findComponent(SidebarAssignee).vm.$emit('update-alert-assignees', 'root');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: AlertSetAssignees,
variables: {
iid: '1527542',
assigneeUsernames: ['root'],
fullPath: 'projectPath',
},
expect(requestHandlers).toHaveBeenCalledWith({
iid: '1527542',
assigneeUsernames: ['root'],
fullPath: 'projectPath',
});
});
it('emits an error when request contains error messages', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ isDropdownSearching: false });
const errorMutationResult = {
data: {
issuableSetAssignees: {
errors: ['There was a problem for sure.'],
alert: {},
},
},
};
mountComponent({
sidebarCollapsed: false,
handlers: mockDefaultHandler(['There was a problem for sure.']),
});
await waitForPromises();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult);
await nextTick();
const SideBarAssigneeItem = wrapper.findAllComponents(SidebarAssignee).at(0);
await SideBarAssigneeItem.vm.$emit('update-alert-assignees');
expect(wrapper.emitted('alert-error')).toBeDefined();
await waitForPromises();
expect(wrapper.emitted('alert-error')).toHaveLength(1);
});
it('stops updating and cancels loading when the request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
wrapper.vm.updateAlertAssignees('root');
expect(findUnassigned().text()).toBe('assign yourself');
});
it('shows a user avatar, username and full name when a user is set', () => {
mountComponent({
data: { alert: mockAlerts[1] },
sidebarCollapsed: false,
loading: false,
stubs: {
SidebarAssignee,
},
props: { alert: mockAlerts[1] },
});
expect(findAssigned().find('img').attributes('src')).toBe('/url');
@ -188,15 +156,10 @@ describe('Alert Details Sidebar Assignees', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers);
mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, mockUsers);
mountComponent({
data: { alert: mockAlert },
loading: false,
users: mockUsers,
stubs: {
SidebarAssignee,
},
props: { alert: mockAlert },
});
});
it('does not display the status dropdown', () => {

View File

@ -214,7 +214,11 @@ RSpec.describe Resolvers::TimelogResolver, feature_category: :team_planning do
let_it_be(:timelog3) { create(:merge_request_timelog, merge_request: merge_request, user: current_user) }
it 'blah' do
expect(timelogs).to contain_exactly(timelog1, timelog3)
if user_found
expect(timelogs).to contain_exactly(timelog1, timelog3)
else
expect(timelogs).to be_empty
end
end
end
@ -250,16 +254,28 @@ RSpec.describe Resolvers::TimelogResolver, feature_category: :team_planning do
let(:object) { current_user }
let(:extra_args) { {} }
let(:args) { {} }
let(:user_found) { true }
it_behaves_like 'with a user'
end
context 'with a user filter' do
let(:object) { nil }
let(:extra_args) { { username: current_user.username } }
let(:args) { {} }
it_behaves_like 'with a user'
context 'when the user has timelogs' do
let(:extra_args) { { username: current_user.username } }
let(:user_found) { true }
it_behaves_like 'with a user'
end
context 'when the user doest not have timelogs' do
let(:extra_args) { { username: 'not_existing_user' } }
let(:user_found) { false }
it_behaves_like 'with a user'
end
end
context 'when no object or arguments provided' do

View File

@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Timelog'], feature_category: :team_planning do
let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions] }
let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions project] }
it { expect(described_class.graphql_name).to eq('Timelog') }
it { expect(described_class).to have_graphql_fields(fields) }

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe TimeTracking::TimelogsController, feature_category: :team_planning do
let_it_be(:user) { create(:user) }
describe 'GET #index' do
subject { get timelogs_path }
context 'when user is not logged in' do
it 'responds with a redirect to the login page' do
subject
expect(response).to have_gitlab_http_status(:redirect)
end
end
context 'when user is logged in' do
before do
sign_in(user)
end
context 'when global_time_tracking_report FF is enabled' do
it 'responds with the global time tracking page', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
end
end
context 'when global_time_tracking_report FF is disable' do
before do
stub_feature_flags(global_time_tracking_report: false)
end
it 'returns a 404 page' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
end