Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
d4e0452ed9
commit
9c05a84cac
|
|
@ -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
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ccdfef925ac6fd2264d456f438faa0ca7adaffc2
|
||||
d7ad67347247776ec267d4f2056e2c4cffcf4ebd
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import initTimelogsApp from '~/time_tracking';
|
||||
|
||||
initTimelogsApp();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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`">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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. | |
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue