Add latest changes from gitlab-org/gitlab@master
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
---
|
||||
Lint/DuplicateCaseCondition:
|
||||
Exclude:
|
||||
- 'app/helpers/icons_helper.rb'
|
||||
|
|
@ -1 +1 @@
|
|||
3030e219d83d55b287a9b3934141a8d5d8d67d22
|
||||
b446ea3d1838519a0d8f09885d8e907addd7d780
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,5 +33,6 @@ query getYourWorkProjects(
|
|||
pageInfo {
|
||||
...PageInfo
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ query getYourWorkUserProjects(
|
|||
pageInfo {
|
||||
...PageInfo
|
||||
}
|
||||
count
|
||||
}
|
||||
starredProjects(
|
||||
first: $first
|
||||
|
|
@ -48,6 +49,7 @@ query getYourWorkUserProjects(
|
|||
pageInfo {
|
||||
...PageInfo
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import GridstackWrapper from './gridstack_wrapper.vue';
|
||||
import dashboardConfigValidator from './utils';
|
||||
import { dashboardConfigValidator } from './utils';
|
||||
|
||||
export default {
|
||||
name: 'DashboardLayout',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>= "−".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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
When Geo is enabled, the:
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ Keep in mind that:
|
|||
|
||||
The following diagram illustrates the underlying architecture of Geo.
|
||||
|
||||

|
||||

|
||||
|
||||
In this diagram:
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ sample projects, and administrator access with which you can test functionality.
|
|||
|
||||
It requires 30 GB of disk space.
|
||||
|
||||

|
||||

|
||||
|
||||
If you prefer to use GDK locally without a VM, use the steps in [Install the GDK development environment](configure-dev-env-gdk.md)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
To install the GDK:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
|
@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||

|
||||
|
||||
Someone from GitLab will look at your request and let you know what the next steps are.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
|
@ -182,7 +182,7 @@ database query:
|
|||
SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC LIMIT 1
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
{{< 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:
|
||||
|
||||

|
||||

|
||||
|
||||
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`.
|
||||
|
||||

|
||||

|
||||
|
||||
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)
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
The following index definition does not work well with `each_batch` (avoid).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Replication layer
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 48 KiB |
|
|
@ -28,7 +28,7 @@ be ported to use it.
|
|||
|
||||
The merge widget is the component of the merge request where the `merge` button exists:
|
||||
|
||||

|
||||

|
||||
|
||||
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 checks
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
If the test is Enterprise Edition only, the test is created in the `features/ee` directory, but follow the same DevOps lifecycle format.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
|
@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||