summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/ci/rules.gitlab-ci.yml2
-rw-r--r--CHANGELOG.md12
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_events_list.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue2
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue2
-rw-r--r--app/assets/javascripts/ide/components/shared/commit_message_field.vue2
-rw-r--r--app/assets/javascripts/issues/constants.js3
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue2
-rw-r--r--app/assets/javascripts/lib/utils/datetime/time_spent_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js1
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js10
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/time_tracking/timelogs/index.js3
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js21
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue2
-rw-r--r--app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql69
-rw-r--r--app/assets/javascripts/time_tracking/components/timelog_source_cell.vue50
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_app.vue229
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_table.vue105
-rw-r--r--app/assets/javascripts/time_tracking/index.js32
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue2
-rw-r--r--app/assets/stylesheets/framework/source_editor.scss23
-rw-r--r--app/assets/stylesheets/framework/variables.scss6
-rw-r--r--app/assets/stylesheets/utilities.scss18
-rw-r--r--app/controllers/time_tracking/timelogs_controller.rb12
-rw-r--r--app/graphql/resolvers/timelog_resolver.rb2
-rw-r--r--app/graphql/types/timelog_type.rb4
-rw-r--r--app/views/time_tracking/timelogs/index.html.haml7
-rw-r--r--config/feature_flags/development/global_time_tracking_report.yml8
-rw-r--r--config/feature_flags/development/pages_deploy_upload_file_outside_transaction.yml2
-rw-r--r--config/routes.rb2
-rw-r--r--db/fixtures/development/37_timelogs.rb99
-rw-r--r--doc/api/graphql/reference/index.md2
-rw-r--r--doc/development/internal_api/index.md37
-rw-r--r--doc/tutorials/index.md1
-rw-r--r--doc/user/application_security/index.md2
-rw-r--r--doc/user/enterprise_user/index.md2
-rw-r--r--doc/user/group/import/index.md2
-rw-r--r--doc/user/group/saml_sso/index.md27
-rw-r--r--doc/user/group/saml_sso/scim_setup.md2
-rw-r--r--doc/user/group/saml_sso/troubleshooting.md14
-rw-r--r--doc/user/markdown.md20
-rw-r--r--locale/gitlab.pot36
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb5
-rw-r--r--spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js2
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js4
-rw-r--r--spec/frontend/fixtures/timelogs.rb53
-rw-r--r--spec/frontend/lib/utils/datetime/time_spent_utility_spec.js25
-rw-r--r--spec/frontend/performance_bar/stores/performance_bar_store_spec.js8
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap2
-rw-r--r--spec/frontend/time_tracking/components/timelog_source_cell_spec.js136
-rw-r--r--spec/frontend/time_tracking/components/timelogs_app_spec.js238
-rw-r--r--spec/frontend/time_tracking/components/timelogs_table_spec.js223
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js157
-rw-r--r--spec/graphql/resolvers/timelog_resolver_spec.rb22
-rw-r--r--spec/graphql/types/timelog_type_spec.rb2
-rw-r--r--spec/requests/time_tracking/timelogs_controller_spec.rb46
62 files changed, 1661 insertions, 168 deletions
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index c8c79decd10..03af9990722 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7fd8392f627..098cf10c7fc 100644
--- a/CHANGELOG.md
+++ b/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)
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 3a8a46adab2..01e42c9eaab 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-ccdfef925ac6fd2264d456f438faa0ca7adaffc2
+d7ad67347247776ec267d4f2056e2c4cffcf4ebd
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 2733a59f62d..1a586bd1e91 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -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"
/>
diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
index ca65665b9ed..24a776e1a29 100644
--- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
+++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
@@ -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>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index dbe2119fadb..d7e98638a11 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -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>
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 31bc462f0b9..b2843b79ba6 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -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">
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 76a68624a63..564942b4d80 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 2799ea1378e..d05aa960f01 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -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"
diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
index 7fca7429ad7..428cf7f55ac 100644
--- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue
+++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
@@ -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"
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index b7d885ed8a7..d35355a8f26 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -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'),
};
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 14aaaa219e9..1c7ba1d331b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
@@ -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">
diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
new file mode 100644
index 00000000000..64c77bf1080
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
@@ -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 }));
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index c1081239544..f9a70371680 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -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';
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 3dbcf28d11c..90de7db8c1b 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -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 {
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 242c5a1a97b..eab4be4dcf1 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -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>
diff --git a/app/assets/javascripts/pages/time_tracking/timelogs/index.js b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
new file mode 100644
index 00000000000..41c78fbe3a6
--- /dev/null
+++ b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
@@ -0,0 +1,3 @@
+import initTimelogsApp from '~/time_tracking';
+
+initTimelogsApp();
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 71f7e5b42f4..34e2763a478 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -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);
diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index e6aa3be0371..24dd978585c 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -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">
diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
new file mode 100644
index 00000000000..3ba0ab29530
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
@@ -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
+ }
+ }
+}
diff --git a/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
new file mode 100644
index 00000000000..33b0ac4b58e
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
@@ -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>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
new file mode 100644
index 00000000000..2069e4a6722
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
@@ -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>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_table.vue b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
new file mode 100644
index 00000000000..b2efb44f56f
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
@@ -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>
diff --git a/app/assets/javascripts/time_tracking/index.js b/app/assets/javascripts/time_tracking/index.js
new file mode 100644
index 00000000000..9cff01799d9
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/index.js
@@ -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),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
index e97701d5991..beff3b4c0c3 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
@@ -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`">
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index dd9d2ce66cd..4c8e4eb5aa1 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -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>
diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss
index 046b8636f65..f1ee4c94942 100644
--- a/app/assets/stylesheets/framework/source_editor.scss
+++ b/app/assets/stylesheets/framework/source_editor.scss
@@ -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;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 2743bba976c..30849ecfdee 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -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:
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index fc5be72f7cf..66c543aa654 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -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;
+ }
+}
diff --git a/app/controllers/time_tracking/timelogs_controller.rb b/app/controllers/time_tracking/timelogs_controller.rb
new file mode 100644
index 00000000000..a2cac071796
--- /dev/null
+++ b/app/controllers/time_tracking/timelogs_controller.rb
@@ -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
diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb
index dc42a5f38c9..d2b67451698 100644
--- a/app/graphql/resolvers/timelog_resolver.rb
+++ b/app/graphql/resolvers/timelog_resolver.rb
@@ -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
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
index 3a060518cd9..88baca028ef 100644
--- a/app/graphql/types/timelog_type.rb
+++ b/app/graphql/types/timelog_type.rb
@@ -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
diff --git a/app/views/time_tracking/timelogs/index.html.haml b/app/views/time_tracking/timelogs/index.html.haml
new file mode 100644
index 00000000000..b0bfc749606
--- /dev/null
+++ b/app/views/time_tracking/timelogs/index.html.haml
@@ -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 } }
diff --git a/config/feature_flags/development/global_time_tracking_report.yml b/config/feature_flags/development/global_time_tracking_report.yml
new file mode 100644
index 00000000000..eb2072e33d3
--- /dev/null
+++ b/config/feature_flags/development/global_time_tracking_report.yml
@@ -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
diff --git a/config/feature_flags/development/pages_deploy_upload_file_outside_transaction.yml b/config/feature_flags/development/pages_deploy_upload_file_outside_transaction.yml
index f3a502593b0..c656166bcc0 100644
--- a/config/feature_flags/development/pages_deploy_upload_file_outside_transaction.yml
+++ b/config/feature_flags/development/pages_deploy_upload_file_outside_transaction.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 50b28830e99..10b1378d924 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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.
diff --git a/db/fixtures/development/37_timelogs.rb b/db/fixtures/development/37_timelogs.rb
new file mode 100644
index 00000000000..b5be9d21cb3
--- /dev/null
+++ b/db/fixtures/development/37_timelogs.rb
@@ -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
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 539f1c1d33f..916c01958ca 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -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. |
diff --git a/doc/development/internal_api/index.md b/doc/development/internal_api/index.md
index 1c7499f1b6d..c1c0177609b 100644
--- a/doc/development/internal_api/index.md
+++ b/doc/development/internal_api/index.md
@@ -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`.
diff --git a/doc/tutorials/index.md b/doc/tutorials/index.md
index d634e8e5743..aee3adba919 100644
--- a/doc/tutorials/index.md
+++ b/doc/tutorials/index.md
@@ -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. | |
diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md
index 492b200d22b..d2452441c38 100644
--- a/doc/user/application_security/index.md
+++ b/doc/user/application_security/index.md
@@ -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. |
diff --git a/doc/user/enterprise_user/index.md b/doc/user/enterprise_user/index.md
index b6a823c656f..901731ad6f9 100644
--- a/doc/user/enterprise_user/index.md
+++ b/doc/user/enterprise_user/index.md
@@ -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.
diff --git a/doc/user/group/import/index.md b/doc/user/group/import/index.md
index a8617677e43..433c842384c 100644
--- a/doc/user/group/import/index.md
+++ b/doc/user/group/import/index.md
@@ -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
diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md
index a76eca4ffac..bf5c58ef442 100644
--- a/doc/user/group/saml_sso/index.md
+++ b/doc/user/group/saml_sso/index.md
@@ -59,7 +59,7 @@ To set up SSO with Azure as your identity provider:
1. Make sure the identity provider is set to have provider-initiated calls
to link existing GitLab accounts.
-1. Optional. If you use [Group Sync](#group-sync), customize the name of the
+1. Optional. If you use [Group Sync](group_sync.md), customize the name of the
group claim to match the required attribute.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
@@ -227,8 +227,6 @@ After you set up your identity provider to work with GitLab, you must configure
For more information, see the [SSO enforcement documentation](#sso-enforcement).
1. Select **Save changes**.
-![Group SAML Settings for GitLab.com](img/group_saml_settings_v13_12.png)
-
NOTE:
The certificate [fingerprint algorithm](../../../integration/saml.md#configure-saml-on-your-idp) must be in SHA1. When configuring the identity provider (such as [Google Workspace](#set-up-google-workspace)), use a secure signature algorithm.
@@ -260,7 +258,7 @@ You can pass user information to GitLab as attributes in the SAML assertion.
For more information, see the [attributes available for self-managed GitLab instances](../../../integration/saml.md#configure-assertions).
-### Linking SAML to your existing GitLab.com account
+### Link SAML to your existing GitLab.com account
> **Remember me** checkbox [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/121569) in GitLab 15.7.
@@ -274,9 +272,9 @@ To link SAML to your existing GitLab.com account:
1. Enter your credentials on the identity provider if prompted.
1. You are then redirected back to GitLab.com and should now have access to the group. In the future, you can use SAML to sign in to GitLab.com.
-On subsequent visits, you should be able to go [sign in to GitLab.com with SAML](#signing-in-to-gitlabcom-with-saml) or by visiting links directly. If the **enforce SSO** option is turned on, you are then redirected to sign in through the identity provider.
+On subsequent visits, you should be able to go [sign in to GitLab.com with SAML](#sign-in-to-gitlabcom-with-saml) or by visiting links directly. If the **enforce SSO** option is turned on, you are then redirected to sign in through the identity provider.
-### Signing in to GitLab.com with SAML
+### Sign in to GitLab.com with SAML
1. Sign in to your identity provider.
1. From the list of apps, select the "GitLab.com" app. (The name is set by the administrator of the identity provider.)
@@ -291,8 +289,8 @@ If [SCIM](scim_setup.md) is configured, group owners can update the SCIM identit
Alternatively, ask the users to reconnect their SAML account.
-1. Ask relevant users to [unlink their account from the group](#unlinking-accounts).
-1. Ask relevant users to [link their account to the new SAML app](#linking-saml-to-your-existing-gitlabcom-account).
+1. Ask relevant users to [unlink their account from the group](#unlink-accounts).
+1. Ask relevant users to [link their account to the new SAML app](#link-saml-to-your-existing-gitlabcom-account).
### Configure user settings from SAML response
@@ -305,9 +303,9 @@ created via [SCIM](scim_setup.md) or by first sign-in with SAML SSO for GitLab.c
#### Supported user attributes
-- `can_create_group` - 'true' or 'false' to indicate whether the user can create
+- **can_create_group** - `true` or `false` to indicate whether the user can create
new groups. Default is `true`.
-- `projects_limit` - The total number of personal projects a user can create.
+- **projects_limit** - The total number of personal projects a user can create.
A value of `0` means the user cannot create new projects in their personal
namespace. Default is `10000`.
@@ -365,7 +363,7 @@ If a user is already a member of the group, linking the SAML identity does not c
Users given a "minimal access" role have [specific restrictions](../../permissions.md#users-with-minimal-access).
-### Blocking access
+### Block user access
To rescind a user's access to the group when only SAML SSO is configured, either:
@@ -376,7 +374,7 @@ To rescind a user's access to the group when only SAML SSO is configured, either
To rescind a user's access to the group when also using SCIM, refer to [Remove access](scim_setup.md#remove-access).
-### Unlinking accounts
+### Unlink accounts
Users can unlink SAML for a group from their profile page. This can be helpful if:
@@ -398,10 +396,6 @@ For example, to unlink the `MyOrg` account:
1. On the left sidebar, select **Account**.
1. In the **Service sign-in** section, select **Disconnect** next to the connected account.
-## Group Sync
-
-For information on automatically managing GitLab group membership, see [SAML Group Sync](group_sync.md).
-
## NameID
GitLab.com uses the SAML **NameID** to identify users. The **NameID** is:
@@ -518,6 +512,7 @@ immediately. If the user:
- [Glossary](../../../integration/saml.md#glossary)
- [Authentication comparison between SaaS and self-managed](../../../administration/auth/index.md#saas-vs-self-managed-comparison)
- [Passwords for users created through integrated authentication](../../../security/passwords_for_integrated_authentication_methods.md)
+- [SAML Group Sync](group_sync.md)
## Troubleshooting
diff --git a/doc/user/group/saml_sso/scim_setup.md b/doc/user/group/saml_sso/scim_setup.md
index a61615104c1..9c4ae9e7a27 100644
--- a/doc/user/group/saml_sso/scim_setup.md
+++ b/doc/user/group/saml_sso/scim_setup.md
@@ -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
diff --git a/doc/user/group/saml_sso/troubleshooting.md b/doc/user/group/saml_sso/troubleshooting.md
index a022c299d0f..103ab0b6ed6 100644
--- a/doc/user/group/saml_sso/troubleshooting.md
+++ b/doc/user/group/saml_sso/troubleshooting.md
@@ -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.
diff --git a/doc/user/markdown.md b/doc/user/markdown.md
index d7f8176001f..0dfadba9dcb 100644
--- a/doc/user/markdown.md
+++ b/doc/user/markdown.md
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2a0e1d43c66..114fc340132 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb
index 1b82543a5d4..38831f6f158 100644
--- a/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/merge_when_pipeline_succeeds_spec.rb
@@ -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|
diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
index 1e125bdfd3a..2b8479eab6d 100644
--- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
+++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
@@ -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);
});
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index 73a366457fb..f50efada91a 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -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', () => {
diff --git a/spec/frontend/fixtures/timelogs.rb b/spec/frontend/fixtures/timelogs.rb
new file mode 100644
index 00000000000..c66e2447ea6
--- /dev/null
+++ b/spec/frontend/fixtures/timelogs.rb
@@ -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
diff --git a/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js
new file mode 100644
index 00000000000..15e056e45d0
--- /dev/null
+++ b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js
@@ -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');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js
index 7d5c5031792..170469db6ad 100644
--- a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js
+++ b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js
@@ -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', () => {
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
index f4ebc5c3e3f..ed54582ca29 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
@@ -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>
diff --git a/spec/frontend/time_tracking/components/timelog_source_cell_spec.js b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js
new file mode 100644
index 00000000000..b9be4689c38
--- /dev/null
+++ b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/frontend/time_tracking/components/timelogs_app_spec.js b/spec/frontend/time_tracking/components/timelogs_app_spec.js
new file mode 100644
index 00000000000..ca470ce63ac
--- /dev/null
+++ b/spec/frontend/time_tracking/components/timelogs_app_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/frontend/time_tracking/components/timelogs_table_spec.js b/spec/frontend/time_tracking/components/timelogs_table_spec.js
new file mode 100644
index 00000000000..980fb79e8fb
--- /dev/null
+++ b/spec/frontend/time_tracking/components/timelogs_table_spec.js
@@ -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');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
index 98a357bac2b..bf4435fae45 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
@@ -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');
+ const mockDefaultHandler = (errors = []) =>
+ jest.fn().mockResolvedValue({
+ data: {
+ issuableSetAssignees: {
+ errors,
+ issuable: {
+ id: 'id',
+ iid: 'iid',
+ assignees: {
+ nodes: [],
+ },
+ notes: {
+ nodes: [],
+ },
+ },
+ },
+ },
+ });
+ const createMockApolloProvider = (handlers) => {
+ Vue.use(VueApollo);
+ requestHandlers = handlers;
+
+ return createMockApollo([[AlertSetAssignees, handlers]]);
+ };
+
function mountComponent({
- data,
- users = [],
- isDropdownSearching = false,
+ props,
sidebarCollapsed = true,
- loading = false,
- stubs = {},
+ handlers = mockDefaultHandler(),
} = {}) {
wrapper = shallowMountExtended(SidebarAssignees, {
- data() {
- return {
- users,
- isDropdownSearching,
- };
- },
+ apolloProvider: createMockApolloProvider(handlers),
propsData: {
alert: { ...mockAlert },
- ...data,
+ ...props,
sidebarCollapsed,
projectPath: 'projectPath',
projectId: '1',
},
- mocks: {
- $apollo: {
- mutate: jest.fn(),
- queries: {
- alert: {
- loading,
- },
- },
- },
- },
- stubs,
});
}
- 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: {},
- },
- },
- };
-
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult);
+ mountComponent({
+ sidebarCollapsed: false,
+ handlers: mockDefaultHandler(['There was a problem for sure.']),
+ });
+ await waitForPromises();
- 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', () => {
diff --git a/spec/graphql/resolvers/timelog_resolver_spec.rb b/spec/graphql/resolvers/timelog_resolver_spec.rb
index cd52308d895..5177873321c 100644
--- a/spec/graphql/resolvers/timelog_resolver_spec.rb
+++ b/spec/graphql/resolvers/timelog_resolver_spec.rb
@@ -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
diff --git a/spec/graphql/types/timelog_type_spec.rb b/spec/graphql/types/timelog_type_spec.rb
index 59a0e373c5d..aa05c5ffd94 100644
--- a/spec/graphql/types/timelog_type_spec.rb
+++ b/spec/graphql/types/timelog_type_spec.rb
@@ -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) }
diff --git a/spec/requests/time_tracking/timelogs_controller_spec.rb b/spec/requests/time_tracking/timelogs_controller_spec.rb
new file mode 100644
index 00000000000..68eecf9b137
--- /dev/null
+++ b/spec/requests/time_tracking/timelogs_controller_spec.rb
@@ -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