Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-09-12 09:13:29 +00:00
parent 24cae4b6d5
commit fab43fda65
44 changed files with 766 additions and 240 deletions

View File

@ -1 +1 @@
2da899b99c7bc3536b1658f54ed1e8fdb6e02f23
5460b4217be7746352cb9e8350fc7e12db27d4ba

View File

@ -1,5 +1,6 @@
<script>
import {
GlAlert,
GlButton,
GlDrawer,
GlFormCheckbox,
@ -16,11 +17,14 @@ import { __, s__ } from '~/locale';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import Tracking from '~/tracking';
import {
allEnvironments,
defaultVariableState,
DRAWER_EVENT_LABEL,
EDIT_VARIABLE_ACTION,
ENVIRONMENT_SCOPE_LINK_TITLE,
EVENT_ACTION,
EXPANDED_VARIABLES_NOTE,
FLAG_LINK_TITLE,
VARIABLE_ACTIONS,
@ -29,6 +33,8 @@ import {
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import { awsTokenList } from './ci_variable_autocomplete_tokens';
const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL });
export const i18n = {
addVariable: s__('CiVariables|Add Variable'),
cancel: __('Cancel'),
@ -49,14 +55,27 @@ export const i18n = {
protectedDescription: s__(
'CiVariables|Export variable to pipelines running on protected branches and tags only.',
),
valueFeedback: {
rawHelpText: s__('CiVariables|Variable value will be evaluated as raw string.'),
maskedReqsNotMet: s__(
'CiVariables|This variable value does not meet the masking requirements.',
),
},
variableReferenceTitle: s__('CiVariables|Value might contain a variable reference'),
variableReferenceDescription: s__(
'CiVariables|Unselect "Expand variable reference" if you want to use the variable value as a raw string.',
),
type: __('Type'),
value: __('Value'),
};
const VARIABLE_REFERENCE_REGEX = /\$/;
export default {
DRAWER_Z_INDEX,
components: {
CiEnvironmentsDropdown,
GlAlert,
GlButton,
GlDrawer,
GlFormCheckbox,
@ -69,7 +88,8 @@ export default {
GlLink,
GlSprintf,
},
inject: ['environmentScopeLink', 'isProtectedByDefault'],
mixins: [trackingMixin],
inject: ['environmentScopeLink', 'isProtectedByDefault', 'maskableRawRegex', 'maskableRegex'],
props: {
areEnvironmentsLoading: {
type: Boolean,
@ -106,22 +126,59 @@ export default {
data() {
return {
variable: { ...defaultVariableState, ...this.selectedVariable },
trackedValidationErrorProperty: undefined,
};
},
computed: {
isValueMaskable() {
return this.variable.masked && !this.isValueMasked;
},
isValueMasked() {
const regex = RegExp(this.maskedRegexToUse);
return regex.test(this.variable.value);
},
canSubmit() {
return this.variable.key.length > 0 && this.isValueValid;
},
getDrawerHeaderHeight() {
return getContentWrapperHeight();
},
hasVariableReference() {
return this.isExpanded && VARIABLE_REFERENCE_REGEX.test(this.variable.value);
},
isExpanded() {
return !this.variable.raw;
},
isMaskedReqsMet() {
return !this.variable.masked || this.isValueMasked;
},
isValueEmpty() {
return this.variable.value === '';
},
isValueValid() {
return this.isValueEmpty || this.isMaskedReqsMet;
},
isEditing() {
return this.mode === EDIT_VARIABLE_ACTION;
},
maskedRegexToUse() {
return this.variable.raw ? this.maskableRawRegex : this.maskableRegex;
},
maskedReqsNotMetText() {
return !this.isMaskedReqsMet ? this.$options.i18n.valueFeedback.maskedReqsNotMet : '';
},
modalActionText() {
return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable;
},
},
watch: {
variable: {
handler() {
this.trackVariableValidationErrors();
},
deep: true,
},
},
mounted() {
if (this.isProtectedByDefault && !this.isEditing) {
this.variable = { ...this.variable, protected: true };
@ -131,6 +188,22 @@ export default {
close() {
this.$emit('close-form');
},
getTrackingErrorProperty() {
if (this.isValueEmpty) {
return null;
}
let property;
if (this.isValueMaskable) {
const supportedChars = this.maskedRegexToUse.replace('^', '').replace(/{(\d,)}\$/, '');
const regex = new RegExp(supportedChars, 'g');
property = this.variable.value.replace(regex, '');
} else if (this.hasVariableReference) {
property = '$';
}
return property;
},
setRaw(expanded) {
this.variable = { ...this.variable, raw: !expanded };
},
@ -138,6 +211,13 @@ export default {
this.$emit(this.isEditing ? 'update-variable' : 'add-variable', this.variable);
this.close();
},
trackVariableValidationErrors() {
const property = this.getTrackingErrorProperty();
if (property && !this.trackedValidationErrorProperty) {
this.track(EVENT_ACTION, { property });
this.trackedValidationErrorProperty = property;
}
},
},
awsTokenList,
flagLink: helpPagePath('ci/variables/index', {
@ -256,24 +336,41 @@ export default {
:token-list="$options.awsTokenList"
:label-text="$options.i18n.key"
class="gl-border-none gl-pb-0! gl-mb-n5"
data-testid="pipeline-form-ci-variable-key"
data-testid="ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
<gl-form-group
:label="$options.i18n.value"
label-for="ci-variable-value"
class="gl-border-none gl-mb-n2"
data-testid="ci-variable-value-label"
:invalid-feedback="maskedReqsNotMetText"
:state="isValueValid"
>
<gl-form-textarea
id="ci-variable-value"
v-model="variable.value"
class="gl-border-none gl-font-monospace!"
rows="3"
max-rows="10"
data-testid="pipeline-form-ci-variable-value"
data-testid="ci-variable-value"
data-qa-selector="ci_variable_value_field"
spellcheck="false"
/>
<p v-if="variable.raw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip">
{{ $options.i18n.valueFeedback.rawHelpText }}
</p>
</gl-form-group>
<gl-alert
v-if="hasVariableReference"
:title="$options.i18n.variableReferenceTitle"
:dismissible="false"
variant="warning"
class="gl-mx-4 gl-pl-9! gl-border-bottom-0"
data-testid="has-variable-reference-alert"
>
{{ $options.i18n.variableReferenceDescription }}
</gl-alert>
<div class="gl-display-flex gl-justify-content-end">
<gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close"
>{{ $options.i18n.cancel }}
@ -281,6 +378,7 @@ export default {
<gl-button
category="primary"
variant="confirm"
:disabled="!canSubmit"
data-testid="ci-variable-confirm-btn"
@click="submit"
>{{ modalActionText }}

View File

@ -46,6 +46,7 @@ export const AWS_TIP_MESSAGE = s__(
);
export const EVENT_LABEL = 'ci_variable_modal';
export const DRAWER_EVENT_LABEL = 'ci_variable_drawer';
export const EVENT_ACTION = 'validation_error';
// AWS TOKEN CONSTANTS

View File

@ -243,10 +243,6 @@ export default {
eventHub.$on('openModal', (options) => {
this.openModal(options);
});
if (this.tasksToBeDoneEnabled) {
this.openModal({ source: 'in_product_marketing_email' });
}
},
methods: {
showInvalidFeedbackMessage(response) {
@ -317,7 +313,7 @@ export default {
const { error, message } = responseFromSuccess(response);
if (error) {
this.showMemberErrors(message);
this.showErrors(message);
} else {
this.onInviteSuccess();
}
@ -327,9 +323,13 @@ export default {
this.isLoading = false;
}
},
showMemberErrors(message) {
this.invalidMembers = message;
this.$refs.alerts.focus();
showErrors(message) {
if (isString(message)) {
this.invalidFeedbackMessage = message;
} else {
this.invalidMembers = message;
this.$refs.alerts.focus();
}
},
tokenName(username) {
// initial token creation hits this and nothing is found... so safe navigation

View File

@ -1,5 +1,3 @@
import { getParameterValues } from '~/lib/utils/url_utility';
export function memberName(member) {
// user defined tokens(invites by email) will have email in `name` and will not contain `username`
return member.username || member.name;
@ -10,5 +8,5 @@ export function triggerExternalAlert() {
}
export function qualifiesForTasksToBeDone() {
return getParameterValues('open_modal')[0] === 'invite_members_for_task';
return false;
}

View File

@ -2,6 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { ORGANIZATION_ROOT_ROUTE_NAME } from '../constants';
import resolvers from '../shared/graphql/resolvers';
import App from './components/app.vue';
@ -23,6 +24,16 @@ export const initOrganizationsGroupsAndProjects = () => {
if (!el) return false;
const {
dataset: { appData },
} = el;
const {
projectsEmptyStateSvgPath,
groupsEmptyStateSvgPath,
newGroupPath,
newProjectPath,
} = convertObjectPropsToCamelCase(JSON.parse(appData));
Vue.use(VueRouter);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers),
@ -34,6 +45,12 @@ export const initOrganizationsGroupsAndProjects = () => {
name: 'OrganizationsGroupsAndProjects',
apolloProvider,
router,
provide: {
projectsEmptyStateSvgPath,
groupsEmptyStateSvgPath,
newGroupPath,
newProjectPath,
},
render(createElement) {
return createElement(App);
},

View File

@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue';
import groupsQuery from '../graphql/queries/groups.query.graphql';
import { formatGroups } from '../utils';
@ -11,8 +11,28 @@ export default {
errorMessage: s__(
'Organization|An error occurred loading the groups. Please refresh the page to try again.',
),
emptyState: {
title: s__("Organization|You don't have any groups yet."),
description: s__(
'Organization|A group is a collection of several projects. If you organize your projects under a group, it works like a folder.',
),
primaryButtonText: __('New group'),
},
},
components: { GlLoadingIcon, GlEmptyState, GroupsList },
inject: {
groupsEmptyStateSvgPath: {},
newGroupPath: {
default: null,
},
},
props: {
shouldShowEmptyStateButtons: {
type: Boolean,
required: false,
default: false,
},
},
components: { GlLoadingIcon, GroupsList },
data() {
return {
groups: [],
@ -33,11 +53,30 @@ export default {
isLoading() {
return this.$apollo.queries.groups.loading;
},
emptyStateProps() {
const baseProps = {
svgHeight: 144,
svgPath: this.groupsEmptyStateSvgPath,
title: this.$options.i18n.emptyState.title,
description: this.$options.i18n.emptyState.description,
};
if (this.shouldShowEmptyStateButtons && this.newGroupPath) {
return {
...baseProps,
primaryButtonLink: this.newGroupPath,
primaryButtonText: this.$options.i18n.emptyState.primaryButtonText,
};
}
return baseProps;
},
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
<groups-list v-else :groups="groups" show-group-icon />
<groups-list v-else-if="groups.length" :groups="groups" show-group-icon />
<gl-empty-state v-else v-bind="emptyStateProps" />
</template>

View File

@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
import { createAlert } from '~/alert';
import projectsQuery from '../graphql/queries/projects.query.graphql';
@ -11,10 +11,31 @@ export default {
errorMessage: s__(
'Organization|An error occurred loading the projects. Please refresh the page to try again.',
),
emptyState: {
title: s__("Organization|You don't have any projects yet."),
description: s__(
'GroupsEmptyState|Projects are where you can store your code, access issues, wiki, and other features of Gitlab.',
),
primaryButtonText: __('New project'),
},
},
components: {
ProjectsList,
GlLoadingIcon,
GlEmptyState,
},
inject: {
projectsEmptyStateSvgPath: {},
newProjectPath: {
default: null,
},
},
props: {
shouldShowEmptyStateButtons: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -36,11 +57,30 @@ export default {
isLoading() {
return this.$apollo.queries.projects.loading;
},
emptyStateProps() {
const baseProps = {
svgHeight: 144,
svgPath: this.projectsEmptyStateSvgPath,
title: this.$options.i18n.emptyState.title,
description: this.$options.i18n.emptyState.description,
};
if (this.shouldShowEmptyStateButtons && this.newProjectPath) {
return {
...baseProps,
primaryButtonLink: this.newProjectPath,
primaryButtonText: this.$options.i18n.emptyState.primaryButtonText,
};
}
return baseProps;
},
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
<projects-list v-else :projects="projects" show-project-icon />
<projects-list v-else-if="projects.length" :projects="projects" show-project-icon />
<gl-empty-state v-else v-bind="emptyStateProps" />
</template>

View File

@ -105,6 +105,6 @@ export default {
$options.i18n.viewAll
}}</gl-link>
</div>
<component :is="routerView" class="gl-mt-5" />
<component :is="routerView" should-show-empty-state-buttons class="gl-mt-5" />
</div>
</template>

View File

@ -27,9 +27,14 @@ export const initOrganizationsShow = () => {
const {
dataset: { appData },
} = el;
const { organization, groupsAndProjectsOrganizationPath } = convertObjectPropsToCamelCase(
JSON.parse(appData),
);
const {
organization,
groupsAndProjectsOrganizationPath,
projectsEmptyStateSvgPath,
groupsEmptyStateSvgPath,
newGroupPath,
newProjectPath,
} = convertObjectPropsToCamelCase(JSON.parse(appData));
Vue.use(VueRouter);
const router = createRouter();
@ -42,6 +47,12 @@ export const initOrganizationsShow = () => {
name: 'OrganizationShowRoot',
apolloProvider,
router,
provide: {
projectsEmptyStateSvgPath,
groupsEmptyStateSvgPath,
newGroupPath,
newProjectPath,
},
render(createElement) {
return createElement(App, {
props: { organization, groupsAndProjectsOrganizationPath },

View File

@ -102,8 +102,14 @@ export default {
</script>
<template>
<div class="user-bar">
<div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2">
<div
class="user-bar gl-display-flex gl-p-3 gl-gap-1"
:class="{ 'gl-flex-direction-column gl-gap-3': sidebarData.is_logged_in }"
>
<div
v-if="hasCollapseButton || sidebarData.is_logged_in"
class="gl-display-flex gl-align-items-center gl-gap-1"
>
<template v-if="sidebarData.is_logged_in">
<brand-logo :logo-url="sidebarData.logo_url" />
<gl-badge
@ -111,7 +117,6 @@ export default {
variant="success"
:href="sidebarData.canary_toggle_com_url"
size="sm"
class="gl-ml-2"
>
{{ $options.NEXT_LABEL }}
</gl-badge>
@ -146,7 +151,7 @@ export default {
</div>
<div
v-if="sidebarData.is_logged_in"
class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-pt-2 gl-pb-3 gl-gap-2"
class="gl-display-flex gl-justify-content-space-between gl-gap-2"
>
<counter
v-gl-tooltip:super-sidebar.hover.noninteractive.ds500.bottom="$options.i18n.issues"
@ -193,18 +198,16 @@ export default {
data-track-property="nav_core_menu"
/>
</div>
<div class="gl-px-3 gl-pb-3">
<button
id="super-sidebar-search"
v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip"
v-gl-modal="$options.SEARCH_MODAL_ID"
class="counter gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-focus--focus gl-w-full"
data-testid="super-sidebar-search-button"
>
<gl-icon name="search" />
{{ $options.i18n.searchBtnText }}
</button>
<search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" />
</div>
<button
id="super-sidebar-search"
v-gl-tooltip.bottom.hover.noninteractive.ds500.html="searchTooltip"
v-gl-modal="$options.SEARCH_MODAL_ID"
class="counter gl-display-block gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-focus--focus gl-w-full"
data-testid="super-sidebar-search-button"
>
<gl-icon name="search" />
{{ $options.i18n.searchBtnText }}
</button>
<search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" />
</div>
</template>

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
module Clusters
module Agents
class DashboardController < ApplicationController
include KasCookie
before_action :check_feature_flag!
before_action :find_agent
before_action :authorize_read_cluster_agent!
before_action :set_kas_cookie, only: [:show], if: -> { current_user }
feature_category :deployment_management
def show
head :ok
end
private
def find_agent
@agent = ::Clusters::Agent.find(params[:agent_id])
end
def check_feature_flag!
not_found unless ::Feature.enabled?(:k8s_dashboard, current_user)
end
def authorize_read_cluster_agent!
not_found unless can?(current_user, :read_cluster_agent, @agent)
end
end
end
end

View File

@ -6,7 +6,22 @@ module Organizations
{
organization: organization.slice(:id, :name),
groups_and_projects_organization_path: groups_and_projects_organization_path(organization)
}.to_json
}.merge(shared_groups_and_projects_app_data).to_json
end
def organization_groups_and_projects_app_data
shared_groups_and_projects_app_data.to_json
end
private
def shared_groups_and_projects_app_data
{
projects_empty_state_svg_path: image_path('illustrations/empty-state/empty-projects-md.svg'),
groups_empty_state_svg_path: image_path('illustrations/empty-state/empty-groups-md.svg'),
new_group_path: new_group_path,
new_project_path: new_project_path
}
end
end
end

View File

@ -1,3 +1,3 @@
- page_title _('Groups and projects')
#js-organizations-groups-and-projects
#js-organizations-groups-and-projects{ data: { app_data: organization_groups_and_projects_app_data } }

View File

@ -0,0 +1,8 @@
---
name: k8s_dashboard
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/130953"
rollout_issue_url: "https://gitlab.com/gitlab-org/gitlab/-/issues/424237"
milestone: '16.4'
type: development
group: group::environments
default_enabled: false

View File

@ -18,7 +18,6 @@ options:
- g_edit_by_web_ide
- g_edit_by_sfe
- g_edit_by_snippet_ide
- g_edit_by_live_preview
distribution:
- ce
- ee

View File

@ -18,7 +18,6 @@ options:
- g_edit_by_web_ide
- g_edit_by_sfe
- g_edit_by_snippet_ide
- g_edit_by_live_preview
distribution:
- ce
- ee

View File

@ -6,7 +6,7 @@ product_section: dev
product_stage: create
product_group: ide
value_type: number
status: active
status: removed
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85420
time_frame: 28d
data_source: redis_hll

View File

@ -18,7 +18,6 @@ options:
- g_edit_by_web_ide
- g_edit_by_sfe
- g_edit_by_snippet_ide
- g_edit_by_live_preview
distribution:
- ce
- ee

View File

@ -117,6 +117,12 @@ InitializerConnections.raise_if_new_database_connection do
get 'offline' => "pwa#offline"
get 'manifest' => "pwa#manifest", constraints: lambda { |req| req.format == :json }
scope module: 'clusters' do
scope module: 'agents' do
get '/kubernetes/:agent_id', to: 'dashboard#show', as: 'kubernetes_dashboard'
end
end
# '/-/health' implemented by BasicHealthCheck middleware
get 'liveness' => 'health#liveness'
get 'readiness' => 'health#readiness'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@ -22,7 +22,7 @@ Load the `.drawio.png` or `.drawio.svg` file directly into **draw.io**, which yo
To create a diagram from a file:
1. Copy existing file and rename it. Ensure that the extension is `.drawio.png` or `.drawio.svg`.
1. Edit the diagram.
1. Edit the diagram.
1. Save the file.
To create a diagram from scratch using [draw.io desktop](https://github.com/jgraph/drawio-desktop/releases):

View File

@ -51,4 +51,4 @@ Today we can have multiple catalogs based on the number of namespaces, making it
### 4.2. Cons
- A separate catalog that surfaces components across Organizations would need to be implemented to serve the wider community (community catalog). This catalog will be required for GitLab.com only, later on we can evaluate if a similar catalog is needed for self-managed customers.
- A separate catalog that surfaces components across Organizations would need to be implemented to serve the wider community (community catalog). This catalog will be required for GitLab.com only, later on we can evaluate if a similar catalog is needed for self-managed customers.

View File

@ -26,7 +26,7 @@ This is especially the case with the merge request, as it is one of the most com
Today personal Namespaces serve two purposes that are mostly non-overlapping:
1. They provide a place for users to create personal Projects
1. They provide a place for users to create personal Projects
that aren't expected to receive contributions from other people. This use case saves them from having to create a Group just for themselves.
1. They provide a default place for a user to put any forks they
create when contributing to Projects where they don't have permission to push a branch. This again saves them from needing to create a Group just to store these forks. But the primary user need here is because they can't push branches to the upstream Project so they create a fork and contribute merge requests from the fork.

View File

@ -74,7 +74,7 @@ quick reference for what features we have now, are planning, their statuses, and
an excutive summary of the overall state of the migration experience.
This could be advertised to self-managed users via a simple chart, allowing them
to tell at a glance the status of this project and determine if it is feature-
complete enough for their needs and level of risk tolerance.
complete enough for their needs and level of risk tolerance.
This should be documented in the container registry administration documentation,
rather than in this blueprint. Providing this information there will place it in
@ -86,7 +86,7 @@ For example:
The metadata database is in early beta for self-managed users. The core migration
process for existing registries has been implemented, and online garbage collection
is fully implemented. Certain database enabled features are only enabled for GitLab.com
is fully implemented. Certain database enabled features are only enabled for GitLab.com
and automatic database provisioning for the registry database is not available.
Please see the table below for the status of features related to the container
registry database.

View File

@ -256,7 +256,7 @@ In our component, we then listen on the `aiCompletionResponse` using the `userId
```graphql
subscription aiCompletionResponse($userId: UserID, $resourceId: AiModelID, $clientSubscriptionId: String) {
aiCompletionResponse(userId: $userId, resourceId: $resourceId, clientSubscriptionId: $clientSubscriptionId) {
responseBody
content
errors
}
}

View File

@ -8,7 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
## Code Suggestions development setup
The recommended setup for locally developing and debugging Code Suggestions is to have all 3 different components running:
The recommended setup for locally developing and debugging Code Suggestions is to have all 3 different components running:
- IDE Extension (e.g. VSCode Extension)
- Main application configured correctly

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

View File

@ -13,7 +13,7 @@ You can update your preferences to change the look and feel of GitLab.
You can change the color theme of the GitLab UI. These colors are displayed on the left sidebar.
Using individual color themes might help you differentiate between your different
GitLab instances.
GitLab instances.
To change the color theme:
@ -25,7 +25,7 @@ To change the color theme:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28252) in GitLab 13.1 as an [Experiment](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28252).
Dark mode makes elements on the GitLab UI stand out on a dark background.
Dark mode makes elements on the GitLab UI stand out on a dark background.
- To turn on Dark mode, Select **Preferences > Color theme > Dark Mode**.
@ -44,11 +44,11 @@ To change the syntax highlighting theme:
1. In the **Syntax highlighting theme** section, select a theme.
1. Select **Save changes**.
To view the updated syntax highlighting theme, refresh your project's page.
To view the updated syntax highlighting theme, refresh your project's page.
To customize the syntax highlighting theme, you can also [use the Application settings API](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls). Use `default_syntax_highlighting_theme` to change the syntax highlighting colors on a more granular level.
If these steps do not work, your programming language might not be supported by the syntax highlighters.
If these steps do not work, your programming language might not be supported by the syntax highlighters.
For more information, view [Rouge Ruby Library](https://github.com/rouge-ruby/rouge) for guidance on code files and Snippets. View [Moncaco Editor](https://microsoft.github.io/monaco-editor/) and [Monarch](https://microsoft.github.io/monaco-editor/monarch.html) for guidance on the Web IDE.
## Change the diff colors
@ -59,7 +59,7 @@ To change the diff colors:
1. On the left sidebar, select your avatar.
1. Select **Preferences**.
1. Go to the **Diff colors** section.
1. Go to the **Diff colors** section.
1. Select a color or enter a color code.
1. Select **Save changes**.
@ -142,7 +142,7 @@ To render whitespace in the Web IDE:
1. Select the **Render whitespace characters in the Web IDE** checkbox.
1. Select **Save changes**.
You can view changes to whitespace in diffs.
You can view changes to whitespace in diffs.
To view diffs on the Web IDE, follow these steps:
@ -315,7 +315,7 @@ To integrate with Gitpod:
### Integrate your GitLab instance with Sourcegraph
GitLab supports Sourcegraph integration for all public projects on GitLab.
GitLab supports Sourcegraph integration for all public projects on GitLab.
To integrate with Sourcegraph:

View File

@ -58,7 +58,6 @@ You can upload files of the following types as designs:
- JPEG
- JPG
- PNG
- SVG
- TIFF
- WEBP

View File

@ -3028,7 +3028,7 @@ msgstr ""
msgid "AddMember|Invite email is invalid"
msgstr ""
msgid "AddMember|Invite limit of %{daily_invites} per day exceeded"
msgid "AddMember|Invite limit of %{daily_invites} per day exceeded."
msgstr ""
msgid "AddMember|Invites cannot be blank"
@ -10275,12 +10275,24 @@ msgstr ""
msgid "CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables."
msgstr ""
msgid "CiVariables|This variable value does not meet the masking requirements."
msgstr ""
msgid "CiVariables|Type"
msgstr ""
msgid "CiVariables|Unselect \"Expand variable reference\" if you want to use the variable value as a raw string."
msgstr ""
msgid "CiVariables|Value"
msgstr ""
msgid "CiVariables|Value might contain a variable reference"
msgstr ""
msgid "CiVariables|Variable value will be evaluated as raw string."
msgstr ""
msgid "CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements."
msgstr ""
@ -32821,6 +32833,9 @@ msgstr ""
msgid "Organizations"
msgstr ""
msgid "Organization|A group is a collection of several projects. If you organize your projects under a group, it works like a folder."
msgstr ""
msgid "Organization|An error occurred loading the groups. Please refresh the page to try again."
msgstr ""
@ -32860,6 +32875,12 @@ msgstr ""
msgid "Organization|View all"
msgstr ""
msgid "Organization|You don't have any groups yet."
msgstr ""
msgid "Organization|You don't have any projects yet."
msgstr ""
msgid "Orphaned member"
msgstr ""

View File

@ -416,7 +416,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
before do
detached_merge_request_pipeline.reload.succeed!
refresh
wait_for_requests
end
it 'merges the merge request' do

View File

@ -1,24 +1,36 @@
import { GlDrawer, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
import CiVariableDrawer, { i18n } from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
import { awsTokenList } from '~/ci/ci_variable_list/components/ci_variable_autocomplete_tokens';
import {
ADD_VARIABLE_ACTION,
DRAWER_EVENT_LABEL,
EDIT_VARIABLE_ACTION,
EVENT_ACTION,
variableOptions,
projectString,
variableTypes,
} from '~/ci/ci_variable_list/constants';
import { mockTracking } from 'helpers/tracking_helper';
import { mockVariablesWithScopes } from '../mocks';
describe('CI Variable Drawer', () => {
let wrapper;
let trackingSpy;
const mockProjectVariable = mockVariablesWithScopes(projectString)[0];
const mockProjectVariableFileType = mockVariablesWithScopes(projectString)[1];
const mockEnvScope = 'staging';
const mockEnvironments = ['*', 'dev', 'staging', 'production'];
// matches strings that contain at least 8 consecutive characters consisting of only
// letters (both uppercase and lowercase), digits, or the specified special characters
const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$';
// matches strings that consist of at least 8 or more non-whitespace characters
const maskableRawRegex = '^\\S{8,}$';
const defaultProps = {
areEnvironmentsLoading: false,
areScopedVariablesAvailable: true,
@ -31,6 +43,8 @@ describe('CI Variable Drawer', () => {
const defaultProvide = {
isProtectedByDefault: true,
environmentScopeLink: '/help/environments',
maskableRawRegex,
maskableRegex,
};
const createComponent = ({
@ -57,8 +71,11 @@ describe('CI Variable Drawer', () => {
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown);
const findExpandedCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox');
const findKeyField = () => wrapper.findComponent(GlFormCombobox);
const findMaskedCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox');
const findProtectedCheckbox = () => wrapper.findByTestId('ci-variable-protected-checkbox');
const findValueField = () => wrapper.findByTestId('ci-variable-value');
const findValueLabel = () => wrapper.findByTestId('ci-variable-value-label');
const findTitle = () => findDrawer().find('h2');
const findTypeDropdown = () => wrapper.findComponent(GlFormSelect);
@ -201,12 +218,131 @@ describe('CI Variable Drawer', () => {
});
it("sets the variable's raw value", async () => {
await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
await findExpandedCheckbox().vm.$emit('change');
await findConfirmBtn().vm.$emit('click');
const sentRawValue = wrapper.emitted('add-variable')[0][0].raw;
expect(sentRawValue).toBe(!defaultProps.raw);
});
it('shows help text when variable is not expanded (will be evaluated as raw)', async () => {
expect(findExpandedCheckbox().attributes('checked')).toBeDefined();
expect(findDrawer().text()).not.toContain(
'Variable value will be evaluated as raw string.',
);
await findExpandedCheckbox().vm.$emit('change');
expect(findExpandedCheckbox().attributes('checked')).toBeUndefined();
expect(findDrawer().text()).toContain('Variable value will be evaluated as raw string.');
});
it('shows help text when variable is expanded and contains the $ character', async () => {
expect(findDrawer().text()).not.toContain(
'Unselect "Expand variable reference" if you want to use the variable value as a raw string.',
);
await findValueField().vm.$emit('input', '$NEW_VALUE');
expect(findDrawer().text()).toContain(
'Unselect "Expand variable reference" if you want to use the variable value as a raw string.',
);
});
});
describe('key', () => {
beforeEach(() => {
createComponent();
});
it('prompts AWS tokens as options', () => {
expect(findKeyField().props('tokenList')).toBe(awsTokenList);
});
it('cannot submit with empty key', async () => {
expect(findConfirmBtn().attributes('disabled')).toBeDefined();
await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
});
});
describe('value', () => {
beforeEach(() => {
createComponent();
});
it('can submit empty value', async () => {
await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
// value is empty by default
expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
});
describe.each`
value | canSubmit | trackingErrorProperty
${'secretValue'} | ${true} | ${null}
${'~v@lid:symbols.'} | ${true} | ${null}
${'short'} | ${false} | ${null}
${'multiline\nvalue'} | ${false} | ${'\n'}
${'dollar$ign'} | ${false} | ${'$'}
${'unsupported|char'} | ${false} | ${'|'}
`('masking requirements', ({ value, canSubmit, trackingErrorProperty }) => {
beforeEach(async () => {
createComponent();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
await findValueField().vm.$emit('input', value);
await findMaskedCheckbox().vm.$emit('input', true);
});
it(`${
canSubmit ? 'can submit' : 'shows validation errors and disables submit button'
} when value is '${value}'`, () => {
if (canSubmit) {
expect(findValueLabel().attributes('invalid-feedback')).toBe('');
expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
} else {
expect(findValueLabel().attributes('invalid-feedback')).toBe(
'This variable value does not meet the masking requirements.',
);
expect(findConfirmBtn().attributes('disabled')).toBeDefined();
}
});
it(`${
trackingErrorProperty ? 'sends the correct' : 'does not send the'
} variable validation tracking event when value is '${value}'`, () => {
const trackingEventSent = trackingErrorProperty ? 1 : 0;
expect(trackingSpy).toHaveBeenCalledTimes(trackingEventSent);
if (trackingErrorProperty) {
expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, {
label: DRAWER_EVENT_LABEL,
property: trackingErrorProperty,
});
}
});
});
it('only sends the tracking event once', async () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
await findMaskedCheckbox().vm.$emit('input', true);
expect(trackingSpy).toHaveBeenCalledTimes(0);
await findValueField().vm.$emit('input', 'unsupported|char');
expect(trackingSpy).toHaveBeenCalledTimes(1);
await findValueField().vm.$emit('input', 'dollar$ign');
expect(trackingSpy).toHaveBeenCalledTimes(1);
});
});
});
@ -227,8 +363,8 @@ describe('CI Variable Drawer', () => {
});
it('title and confirm button renders the correct text', () => {
expect(findTitle().text()).toBe(i18n.addVariable);
expect(findConfirmBtn().text()).toBe(i18n.addVariable);
expect(findTitle().text()).toBe('Add Variable');
expect(findConfirmBtn().text()).toBe('Add Variable');
});
});
@ -241,8 +377,8 @@ describe('CI Variable Drawer', () => {
});
it('title and confirm button renders the correct text', () => {
expect(findTitle().text()).toBe(i18n.editVariable);
expect(findConfirmBtn().text()).toBe(i18n.editVariable);
expect(findTitle().text()).toBe('Edit Variable');
expect(findConfirmBtn().text()).toBe('Edit Variable');
});
});
});

View File

@ -1,4 +1,4 @@
import { GlLink, GlModal, GlSprintf, GlFormGroup, GlCollapse, GlIcon } from '@gitlab/ui';
import { GlModal, GlSprintf, GlFormGroup, GlCollapse, GlIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
@ -12,7 +12,6 @@ import ModalConfetti from '~/invite_members/components/confetti.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue';
import {
INVITE_MEMBERS_FOR_TASK,
MEMBERS_MODAL_CELEBRATE_INTRO,
MEMBERS_MODAL_CELEBRATE_TITLE,
MEMBERS_PLACEHOLDER,
@ -31,7 +30,6 @@ import {
HTTP_STATUS_CREATED,
HTTP_STATUS_INTERNAL_SERVER_ERROR,
} from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
@ -54,10 +52,6 @@ import {
jest.mock('~/invite_members/utils/trigger_successful_invite_alert');
jest.mock('~/experimentation/experiment_tracking');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
getParameterValues: jest.fn(() => []),
}));
describe('InviteMembersModal', () => {
let wrapper;
@ -129,7 +123,6 @@ describe('InviteMembersModal', () => {
});
const findModal = () => wrapper.findComponent(GlModal);
const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findEmptyInvitesAlert = () => wrapper.findByTestId('empty-invites-alert');
const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error');
@ -155,10 +148,6 @@ describe('InviteMembersModal', () => {
findMembersFormGroup().attributes('invalid-feedback');
const membersFormGroupDescription = () => findMembersFormGroup().attributes('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select');
const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert');
const findCelebrationEmoji = () => wrapper.findComponent(GlEmoji);
const triggerOpenModal = async ({ mode = 'default', source } = {}) => {
eventHub.$emit('openModal', { mode, source });
@ -168,131 +157,11 @@ describe('InviteMembersModal', () => {
findMembersSelect().vm.$emit('input', val);
await nextTick();
};
const triggerTasks = async (val) => {
findTasks().vm.$emit('input', val);
await nextTick();
};
const triggerAccessLevel = async (val) => {
findBase().vm.$emit('access-level', val);
await nextTick();
};
const removeMembersToken = async (val) => {
findMembersSelect().vm.$emit('token-remove', val);
await nextTick();
};
describe('rendering the tasks to be done', () => {
const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => {
getParameterValues.mockImplementation(() => urlParameter);
createComponent(props);
await triggerAccessLevel(30);
};
const setupComponentWithTasks = async (...args) => {
await setupComponent(...args);
await triggerTasks(['ci', 'code']);
};
afterAll(() => {
getParameterValues.mockImplementation(() => []);
});
it('renders the tasks to be done', async () => {
await setupComponent();
expect(findTasksToBeDone().exists()).toBe(true);
});
describe('when the selected access level is lower than 30', () => {
it('does not render the tasks to be done', async () => {
await setupComponent();
await triggerAccessLevel(20);
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => {
it('does not render the tasks to be done', async () => {
await setupComponent({}, []);
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('rendering the tasks', () => {
it('renders the tasks', async () => {
await setupComponent();
expect(findTasks().exists()).toBe(true);
});
it('does not render an alert', async () => {
await setupComponent();
expect(findNoProjectsAlert().exists()).toBe(false);
});
describe('when there are no projects passed in the data', () => {
it('does not render the tasks', async () => {
await setupComponent({ projects: [] });
expect(findTasks().exists()).toBe(false);
});
it('renders an alert with a link to the new projects path', async () => {
await setupComponent({ projects: [] });
expect(findNoProjectsAlert().exists()).toBe(true);
expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe(
newProjectPath,
);
});
});
});
describe('rendering the project dropdown', () => {
it('renders the project select', async () => {
await setupComponentWithTasks();
expect(findProjectSelect().exists()).toBe(true);
});
describe('when the modal is shown for a project', () => {
it('does not render the project select', async () => {
await setupComponentWithTasks({ isProject: true });
expect(findProjectSelect().exists()).toBe(false);
});
});
describe('when no tasks are selected', () => {
it('does not render the project select', async () => {
await setupComponent();
expect(findProjectSelect().exists()).toBe(false);
});
});
});
describe('tracking events', () => {
it('tracks the submit for invite_members_for_task', async () => {
await setupComponentWithTasks();
await triggerMembersTokenSelect([user1]);
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
clickInviteButton();
expectTracking(INVITE_MEMBERS_FOR_TASK.submit, 'selected_tasks_to_be_done', 'ci,code');
unmockTracking();
});
});
});
describe('rendering with tracking considerations', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
@ -624,6 +493,18 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('displays invite limit error message', async () => {
mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.INVITE_LIMIT);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(
invitationsApiResponse.INVITE_LIMIT.message,
);
});
});
});

View File

@ -47,6 +47,11 @@ const EMAIL_TAKEN = {
status: 'error',
};
const INVITE_LIMIT = {
message: 'Invite limit of 5 per day exceeded.',
status: 'error',
};
export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations';
export const invitationsApiResponse = {
@ -56,6 +61,7 @@ export const invitationsApiResponse = {
MULTIPLE_RESTRICTED,
EMAIL_TAKEN,
EXPANDED_RESTRICTED,
INVITE_LIMIT,
};
export const IMPORT_PROJECT_MEMBERS_PATH = '/api/v4/projects/1/import_project_members/2';

View File

@ -3,8 +3,6 @@ import {
triggerExternalAlert,
qualifiesForTasksToBeDone,
} from '~/invite_members/utils/member_utils';
import setWindowLocation from 'helpers/set_window_location_helper';
import { getParameterValues } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
@ -26,15 +24,7 @@ describe('Trigger External Alert', () => {
});
describe('Qualifies For Tasks To Be Done', () => {
it.each([
['invite_members_for_task', true],
['blah', false],
])(`returns name from supplied member token: %j`, (value, result) => {
setWindowLocation(`blah/blah?open_modal=${value}`);
getParameterValues.mockImplementation(() => {
return [value];
});
expect(qualifiesForTasksToBeDone()).toBe(result);
it('returns false', () => {
expect(qualifiesForTasksToBeDone()).toBe(false);
});
});

View File

@ -1,6 +1,6 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import GroupsView from '~/organizations/shared/components/groups_view.vue';
import { formatGroups } from '~/organizations/shared/utils';
import resolvers from '~/organizations/shared/graphql/resolvers';
@ -20,10 +20,19 @@ describe('GroupsView', () => {
let wrapper;
let mockApollo;
const createComponent = ({ mockResolvers = resolvers } = {}) => {
const defaultProvide = {
groupsEmptyStateSvgPath: 'illustrations/empty-state/empty-groups-md.svg',
newGroupPath: '/groups/new',
};
const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers);
wrapper = shallowMountExtended(GroupsView, { apolloProvider: mockApollo });
wrapper = shallowMountExtended(GroupsView, {
apolloProvider: mockApollo,
provide: defaultProvide,
propsData,
});
};
afterEach(() => {
@ -47,17 +56,66 @@ describe('GroupsView', () => {
});
describe('when API call is successful', () => {
beforeEach(() => {
createComponent();
describe('when there are no groups', () => {
it('renders empty state without buttons by default', async () => {
const mockResolvers = {
Query: {
organization: jest.fn().mockResolvedValueOnce({
groups: { nodes: [] },
}),
},
};
createComponent({ mockResolvers });
jest.runAllTimers();
await waitForPromises();
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
title: "You don't have any groups yet.",
description:
'A group is a collection of several projects. If you organize your projects under a group, it works like a folder.',
svgHeight: 144,
svgPath: defaultProvide.groupsEmptyStateSvgPath,
primaryButtonLink: null,
primaryButtonText: null,
});
});
describe('when `shouldShowEmptyStateButtons` is `true` and `groupsEmptyStateSvgPath` is set', () => {
it('renders empty state with buttons', async () => {
const mockResolvers = {
Query: {
organization: jest.fn().mockResolvedValueOnce({
groups: { nodes: [] },
}),
},
};
createComponent({ mockResolvers, propsData: { shouldShowEmptyStateButtons: true } });
jest.runAllTimers();
await waitForPromises();
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
primaryButtonLink: defaultProvide.newGroupPath,
primaryButtonText: 'New group',
});
});
});
});
it('renders `GroupsList` component and passes correct props', async () => {
jest.runAllTimers();
await waitForPromises();
describe('when there are groups', () => {
beforeEach(() => {
createComponent();
});
expect(wrapper.findComponent(GroupsList).props()).toEqual({
groups: formatGroups(organizationGroups.nodes),
showGroupIcon: true,
it('renders `GroupsList` component and passes correct props', async () => {
jest.runAllTimers();
await waitForPromises();
expect(wrapper.findComponent(GroupsList).props()).toEqual({
groups: formatGroups(organizationGroups.nodes),
showGroupIcon: true,
});
});
});
});

View File

@ -1,6 +1,6 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import ProjectsView from '~/organizations/shared/components/projects_view.vue';
import { formatProjects } from '~/organizations/shared/utils';
import resolvers from '~/organizations/shared/graphql/resolvers';
@ -20,10 +20,19 @@ describe('ProjectsView', () => {
let wrapper;
let mockApollo;
const createComponent = ({ mockResolvers = resolvers } = {}) => {
const defaultProvide = {
projectsEmptyStateSvgPath: 'illustrations/empty-state/empty-projects-md.svg',
newProjectPath: '/projects/new',
};
const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers);
wrapper = shallowMountExtended(ProjectsView, { apolloProvider: mockApollo });
wrapper = shallowMountExtended(ProjectsView, {
apolloProvider: mockApollo,
provide: defaultProvide,
propsData,
});
};
afterEach(() => {
@ -47,17 +56,66 @@ describe('ProjectsView', () => {
});
describe('when API call is successful', () => {
beforeEach(() => {
createComponent();
describe('when there are no projects', () => {
it('renders empty state without buttons by default', async () => {
const mockResolvers = {
Query: {
organization: jest.fn().mockResolvedValueOnce({
projects: { nodes: [] },
}),
},
};
createComponent({ mockResolvers });
jest.runAllTimers();
await waitForPromises();
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
title: "You don't have any projects yet.",
description:
'Projects are where you can store your code, access issues, wiki, and other features of Gitlab.',
svgHeight: 144,
svgPath: defaultProvide.projectsEmptyStateSvgPath,
primaryButtonLink: null,
primaryButtonText: null,
});
});
describe('when `shouldShowEmptyStateButtons` is `true` and `projectsEmptyStateSvgPath` is set', () => {
it('renders empty state with buttons', async () => {
const mockResolvers = {
Query: {
organization: jest.fn().mockResolvedValueOnce({
projects: { nodes: [] },
}),
},
};
createComponent({ mockResolvers, propsData: { shouldShowEmptyStateButtons: true } });
jest.runAllTimers();
await waitForPromises();
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
primaryButtonLink: defaultProvide.newProjectPath,
primaryButtonText: 'New project',
});
});
});
});
it('renders `ProjectsList` component and passes correct props', async () => {
jest.runAllTimers();
await waitForPromises();
describe('when there are projects', () => {
beforeEach(() => {
createComponent();
});
expect(wrapper.findComponent(ProjectsList).props()).toEqual({
projects: formatProjects(organizationProjects.nodes),
showProjectIcon: true,
it('renders `ProjectsList` component and passes correct props', async () => {
jest.runAllTimers();
await waitForPromises();
expect(wrapper.findComponent(ProjectsList).props()).toEqual({
projects: formatProjects(organizationProjects.nodes),
showProjectIcon: true,
});
});
});
});

View File

@ -72,7 +72,9 @@ describe('OrganizationShowGroupsAndProjects', () => {
});
it('renders expected view', () => {
expect(wrapper.findComponent(expectedViewComponent).exists()).toBe(true);
expect(
wrapper.findComponent(expectedViewComponent).props('shouldShowEmptyStateButtons'),
).toBe(true);
});
},
);

View File

@ -4,6 +4,17 @@ require 'spec_helper'
RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do
let_it_be(:organization) { build_stubbed(:organization) }
let_it_be(:new_group_path) { '/groups/new' }
let_it_be(:new_project_path) { '/projects/new' }
let_it_be(:groups_empty_state_svg_path) { 'illustrations/empty-state/empty-groups-md.svg' }
let_it_be(:projects_empty_state_svg_path) { 'illustrations/empty-state/empty-projects-md.svg' }
before do
allow(helper).to receive(:new_group_path).and_return(new_group_path)
allow(helper).to receive(:new_project_path).and_return(new_project_path)
allow(helper).to receive(:image_path).with(groups_empty_state_svg_path).and_return(groups_empty_state_svg_path)
allow(helper).to receive(:image_path).with(projects_empty_state_svg_path).and_return(projects_empty_state_svg_path)
end
describe '#organization_show_app_data' do
before do
@ -20,7 +31,28 @@ RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do
).to eq(
{
'organization' => { 'id' => organization.id, 'name' => organization.name },
'groups_and_projects_organization_path' => '/-/organizations/default/groups_and_projects'
'groups_and_projects_organization_path' => '/-/organizations/default/groups_and_projects',
'new_group_path' => new_group_path,
'new_project_path' => new_project_path,
'groups_empty_state_svg_path' => groups_empty_state_svg_path,
'projects_empty_state_svg_path' => projects_empty_state_svg_path
}
)
end
end
describe '#organization_groups_and_projects_app_data' do
it 'returns expected json' do
expect(
Gitlab::Json.parse(
helper.organization_groups_and_projects_app_data
)
).to eq(
{
'new_group_path' => new_group_path,
'new_project_path' => new_project_path,
'groups_empty_state_svg_path' => groups_empty_state_svg_path,
'projects_empty_state_svg_path' => projects_empty_state_svg_path
}
)
end

View File

@ -0,0 +1,76 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::Agents::DashboardController, feature_category: :deployment_management do
describe 'GET show' do
let_it_be(:organization) { create(:group) }
let_it_be(:agent_management_project) { create(:project, group: organization) }
let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) }
let_it_be(:deployment_project) { create(:project, group: organization) }
let(:user) { create(:user) }
let(:stub_ff) { true }
before do
allow(::Gitlab::Kas).to receive(:enabled?).and_return(true)
end
context 'with authorized user' do
let!(:authorization) do
create(
:agent_user_access_project_authorization,
agent: agent,
project: deployment_project
)
end
before do
stub_feature_flags(k8s_dashboard: stub_ff)
deployment_project.add_member(user, :developer)
sign_in(user)
get kubernetes_dashboard_path(agent.id)
end
it 'sets the kas cookie' do
expect(
request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
).to be_present
end
it 'returns not found' do
expect(response).to have_gitlab_http_status(:ok)
end
context 'with k8s_dashboard feature flag disabled' do
let(:stub_ff) { false }
it 'does not set the kas cookie' do
expect(
request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
).not_to be_present
end
it 'returns not found' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'with unauthorized user' do
before do
sign_in(user)
get kubernetes_dashboard_path(agent.id)
end
it 'does not set the kas cookie' do
expect(
request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
).not_to be_present
end
it 'returns not found' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end