Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-05-16 15:07:26 +00:00
parent 2c4aefd350
commit e388922d11
78 changed files with 525 additions and 146 deletions

View File

@ -3,7 +3,6 @@ Lint/DuplicateBranch:
Exclude:
- 'app/controllers/concerns/issuable_collections.rb'
- 'app/controllers/concerns/sorting_preference.rb'
- 'app/controllers/projects/google_cloud/databases_controller.rb'
- 'app/helpers/icons_helper.rb'
- 'app/helpers/labels_helper.rb'
- 'app/models/application_setting_implementation.rb'

View File

@ -1,4 +0,0 @@
---
Lint/DuplicateCaseCondition:
Exclude:
- 'app/helpers/icons_helper.rb'

View File

@ -1 +1 @@
3030e219d83d55b287a9b3934141a8d5d8d67d22
b446ea3d1838519a0d8f09885d8e907addd7d780

View File

@ -1,11 +1,11 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { isNumber } from 'lodash';
import { n__ } from '~/locale';
import { isNotDiffable, stats } from '../utils/diff_file';
export default {
components: { GlIcon },
components: { GlIcon, GlSprintf },
props: {
diffFile: {
type: Object,
@ -31,7 +31,7 @@ export default {
return parseInt(this.diffsCount, 10);
},
filesText() {
return n__('file', 'files', this.diffFilesLength);
return n__('%{count} file', '%{count} files', this.diffFilesLength);
},
isCompareVersionsHeader() {
return Boolean(this.diffsCount);
@ -45,6 +45,18 @@ export default {
fileStats() {
return stats(this.diffFile);
},
statsLabel() {
const counters = [];
if (this.addedLines > 0)
counters.push(
n__('RapidDiffs|Added %d line.', 'RapidDiffs|Added %d lines.', this.addedLines),
);
if (this.removedLines > 0)
counters.push(
n__('RapidDiffs|Removed %d line.', 'RapidDiffs|Removed %d lines.', this.removedLines),
);
return counters.join(' ');
},
},
};
</script>
@ -63,21 +75,27 @@ export default {
<div v-else class="diff-stats-contents">
<div v-if="hasDiffFiles" class="diff-stats-group">
<gl-icon name="doc-code" class="diff-stats-icon" variant="subtle" />
<span class="gl-font-bold gl-text-subtle">{{ diffsCount }} {{ filesText }}</span>
<span class="gl-font-bold gl-text-subtle">
<gl-sprintf :message="filesText">
<template #count>{{ diffsCount }}</template>
</gl-sprintf>
</span>
</div>
<div
class="diff-stats-group gl-flex gl-items-center gl-text-success"
:class="{ 'gl-font-bold': isCompareVersionsHeader }"
>
<span>+</span>
<span data-testid="js-file-addition-line">{{ addedLines }}</span>
</div>
<div
class="diff-stats-group gl-flex gl-items-center gl-text-danger"
:class="{ 'gl-font-bold': isCompareVersionsHeader }"
>
<span></span>
<span data-testid="js-file-deletion-line">{{ removedLines }}</span>
<div class="gl-flex" :aria-label="statsLabel">
<div
class="diff-stats-group gl-flex gl-items-center gl-text-success"
:class="{ 'gl-font-bold': isCompareVersionsHeader }"
>
<span>+</span>
<span data-testid="js-file-addition-line">{{ addedLines }}</span>
</div>
<div
class="diff-stats-group gl-flex gl-items-center gl-text-danger"
:class="{ 'gl-font-bold': isCompareVersionsHeader }"
>
<span></span>
<span data-testid="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
<script>
import { GlToggle, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { GlToggle, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import getProjectPagesDeployments from '../queries/get_project_pages_deployments.graphql';
@ -13,7 +13,6 @@ export default {
LoadMoreDeployments,
PagesDeployment,
GlToggle,
GlLoadingIcon,
GlAlert,
},
inject: ['projectFullPath'],
@ -157,9 +156,13 @@ export default {
{{ message }}
</gl-alert>
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ $options.i18n.loadErrorMessage }}
</gl-alert>
<crud-component
v-if="loadedPrimaryDeploymentsCount > 0"
:title="$options.i18n.title"
:is-loading="!primaryDeployments && $apollo.loading"
data-testid="primary-deployment-list"
>
<template #actions>
@ -171,7 +174,11 @@ export default {
/>
</template>
<ul class="content-list">
<template v-if="!loadedPrimaryDeploymentsCount" #empty>
{{ $options.i18n.noDeploymentsMessage }}.
</template>
<ul v-if="loadedPrimaryDeploymentsCount" class="content-list">
<pages-deployment
v-for="node in primaryDeployments.nodes"
:key="node.id"
@ -195,9 +202,14 @@ export default {
<crud-component
v-if="loadedParallelDeploymentsCount > 0"
:title="$options.i18n.parallelDeploymentsTitle"
:is-loading="!parallelDeployments && $apollo.loading"
data-testid="parallel-deployment-list"
>
<ul class="content-list">
<template v-if="!loadedParallelDeploymentsCount" #empty>
{{ $options.i18n.noDeploymentsMessage }}.
</template>
<ul v-if="loadedParallelDeploymentsCount" class="content-list">
<pages-deployment
v-for="node in parallelDeployments.nodes"
:key="node.id"
@ -217,18 +229,5 @@ export default {
/>
</template>
</crud-component>
<div v-if="!primaryDeployments && !parallelDeployments && $apollo.loading">
<gl-loading-icon size="sm" />
</div>
<div
v-else-if="!loadedPrimaryDeploymentsCount && !loadedParallelDeploymentsCount"
class="gl-text-center gl-text-subtle"
>
{{ $options.i18n.noDeploymentsMessage }}
</div>
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ $options.i18n.loadErrorMessage }}
</gl-alert>
</div>
</template>

View File

@ -21,6 +21,11 @@ export default {
required: true,
type: Object,
},
displayAsLink: {
required: false,
default: false,
type: Boolean,
},
},
data() {
return {
@ -50,7 +55,7 @@ export default {
<gl-link
ref="reference"
class="gl-text-strong"
:class="`gfm gfm-${type}`"
:class="[`gfm gfm-${type}`, { '!gl-text-link': displayAsLink }]"
:data-original="`${project || group}${data.reference}+`"
:data-reference-type="type"
:title="data.title"

View File

@ -116,7 +116,8 @@ export default {
:data-testid="`table-row-${itemIndex}`"
>
<td v-for="field in fields" :key="field.key">
<component :is="presenter.forField(item, field.key)" />
<!-- eslint-disable-next-line @gitlab/vue-no-new-non-primitive-in-template -->
<component :is="presenter.forField(item, field.key, { displayAsLink: true })" />
</td>
</tr>
</template>

View File

@ -72,14 +72,14 @@ export default class Presenter {
#config;
#component;
forField(item, fieldName) {
forField(item, fieldName, props) {
const field = fieldName === 'title' || !fieldName ? item : item[fieldName];
const component = componentForField(field, fieldName);
const { source } = this.#config || {};
return {
render(h) {
return h(component, { props: { data: field, source } });
return h(component, { props: { data: field, source, ...props } });
},
};
}

View File

@ -119,11 +119,12 @@ export default {
return transformedVariables;
},
update(response) {
const { nodes, pageInfo } = get(response, this.tab.queryPath);
const { nodes, pageInfo, count } = get(response, this.tab.queryPath);
return {
nodes: this.tab.formatter(nodes),
pageInfo,
count,
};
},
result() {
@ -207,6 +208,11 @@ export default {
return baseProps;
},
},
watch: {
'items.count': function watchCount(newCount) {
this.$emit('update-count', this.tab, newCount);
},
},
methods: {
async onRefetch() {
await this.apolloClient.clearStore();

View File

@ -4,6 +4,7 @@ import { isEqual, pick, get } from 'lodash';
import { __ } from '~/locale';
import { QUERY_PARAM_END_CURSOR, QUERY_PARAM_START_CURSOR } from '~/graphql_shared/constants';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { convertToCamelCase } from '~/lib/utils/text_utility';
import { createAlert } from '~/alert';
import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
import { calculateGraphQLPaginationQueryParams } from '~/graphql_shared/utils';
@ -229,12 +230,14 @@ export default {
timestampType() {
return this.timestampTypeMap[this.activeSortOption.value];
},
hasTabCountsQuery() {
return Boolean(Object.keys(this.tabCountsQuery).length);
},
},
async created() {
this.getTabCounts();
},
methods: {
numberToMetricPrefix,
createSortQuery({ sortBy, isAscending }) {
return `${sortBy}_${isAscending ? SORT_DIRECTION_ASC : SORT_DIRECTION_DESC}`;
},
@ -267,10 +270,9 @@ export default {
this.trackEvent(this.eventTracking.tabs, { label: tab.value });
},
tabCount(tab) {
return this.tabCounts[tab.value];
},
shouldShowCountBadge(tab) {
return this.tabCount(tab) !== undefined;
const tabCount = this.tabCounts[tab.value];
return tabCount === undefined ? '-' : numberToMetricPrefix(tabCount);
},
onSortDirectionChange(isAscending) {
const sort = this.createSortQuery({ sortBy: this.activeSortOption.value, isAscending });
@ -358,15 +360,23 @@ export default {
}
},
async getTabCounts() {
if (!Object.keys(this.tabCountsQuery).length) {
if (!this.hasTabCountsQuery) {
return;
}
try {
const { data } = await this.$apollo.query({ query: this.tabCountsQuery });
const { data } = await this.$apollo.query({
query: this.tabCountsQuery,
// Since GraphQL doesn't support string comparison in @skip(if:)
// we use the naming convention of skip${tabValue} in camelCase (e.g. skipContributed).
// Skip the active tab to avoid requesting the count twice.
variables: { [convertToCamelCase(`skip_${this.activeTab.value}`)]: true },
});
this.tabCounts = this.tabs.reduce((accumulator, tab) => {
const { count } = get(data, tab.countsQueryPath);
const countsQueryPath = get(data, tab.countsQueryPath);
const count =
countsQueryPath === undefined ? this.tabCounts[tab.value] : countsQueryPath.count;
return {
...accumulator,
@ -394,6 +404,9 @@ export default {
this.trackEvent(this.eventTracking.initialLoad, { label: this.activeTab.value });
},
onUpdateCount(tab, newCount) {
this.tabCounts[tab.value] = newCount;
},
},
};
</script>
@ -405,11 +418,11 @@ export default {
<div class="gl-flex gl-items-center gl-gap-2" data-testid="projects-dashboard-tab-title">
<span>{{ tab.text }}</span>
<gl-badge
v-if="shouldShowCountBadge(tab)"
v-if="hasTabCountsQuery"
size="sm"
class="gl-tab-counter-badge"
data-testid="tab-counter-badge"
>{{ numberToMetricPrefix(tabCount(tab)) }}</gl-badge
>{{ tabCount(tab) }}</gl-badge
>
</div>
</template>
@ -431,6 +444,7 @@ export default {
@offset-page-change="onOffsetPageChange"
@refetch="onRefetch"
@query-complete="onQueryComplete"
@update-count="onUpdateCount"
/>
<template v-else>{{ tab.text }}</template>
</gl-tab>

View File

@ -40,6 +40,9 @@ export default class SigninTabsMemoizer {
const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`);
if (tab) {
tab.click();
const section = document.querySelector(anchorName);
section.querySelector('[autocomplete=current-password]')?.focus();
section.querySelector('[autocomplete=username]')?.focus();
} else {
const firstTab = document.querySelector(`${this.tabSelector} a`);
if (firstTab) {

View File

@ -1,20 +1,28 @@
query getProjectCounts {
# Since GraphQL doesn't support string comparison in @skip(if:)
# we use the naming convention of skip${tabValue} in camelCase
query getProjectCounts(
$skipContributed: Boolean = false
$skipStarred: Boolean = false
$skipPersonal: Boolean = false
$skipMember: Boolean = false
$skipInactive: Boolean = false
) {
currentUser {
id
contributed: contributedProjects {
contributed: contributedProjects @skip(if: $skipContributed) {
count
}
starred: starredProjects {
starred: starredProjects @skip(if: $skipStarred) {
count
}
}
personal: projects(personal: true) {
personal: projects(personal: true) @skip(if: $skipPersonal) {
count
}
member: projects(membership: true) {
member: projects(membership: true) @skip(if: $skipMember) {
count
}
inactive: projects(archived: ONLY, membership: true) {
inactive: projects(archived: ONLY, membership: true) @skip(if: $skipInactive) {
count
}
}

View File

@ -33,5 +33,6 @@ query getYourWorkProjects(
pageInfo {
...PageInfo
}
count
}
}

View File

@ -31,6 +31,7 @@ query getYourWorkUserProjects(
pageInfo {
...PageInfo
}
count
}
starredProjects(
first: $first
@ -48,6 +49,7 @@ query getYourWorkUserProjects(
pageInfo {
...PageInfo
}
count
}
}
}

View File

@ -8,7 +8,7 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { TYPENAME_GROUP } from '~/graphql_shared/constants';
import { TYPENAME_CI_JOB_TOKEN_ACCESSIBLE_GROUP } from '~/graphql_shared/constants';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import { s__, __ } from '~/locale';
import { JOB_TOKEN_POLICIES } from '../constants';
@ -78,7 +78,7 @@ export default {
methods: {
itemType(item) {
// eslint-disable-next-line no-underscore-dangle
return item.__typename === TYPENAME_GROUP ? 'group' : 'project';
return item.__typename === TYPENAME_CI_JOB_TOKEN_ACCESSIBLE_GROUP ? 'group' : 'project';
},
getPolicies(policyKeys) {
return policyKeys?.map((key) => JOB_TOKEN_POLICIES[key]);

View File

@ -1,6 +1,6 @@
<script>
import GridstackWrapper from './gridstack_wrapper.vue';
import dashboardConfigValidator from './utils';
import { dashboardConfigValidator } from './utils';
export default {
name: 'DashboardLayout',

View File

@ -98,10 +98,13 @@ export default {
this.mountGridComponents(this.gridPanels);
},
initGridStack() {
this.grid = GridStack.init({
...GRIDSTACK_BASE_CONFIG,
staticGrid: !this.editing,
}).load(this.gridConfig);
this.grid = GridStack.init(
{
...GRIDSTACK_BASE_CONFIG,
staticGrid: !this.editing,
},
this.$refs.grid,
).load(this.gridConfig);
// Sync Vue components array with gridstack items
this.initGridPanelSlots(this.grid.getGridItems());
@ -162,7 +165,7 @@ export default {
</script>
<template>
<div class="grid-stack" data-testid="gridstack-grid">
<div ref="grid" class="grid-stack" data-testid="gridstack-grid">
<div
v-for="panel in gridPanels"
:id="panel.id"

View File

@ -22,6 +22,12 @@
calc(#{$file-header-height} + #{$total-borders} + (#{constants.$code-row-height-target} * var(--total-rows)));
}
// content-visibility: auto; applies paint containment, which means you can not draw outside a diff file
// we need to disable this when we show dropdowns and other elements which extend past a diff file
.rd-diff-file[data-virtual]:has([data-options-toggle] button[aria-expanded='true']) {
--rd-content-visibility-auto: visible;
}
.rd-diff-file-header {
@apply gl-text-default;
position: sticky;

View File

@ -41,13 +41,9 @@
- if @diff_file.stored_externally? && @diff_file.external_storage == :lfs
= helpers.gl_badge_tag(_('LFS'), variant: :neutral)
.rd-diff-file-info
.rd-diff-file-stats
%span.rd-lines-added
%span>= "+"
%span{ "data-testid" => "js-file-addition-line" }= @diff_file.added_lines
%span.rd-lines-removed
%span>= "&#x2212;".html_safe
%span{ "data-testid" => "js-file-deletion-line" }= @diff_file.removed_lines
.rd-diff-file-stats{ aria: { label: stats_label } }
%span.rd-lines-added +#{@diff_file.added_lines}
%span.rd-lines-removed #{@diff_file.removed_lines}
.rd-diff-file-options-menu
%div{ data: { options_menu: true } }
-# <script> here is likely the most effective way to minimize bytes:

View File

@ -45,5 +45,14 @@ module RapidDiffs
new: @diff_file.new_path
)
end
def stats_label
added = @diff_file.added_lines
removed = @diff_file.removed_lines
counters = []
counters << (ns_('RapidDiffs|Added %d line.', 'RapidDiffs|Added %d lines.', added) % added) if added > 0
counters << (ns_('RapidDiffs|Removed %d line.', 'RapidDiffs|Removed %d lines.', removed) % removed) if removed > 0
counters.join(' ')
end
end
end

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
module Groups
class ObservabilityController < Groups::ApplicationController
before_action :authenticate_user!
before_action :authorize_read_observability!
feature_category :observability
urgency :low
VALID_PATHS = %w[
services
traces-explorer
logs/logs-explorer
metrics-explorer
infrastructure-monitoring
dashboard
messaging-queues
api-monitoring/explorer
alerts
exceptions
service-map
].freeze
def show
@o11y_url = ENV['O11Y_URL']
@path = permitted_params[:id]
return render_404 unless VALID_PATHS.include?(@path)
render
end
private
def permitted_params
params.permit(:id)
end
def authorize_read_observability!
return render_404 unless ::Feature.enabled?(:observability_sass_features, group)
render_404 unless current_user.can?(:maintainer_access, group)
end
end
end

View File

@ -117,7 +117,7 @@ module Projects
when :mysql
s_('CloudSeed|Create MySQL Instance')
else
s_('CloudSeed|Create MySQL Instance')
s_('CloudSeed|Create SQL Server Instance')
end
end

View File

@ -170,7 +170,7 @@ module IconsHelper
'volume-up'
when '.mp4', '.m4p', '.m4v',
'.mpg', '.mp2', '.mpeg', '.mpe', '.mpv',
'.mpg', '.mpeg', '.m2v', '.m2ts',
'.m2v', '.m2ts',
'.avi', '.mkv', '.flv', '.ogv', '.mov',
'.3gp', '.3g2'
'live-preview'

View File

@ -681,6 +681,14 @@ class Group < Namespace
return false unless user
all_owners = member_owners_excluding_project_bots
last_owner_in_list?(user, all_owners)
end
# This is used in BillableMember Entity to
# avoid multiple "member_owners_excluding_project_bots" calls
# for each billable members
def last_owner_in_list?(user, all_owners)
return false unless user
all_owners.size == 1 && all_owners.first.user_id == user.id
end

View File

@ -0,0 +1,8 @@
- page_title _("Observability")
- add_page_specific_style 'page_bundles/observability'
.gl-mt-3
.gl-display-flex.gl-align-items-center.gl-mb-5
%h1.gl-heading-1= _("Observability")
#js-observability{ data: { o11y_url: @o11y_url, path: @path } }

View File

@ -0,0 +1,8 @@
---
name: observability_sass_features
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/190904
rollout_issue_url:
milestone: '18.1'
type: experiment
group: group::platform insights
default_enabled: false

View File

@ -185,6 +185,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resource :import_history, only: [:show]
resources :observability, only: [:show]
post :preview_markdown
post '/restore' => '/groups#restore', as: :restore

View File

@ -128,7 +128,7 @@ This is a brief summary of how Geo works in your GitLab environment. For more de
Your Geo instance can be used for cloning and fetching projects, in addition to reading any data. This makes working with large repositories over large distances much faster.
![Geo overview](replication/img/geo_overview_v11_5.png)
![Geo overview](img/geo_overview_v11_5.png)
When Geo is enabled, the:
@ -149,7 +149,7 @@ Keep in mind that:
The following diagram illustrates the underlying architecture of Geo.
![Geo architecture](replication/img/geo_architecture_v13_8.png)
![Geo architecture](img/geo_architecture_v13_8.png)
In this diagram:

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -548,10 +548,10 @@ database to help you troubleshoot issues. Where you run this command depends on
Modify the command as needed:
- **Default path** - To run the command with the default folder path (`/var/opt/gitlab/gitlab-rails/tmp/sos.zip`), run `gitlab-rake gitlab:db:sos`.
- **Custom path** - To change the folder path, run `gitlab-rake gitlab:db:sos["custom/path/to/folder"]`.
- **Default path** - To run the command with the default file path (`/var/opt/gitlab/gitlab-rails/tmp/sos.zip`), run `gitlab-rake gitlab:db:sos`.
- **Custom path** - To change the file path, run `gitlab-rake gitlab:db:sos["custom/path/to/file.zip"]`.
- **Zsh users** - If you have not modified your Zsh configuration, you must add quotation marks
around the entire command, like this: `gitlab-rake "gitlab:db:sos[custom/path/to/folder]"`
around the entire command, like this: `gitlab-rake "gitlab:db:sos[custom/path/to/file.zip]"`
The Rake task runs for five minutes. It creates a compressed folder in the path you specify.
The compressed folder contains a large number of files.

View File

@ -15,7 +15,7 @@ sample projects, and administrator access with which you can test functionality.
It requires 30 GB of disk space.
![Home page of GitLab running in local development environment on port 3000](../img/gdk_home_v15_11.png)
![Home page of GitLab running in local development environment on port 3000](img/gdk_home_v15_11.png)
If you prefer to use GDK locally without a VM, use the steps in [Install the GDK development environment](configure-dev-env-gdk.md)

View File

@ -12,7 +12,7 @@ a local version of GitLab that's yours to play with.
The GDK is a local development environment that includes an installation of GitLab Self-Managed,
sample projects, and administrator access with which you can test functionality.
![Home page of GitLab running in local development environment on port 3000](../img/gdk_home_v15_11.png)
![Home page of GitLab running in local development environment on port 3000](img/gdk_home_v15_11.png)
If you prefer to use GDK in a local virtual machine, use the steps in [Configure GDK-in-a-box](configure-dev-env-gdk-in-a-box.md)
@ -31,7 +31,7 @@ also set aside some time for troubleshooting.
It might seem like a lot of work, but after you have the GDK running,
you'll be able to make any changes.
![Home page of GitLab running in local development environment on port 3000](../img/gdk_home_v15_11.png)
![Home page of GitLab running in local development environment on port 3000](img/gdk_home_v15_11.png)
To install the GDK:

View File

@ -11,6 +11,13 @@ In this example, I found some UI text I'd like to change.
In the upper-right corner in GitLab, I selected my avatar and then **Preferences**.
I want to change `Syntax highlighting theme` to `Code syntax highlighting theme`:
{{< alert type="warning" >}}
This tutorial is designed to be a general introduction to contributing to the GitLab project
and is not an example of a change that should be submitted for review.
{{< /alert >}}
Use your local IDE to make changes to the code in the GDK directory.
1. Create a new branch for your changes:

View File

@ -11,6 +11,13 @@ In this example, I found some UI text I'd like to change.
In the upper-right corner in GitLab, I selected my avatar and then **Preferences**.
I want to change `Syntax highlighting theme` to `Code syntax highlighting theme`:
{{< alert type="warning" >}}
This tutorial is designed to be a general introduction to contributing to the GitLab project
and is not an example of a change that should be submitted for review.
{{< /alert >}}
1. Create a new branch for your changes:
Select `master` in the status bar, then from the **Select a branch or tag to checkout** box,

View File

@ -7,6 +7,13 @@ title: Contribute code with the Web IDE
The [GitLab Web IDE](../../../user/project/web_ide/_index.md) is a built-in advanced editor with commit staging.
{{< alert type="warning" >}}
This tutorial is designed to be a general introduction to contributing to the GitLab project
and is not an example of a change that should be submitted for review.
{{< /alert >}}
The example in this section shows how to modify a line of code as part of a community contribution
to GitLab code using the Web IDE.

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -12,7 +12,7 @@ Now you're ready to push changes from the community fork to the main GitLab repo
1. Go to [the community fork on GitLab.com](https://gitlab.com/gitlab-community/gitlab).
You should see a message like this one:
![A banner prompting the user to create a merge request in response to recently pushed changes](../img/mr_button_v15_11.png)
![A banner prompting the user to create a merge request in response to recently pushed changes](img/mr_button_v15_11.png)
Select **Create merge request**.
If you don't see this message, on the left sidebar, select **Code > Merge requests > New merge request**.
@ -20,7 +20,7 @@ Now you're ready to push changes from the community fork to the main GitLab repo
1. Take a look at the branch names. You should be merging from your branch
in the community fork to the `master` branch in the GitLab repository.
![The user interface for creating a new merge request that highlights the source and destination branches](../img/new_merge_request_v15_11.png)
![The user interface for creating a new merge request that highlights the source and destination branches](img/new_merge_request_v15_11.png)
1. Fill out the information and then select **Save changes**.
Don't worry if your merge request is not complete.
@ -30,7 +30,7 @@ Now you're ready to push changes from the community fork to the main GitLab repo
1. Select the **Changes** tab. It should look something like this:
![A snapshot of the changes made in the merge request, with differences highlighted in red and green](../img/changes_tab_v15_11.png)
![A snapshot of the changes made in the merge request, with differences highlighted in red and green](img/changes_tab_v15_11.png)
The red text shows the code before you made changes. The green shows what
the code looks like now.
@ -38,7 +38,7 @@ Now you're ready to push changes from the community fork to the main GitLab repo
1. If you're happy with this merge request and want to start the review process, type
`@gitlab-bot ready` in a comment and then select **Comment**.
![A draft comment with the "GitLab bot ready" command to initiate the review process](../img/bot_ready_v16_6.png)
![A draft comment with the "GitLab bot ready" command to initiate the review process](img/bot_ready_v16_6.png)
Someone from GitLab will look at your request and let you know what the next steps are.

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -182,7 +182,7 @@ database query:
SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC LIMIT 1
```
![Reading the start ID value](../img/each_batch_users_table_iteration_1_v13_7.png)
![Reading the start ID value](img/each_batch_users_table_iteration_1_v13_7.png)
Notice that the query only reads data from the index (`INDEX ONLY SCAN`), the table is not
accessed. Database indexes are sorted so taking out the first item is a very cheap operation.
@ -195,7 +195,7 @@ to get a "shifted" `id` value.
SELECT "users"."id" FROM "users" WHERE "users"."id" >= 1 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5
```
![Reading the end ID value](../img/each_batch_users_table_iteration_2_v13_7.png)
![Reading the end ID value](img/each_batch_users_table_iteration_2_v13_7.png)
Again, the query only looks into the index. The `OFFSET 5` takes out the sixth `id` value: this
query reads a maximum of six items from the index regardless of the table size or the iteration
@ -208,7 +208,7 @@ for the `relation` block.
SELECT "users".* FROM "users" WHERE "users"."id" >= 1 AND "users"."id" < 302
```
![Reading the rows from the `users` table](../img/each_batch_users_table_iteration_3_v13_7.png)
![Reading the rows from the `users` table](img/each_batch_users_table_iteration_3_v13_7.png)
Notice the `<` sign. Previously six items were read from the index and in this query, the last
value is "excluded". The query looks at the index to get the location of the five `user`
@ -221,7 +221,7 @@ previous iteration to find out the next end `id` value.
SELECT "users"."id" FROM "users" WHERE "users"."id" >= 302 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5
```
![Reading the second end ID value](../img/each_batch_users_table_iteration_4_v13_7.png)
![Reading the second end ID value](img/each_batch_users_table_iteration_4_v13_7.png)
Now we can easily construct the `users` query for the second iteration.
@ -229,7 +229,7 @@ Now we can easily construct the `users` query for the second iteration.
SELECT "users".* FROM "users" WHERE "users"."id" >= 302 AND "users"."id" < 353
```
![Reading the rows for the second iteration from the users table](../img/each_batch_users_table_iteration_5_v13_7.png)
![Reading the rows for the second iteration from the users table](img/each_batch_users_table_iteration_5_v13_7.png)
### Example 2: Iteration with filters
@ -255,7 +255,7 @@ index on the `id` (primary key index) column however, we also have an extra cond
`sign_in_count` column. The column is not part of the index, so the database needs to look into
the actual table to find the first matching row.
![Reading the index with extra filter](../img/each_batch_users_table_filter_v13_7.png)
![Reading the index with extra filter](img/each_batch_users_table_filter_v13_7.png)
{{< alert type="note" >}}
@ -282,7 +282,7 @@ CREATE INDEX index_on_users_never_logged_in ON users (id) WHERE sign_in_count =
This is how our table and the newly created index looks like:
![Reading the specialized index](../img/each_batch_users_table_filtered_index_v13_7.png)
![Reading the specialized index](img/each_batch_users_table_filtered_index_v13_7.png)
This index definition covers the conditions on the `id` and `sign_in_count` columns thus makes the
`each_batch` queries very effective (similar to the simple iteration example).
@ -325,7 +325,7 @@ Executing the query above results in an `INDEX ONLY SCAN`. However, the query st
iterate over an unknown number of entries in the index, and then find the first item where the
`sign_in_count` is `0`.
![Reading an ineffective index](../img/each_batch_users_table_bad_index_v13_7.png)
![Reading an ineffective index](img/each_batch_users_table_bad_index_v13_7.png)
We can improve the query significantly by swapping the columns in the index definition (prefer).
@ -333,7 +333,7 @@ We can improve the query significantly by swapping the columns in the index defi
CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count, id)
```
![Reading a good index](../img/each_batch_users_table_good_index_v13_7.png)
![Reading a good index](img/each_batch_users_table_good_index_v13_7.png)
The following index definition does not work well with `each_batch` (avoid).

View File

@ -34,7 +34,7 @@ Rendering long lists can significantly affect both the frontend and backend perf
With pagination, the data is split into equal pieces (pages). On the first visit, the user receives only a limited number of items (page size). The user can see more items by paginating forward which results in a new HTTP request and a new database query.
![Project issues page with pagination](../img/project_issues_pagination_v13_11.jpg)
![Project issues page with pagination](img/project_issues_pagination_v13_11.jpg)
## General guidelines for paginating
@ -97,7 +97,7 @@ Notice that the query also orders the rows by the primary key (`id`). When pagin
Example pagination bar:
![Page selector rendered by Kaminari](../img/offset_pagination_ui_v13_11.jpg)
![Page selector rendered by Kaminari](img/offset_pagination_ui_v13_11.jpg)
The Kaminari gem renders a nice pagination bar on the UI with page numbers and optionally quick shortcuts the next, previous, first, and last page buttons. To render these buttons, Kaminari needs to know the number of rows, and for that, a count query is executed.

View File

@ -425,7 +425,7 @@ After turning on the feature flag, you need to [monitor the relevant graphs](htt
In this illustration, you can see that the Apdex score started to decline after the feature flag was enabled at `09:46`. The feature flag was then deactivated at `10:31`, and the service returned to the original value:
![A line graph showing the decline and recovery of the Apdex score](../img/feature-flag-metrics_v15_8.png)
![A line graph showing the decline and recovery of the Apdex score](img/feature-flag-metrics_v15_8.png)
Certain features necessitate extensive monitoring over multiple days, particularly those that are high-risk and critical to business operations. In contrast, other features may only require a 24-hour monitoring period before continuing with the rollout.

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -10,7 +10,7 @@ designated as a **primary** site and can be run with multiple
**secondary** sites. Geo orchestrates quite a few components that can be seen on
the diagram below and are described in more detail in this document.
![Geo Architecture Diagram](../administration/geo/replication/img/geo_architecture_v13_8.png)
![Geo Architecture Diagram](img/geo_architecture_v13_8.png)
## Replication layer

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -28,7 +28,7 @@ be ported to use it.
The merge widget is the component of the merge request where the `merge` button exists:
![merge widget](../img/merge_widget_v17_11.png)
![merge widget](img/merge_widget_v17_11.png)
This area of the merge request is where all of the options and commit messages are defined prior to merging. It also contains information about what is in the merge request, what issues are closed, and other information important to the merging process.
@ -38,7 +38,7 @@ Reports are widgets within the merge request that report information about chang
[Design Documentation](https://design.gitlab.com/patterns/merge-request-reports/)
![merge request reports](../img/merge_request_reports_v17_11.png)
![merge request reports](img/merge_request_reports_v17_11.png)
## Merge checks

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -45,7 +45,7 @@ In this tutorial we're writing a login end-to-end test, even though it has been
The GitLab QA end-to-end tests are organized by the different [stages in the DevOps lifecycle](https://gitlab.com/gitlab-org/gitlab-foss/tree/master/qa/qa/specs/features/browser_ui). Determine where the test should be placed by [stage](https://handbook.gitlab.com/handbook/product/categories/#devops-stages), determine which feature the test belongs to, and then place it in a subdirectory under the stage.
![DevOps lifecycle by stages](../img/gl-devops-lifecycle-by-stage_v12_10.png)
![DevOps lifecycle by stages](img/gl-devops-lifecycle-by-stage_v12_10.png)
If the test is Enterprise Edition only, the test is created in the `features/ee` directory, but follow the same DevOps lifecycle format.

View File

@ -317,7 +317,7 @@ First, write your comments you want to attach to specific lines or files:
[select multiple lines](../../user/project/merge_requests/reviews/suggestions.md#multi-line-suggestions),
or select an entire file to comment on:
![Code diff interface showing a speech bubble button next to a line number with a tooltip for adding a comment to one or multiple lines](../../user/project/merge_requests/reviews/img/comment_on_any_diff_line_v16_6.png)
![Code diff interface showing a speech bubble button next to a line number with a tooltip for adding a comment to one or multiple lines](img/comment_on_any_diff_line_v16_6.png)
1. In the text area, write your first comment. To keep your comments private until
the end of your review, select **Start a review** below your comment.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -762,6 +762,11 @@ msgid_plural "%{count} events have ocurred"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} file"
msgid_plural "%{count} files"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} files touched"
msgstr ""
@ -13504,6 +13509,9 @@ msgstr ""
msgid "CloudSeed|Create Postgres Instance"
msgstr ""
msgid "CloudSeed|Create SQL Server Instance"
msgstr ""
msgid "CloudSeed|Create cluster"
msgstr ""
@ -41772,6 +41780,9 @@ msgstr ""
msgid "Object does not exist on the server or you don't have permissions to access it"
msgstr ""
msgid "Observability"
msgstr ""
msgid "ObservabilityLogs|Attribute"
msgstr ""
@ -50020,6 +50031,11 @@ msgstr ""
msgid "Random"
msgstr ""
msgid "RapidDiffs|Added %d line."
msgid_plural "RapidDiffs|Added %d lines."
msgstr[0] ""
msgstr[1] ""
msgid "RapidDiffs|Added line %d"
msgstr ""
@ -50044,6 +50060,11 @@ msgstr ""
msgid "RapidDiffs|Original line number"
msgstr ""
msgid "RapidDiffs|Removed %d line."
msgid_plural "RapidDiffs|Removed %d lines."
msgstr[0] ""
msgstr[1] ""
msgid "RapidDiffs|Removed line %d"
msgstr ""

View File

@ -72,8 +72,8 @@ RSpec.describe RapidDiffs::DiffFileHeaderComponent, type: :component, feature_ca
it "renders line count" do
render_component
expect(page.find('[data-testid="js-file-addition-line"]')).to have_text(diff_file.added_lines)
expect(page.find('[data-testid="js-file-deletion-line"]')).to have_text(diff_file.removed_lines)
selector = "[aria-label=\"Added #{diff_file.added_lines} lines. Removed #{diff_file.removed_lines} lines.\"]"
expect(page.find(selector)).to have_text("+#{diff_file.added_lines} #{diff_file.removed_lines}")
end
describe 'menu items' do

View File

@ -1,4 +1,4 @@
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@ -21,6 +21,9 @@ describe('diff_stats', () => {
removedLines: TEST_REMOVED_LINES,
...props,
},
stubs: {
GlSprintf,
},
}),
);
};
@ -82,6 +85,16 @@ describe('diff_stats', () => {
it('shows the amount of lines removed', () => {
expect(findFileLine('js-file-deletion-line').text()).toBe(TEST_REMOVED_LINES.toString());
});
it('labels stats', () => {
expect(
wrapper
.find(
`[aria-label="Added ${TEST_ADDED_LINES} lines. Removed ${TEST_REMOVED_LINES} lines."]`,
)
.exists(),
).toBe(true);
});
});
describe('files changes', () => {

View File

@ -6,3 +6,10 @@
<a href="#login-pane">Standard</a>
</li>
</ul>
<div id="ldap">
<input autocomplete="username">
<input autocomplete="current-password">
</div>
<div id="login-pane">
<input type="password" autocomplete="current-password">
</div>

View File

@ -1,6 +1,5 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui';
import PagesDeployments from '~/gitlab_pages/components/deployments.vue';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@ -248,8 +247,8 @@ describe('PagesDeployments', () => {
createComponent();
});
it('displays the loading icon', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
it('displays the loader', () => {
expect(wrapper.findComponent(CrudComponent).props('isLoading')).toBe(true);
});
});
});

View File

@ -104,14 +104,14 @@ describe('TabView', () => {
describe.each`
tab | handler | expectedVariables | expectedProjects
${CONTRIBUTED_TAB} | ${[CONTRIBUTED_TAB.query, jest.fn().mockResolvedValue(contributedProjectsGraphQlResponse)]} | ${{ contributed: true, starred: false, sort: defaultPropsData.sort.toUpperCase() }} | ${contributedProjectsGraphQlResponse.data.currentUser.contributedProjects.nodes}
${PERSONAL_TAB} | ${[PERSONAL_TAB.query, jest.fn().mockResolvedValue(personalProjectsGraphQlResponse)]} | ${{ personal: true, membership: false, archived: 'EXCLUDE', sort: defaultPropsData.sort }} | ${personalProjectsGraphQlResponse.data.projects.nodes}
${MEMBER_TAB} | ${[MEMBER_TAB.query, jest.fn().mockResolvedValue(membershipProjectsGraphQlResponse)]} | ${{ personal: false, membership: true, archived: 'EXCLUDE', sort: defaultPropsData.sort }} | ${membershipProjectsGraphQlResponse.data.projects.nodes}
${STARRED_TAB} | ${[STARRED_TAB.query, jest.fn().mockResolvedValue(starredProjectsGraphQlResponse)]} | ${{ contributed: false, starred: true, sort: defaultPropsData.sort.toUpperCase() }} | ${starredProjectsGraphQlResponse.data.currentUser.starredProjects.nodes}
${INACTIVE_TAB} | ${[INACTIVE_TAB.query, jest.fn().mockResolvedValue(inactiveProjectsGraphQlResponse)]} | ${{ personal: false, membership: true, archived: 'ONLY', sort: defaultPropsData.sort }} | ${inactiveProjectsGraphQlResponse.data.projects.nodes}
${CONTRIBUTED_TAB} | ${[CONTRIBUTED_TAB.query, jest.fn().mockResolvedValue(contributedProjectsGraphQlResponse)]} | ${{ contributed: true, starred: false, sort: defaultPropsData.sort.toUpperCase() }} | ${contributedProjectsGraphQlResponse.data.currentUser.contributedProjects}
${PERSONAL_TAB} | ${[PERSONAL_TAB.query, jest.fn().mockResolvedValue(personalProjectsGraphQlResponse)]} | ${{ personal: true, membership: false, archived: 'EXCLUDE', sort: defaultPropsData.sort }} | ${personalProjectsGraphQlResponse.data.projects}
${MEMBER_TAB} | ${[MEMBER_TAB.query, jest.fn().mockResolvedValue(membershipProjectsGraphQlResponse)]} | ${{ personal: false, membership: true, archived: 'EXCLUDE', sort: defaultPropsData.sort }} | ${membershipProjectsGraphQlResponse.data.projects}
${STARRED_TAB} | ${[STARRED_TAB.query, jest.fn().mockResolvedValue(starredProjectsGraphQlResponse)]} | ${{ contributed: false, starred: true, sort: defaultPropsData.sort.toUpperCase() }} | ${starredProjectsGraphQlResponse.data.currentUser.starredProjects}
${INACTIVE_TAB} | ${[INACTIVE_TAB.query, jest.fn().mockResolvedValue(inactiveProjectsGraphQlResponse)]} | ${{ personal: false, membership: true, archived: 'ONLY', sort: defaultPropsData.sort }} | ${inactiveProjectsGraphQlResponse.data.projects}
`(
'onMount when route name is $tab.value',
({ tab, handler, expectedVariables, expectedProjects }) => {
({ tab, handler, expectedVariables, expectedProjects: { nodes, count } }) => {
describe('when GraphQL request is loading', () => {
beforeEach(() => {
createComponent({ handlers: [handler], propsData: { tab } });
@ -147,8 +147,12 @@ describe('TabView', () => {
expect(wrapper.emitted('query-complete')).toEqual([[]]);
});
it('emits update-count event', () => {
expect(wrapper.emitted('update-count')).toEqual([[tab, count]]);
});
it('passes items to `ProjectsList` component', () => {
expect(findProjectsList().props('items')).toEqual(formatProjects(expectedProjects));
expect(findProjectsList().props('items')).toEqual(formatProjects(nodes));
});
it('passes `timestampType` prop to `ProjectsList` component', () => {
@ -325,6 +329,7 @@ describe('TabView', () => {
projects: {
nodes: personalProjectsGraphQlResponse.data.projects.nodes,
pageInfo: pageInfoMultiplePages,
count: personalProjectsGraphQlResponse.data.projects.count,
},
},
}),

View File

@ -158,9 +158,13 @@ describe('TabsWithList', () => {
describe('template', () => {
describe('when tab counts are loading', () => {
it('does not show count badges', async () => {
it('shows badges with -', async () => {
await createComponent();
expect(findBadge().exists()).toBe(false);
expect(getTabCount('Contributed')).toBe('-');
expect(getTabCount('Starred')).toBe('-');
expect(getTabCount('Personal')).toBe('-');
expect(getTabCount('Member')).toBe('-');
expect(getTabCount('Inactive')).toBe('-');
});
});
@ -170,13 +174,23 @@ describe('TabsWithList', () => {
await waitForPromises();
});
it('shows count badges', () => {
expect(getTabCount('Contributed')).toBe('2');
it('skips active tab count but shows count for other tabs', () => {
expect(getTabCount('Contributed')).toBe('-');
expect(getTabCount('Starred')).toBe('0');
expect(getTabCount('Personal')).toBe('0');
expect(getTabCount('Member')).toBe('2');
expect(getTabCount('Inactive')).toBe('0');
});
describe('when TabView component emits update-count event', () => {
beforeEach(() => {
findTabView().vm.$emit('update-count', CONTRIBUTED_TAB, 5);
});
it('updates count of active tab', () => {
expect(getTabCount('Contributed')).toBe('5');
});
});
});
describe('when tab counts are not successfully retrieved', () => {
@ -214,13 +228,6 @@ describe('TabsWithList', () => {
});
});
});
it('does not show tab count badges', async () => {
await createComponent({ projectsCountHandler: jest.fn().mockRejectedValue(error) });
await waitForPromises();
expect(findBadge().exists()).toBe(false);
});
});
describe('when tabCountsQuery prop is not passed', () => {

View File

@ -59,6 +59,28 @@ describe('SigninTabsMemoizer', () => {
expect(tab.click).toHaveBeenCalled();
});
describe('when username or password field are available', () => {
it('focus on the username field', () => {
getCookie.mockReturnValue('#ldap');
createMemoizer();
expect(document.activeElement).toBe(
document.querySelector('#ldap input[autocomplete=username]'),
);
});
});
describe('when only password field is available', () => {
it('focus on the password field', () => {
getCookie.mockReturnValue('#login-pane');
createMemoizer();
expect(document.activeElement).toBe(
document.querySelector('#login-pane input[autocomplete=current-password]'),
);
});
});
it('saves last selected tab on click', () => {
createMemoizer();

View File

@ -79,7 +79,7 @@ export const mockGroups = [
defaultPermissions: false,
jobTokenPolicies: ['READ_JOBS', 'ADMIN_DEPLOYMENTS'],
autopopulated: true,
__typename: 'Group',
__typename: 'CiJobTokenAccessibleGroup',
},
{
id: 2,
@ -89,7 +89,7 @@ export const mockGroups = [
defaultPermissions: true,
jobTokenPolicies: [],
autopopulated: true,
__typename: 'Group',
__typename: 'CiJobTokenAccessibleGroup',
},
{
id: 3,
@ -99,7 +99,7 @@ export const mockGroups = [
defaultPermissions: false,
jobTokenPolicies: [],
autopopulated: false,
__typename: 'Group',
__typename: 'CiJobTokenAccessibleGroup',
},
];

View File

@ -48,6 +48,7 @@ describe('GridstackWrapper', () => {
});
};
const findGrid = () => wrapper.findByTestId('gridstack-grid');
const findGridStackPanels = () => wrapper.findAllByTestId('grid-stack-panel');
const findGridItemContentById = (panelId) =>
wrapper.find(`[gs-id="${panelId}"]`).find('.grid-stack-item-content');
@ -77,17 +78,20 @@ describe('GridstackWrapper', () => {
});
it('sets up GridStack', () => {
expect(GridStack.init).toHaveBeenCalledWith({
alwaysShowResizeHandle: true,
staticGrid: true,
animate: true,
margin: GRIDSTACK_MARGIN,
handle: GRIDSTACK_CSS_HANDLE,
cellHeight: GRIDSTACK_CELL_HEIGHT,
minRow: GRIDSTACK_MIN_ROW,
columnOpts: { breakpoints: [{ w: breakpoints.md, c: 1 }] },
float: true,
});
expect(GridStack.init).toHaveBeenCalledWith(
{
alwaysShowResizeHandle: true,
staticGrid: true,
animate: true,
margin: GRIDSTACK_MARGIN,
handle: GRIDSTACK_CSS_HANDLE,
cellHeight: GRIDSTACK_CELL_HEIGHT,
minRow: GRIDSTACK_MIN_ROW,
columnOpts: { breakpoints: [{ w: breakpoints.md, c: 1 }] },
float: true,
},
findGrid().element,
);
});
it('loads the parsed dashboard config', () => {
@ -176,6 +180,7 @@ describe('GridstackWrapper', () => {
expect.objectContaining({
staticGrid: false,
}),
findGrid().element,
);
});

View File

@ -1902,6 +1902,45 @@ RSpec.describe Group, feature_category: :groups_and_projects do
end
end
describe '#last_owner_in_list?' do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
context 'when user is nil' do
it 'returns false' do
expect(group.last_owner_in_list?(nil, [])).to be false
end
end
context 'with a single owner in the list' do
let(:owner_member) { build_stubbed(:group_member, user_id: user.id) }
it 'returns true when user matches the owner' do
expect(group.last_owner_in_list?(user, [owner_member])).to be true
end
it 'returns false when user does not match the owner' do
other_user = create(:user)
expect(group.last_owner_in_list?(other_user, [owner_member])).to be false
end
end
context 'with multiple owners in the list' do
let(:owner_member) { build_stubbed(:group_member, user_id: user.id) }
let(:another_owner) { build_stubbed(:group_member, user_id: create(:user).id) }
it 'returns false even if user is one of the owners' do
expect(group.last_owner_in_list?(user, [owner_member, another_owner])).to be false
end
end
context 'with an empty owners list' do
it 'returns false' do
expect(group.last_owner_in_list?(user, [])).to be false
end
end
end
context 'when analyzing blocked owners' do
let_it_be(:blocked_user) { create(:user, :blocked) }

View File

@ -0,0 +1,101 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::ObservabilityController, feature_category: :observability do
let(:group) { create(:group, :public) }
let(:user) { create(:user) }
before do
group.add_maintainer(user)
sign_in(user)
end
shared_examples 'redirects to 404' do
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe 'GET #show' do
subject(:observability_page) { get group_observability_path(group, '') }
context 'when feature flag is enabled' do
before do
stub_feature_flags(observability_sass_features: group)
end
context 'with incorrect permissons' do
let(:user) { create(:user) }
before do
group.add_developer(user)
sign_in(user)
end
subject { get group_observability_path(group, 'services') }
it_behaves_like 'redirects to 404'
end
context 'when the ENV var is not set' do
subject(:services_page) { get group_observability_path(group, 'services') }
before do
stub_env('O11Y_URL', 'http://localhost:3301/')
end
it 'sets the o11y url' do
services_page
expect(response).to render_template(:show)
expect(assigns(:path)).to eq('services')
expect(assigns(:o11y_url)).to eq('http://localhost:3301/')
end
end
context 'with a valid path parameter' do
Groups::ObservabilityController::VALID_PATHS.each do |path|
context "with path=#{path}" do
subject(:observability_page) { get group_observability_path(group, path) }
it 'renders the observability page with the specified path' do
observability_page
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:path)).to eq(path)
end
end
end
end
context 'with an invalid path parameter' do
subject { get group_observability_path(group, 'invalid-path') }
it_behaves_like 'redirects to 404'
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(observability_sass_features: false)
end
it_behaves_like 'redirects to 404'
end
context 'when user is not authenticated' do
before do
stub_feature_flags(observability_sass_features: group)
sign_out(user)
end
it 'redirects to sign in page' do
observability_page
expect(response).to redirect_to(new_user_session_path)
end
end
end
end