Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-08-10 09:09:29 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-08-10 09:09:29 +0300
commit76c4dd062c4eeb853866ef8b6451c59f9e24221c (patch)
treefaf481c7b2f6da10c13234ad4e4a6ca1cb5a1030
parentc2858333644a2bca10fd556a5a298b4a1aaedca2 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js13
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js34
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue66
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js5
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js8
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue66
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue2
-rw-r--r--app/assets/javascripts/lib/print_markdown_dom.js50
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue40
-rw-r--r--app/assets/javascripts/pages/shared/wikis/show.js21
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue36
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue291
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js43
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js3
-rw-r--r--app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue58
-rw-r--r--app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue75
-rw-r--r--app/assets/javascripts/service_desk/components/service_desk_list_app.vue24
-rw-r--r--app/assets/javascripts/service_desk/constants.js10
-rw-r--r--app/assets/javascripts/service_desk/index.js2
-rw-r--r--app/assets/stylesheets/print.scss36
-rw-r--r--app/events/repositories/default_branch_changed_event.rb16
-rw-r--r--app/graphql/mutations/ci/pipeline_trigger/create.rb38
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/models/concerns/has_repository.rb3
-rw-r--r--app/views/ci/variables/_index.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/projects/triggers/_form.html.haml4
-rw-r--r--app/views/projects/triggers/_index.html.haml43
-rw-r--r--app/views/shared/wikis/show.html.haml2
-rw-r--r--config/feature_flags/development/print_wiki.yml8
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--doc/administration/geo/setup/index.md19
-rw-r--r--doc/administration/monitoring/performance/grafana_configuration.md116
-rw-r--r--doc/administration/package_information/postgresql_versions.md1
-rw-r--r--doc/administration/sidekiq/sidekiq_troubleshooting.md2
-rw-r--r--doc/api/graphql/reference/index.md24
-rw-r--r--doc/ci/triggers/index.md5
-rw-r--r--doc/ci/variables/predefined_variables.md4
-rw-r--r--doc/development/api_graphql_styleguide.md9
-rw-r--r--doc/development/graphql_guide/monitoring.md17
-rw-r--r--doc/development/testing_guide/testing_levels.md18
-rw-r--r--doc/update/versions/gitlab_16_changes.md25
-rw-r--r--doc/user/application_security/dependency_list/index.md3
-rw-r--r--doc/user/project/integrations/webhooks.md1
-rw-r--r--doc/user/search/advanced_search.md4
-rw-r--r--locale/gitlab.pot107
-rw-r--r--package.json1
-rw-r--r--spec/features/issues/service_desk_spec.rb38
-rw-r--r--spec/features/triggers_spec.rb7
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js19
-rw-r--r--spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js24
-rw-r--r--spec/frontend/lib/print_markdown_dom_spec.js102
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_export_spec.js48
-rw-r--r--spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js199
-rw-r--r--spec/frontend/projects/settings_service_desk/components/custom_email_spec.js52
-rw-r--r--spec/frontend/projects/settings_service_desk/components/mock_data.js17
-rw-r--r--spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js74
-rw-r--r--spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js89
-rw-r--r--spec/frontend/service_desk/components/service_desk_list_app_spec.js68
-rw-r--r--spec/requests/api/graphql/mutations/ci/pipeline_trigger/create_spec.rb75
-rw-r--r--spec/services/issues/update_service_spec.rb90
-rw-r--r--spec/services/merge_requests/update_service_spec.rb4
-rw-r--r--spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb70
-rw-r--r--yarn.lock5
65 files changed, 1963 insertions, 396 deletions
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index a6faa04b440..689f2f0898e 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -399,6 +399,12 @@ export const ISSUABLE_CHANGE_LABEL = {
defaultKeys: ['l'],
};
+export const ISSUABLE_COPY_REF = {
+ id: 'issuables.copyIssuableRef',
+ description: __('Copy reference'),
+ defaultKeys: ['c r'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
export const ISSUE_MR_CHANGE_ASSIGNEE = {
id: 'issuesMRs.changeAssignee',
description: __('Change assignee'),
@@ -606,7 +612,12 @@ const PROJECT_FILES_SHORTCUTS_GROUP = {
const ISSUABLE_SHORTCUTS_GROUP = {
id: 'issuables',
name: __('Epics, issues, and merge requests'),
- keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL],
+ keybindings: [
+ ISSUABLE_COMMENT_OR_REPLY,
+ ISSUABLE_EDIT_DESCRIPTION,
+ ISSUABLE_CHANGE_LABEL,
+ ISSUABLE_COPY_REF,
+ ],
};
const ISSUE_MR_SHORTCUTS_GROUP = {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 0c882ff9ea2..b0e515ac19d 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -14,6 +14,7 @@ import {
ISSUABLE_COMMENT_OR_REPLY,
ISSUABLE_EDIT_DESCRIPTION,
MR_COPY_SOURCE_BRANCH_NAME,
+ ISSUABLE_COPY_REF,
} from './keybindings';
import Shortcuts from './shortcuts';
@@ -21,15 +22,24 @@ export default class ShortcutsIssuable extends Shortcuts {
constructor() {
super();
- this.inMemoryButton = document.createElement('button');
- this.clipboardInstance = new ClipboardJS(this.inMemoryButton);
- this.clipboardInstance.on('success', () => {
+ this.branchInMemoryButton = document.createElement('button');
+ this.branchClipboardInstance = new ClipboardJS(this.branchInMemoryButton);
+ this.branchClipboardInstance.on('success', () => {
toast(s__('GlobalShortcuts|Copied source branch name to clipboard.'));
});
- this.clipboardInstance.on('error', () => {
+ this.branchClipboardInstance.on('error', () => {
toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.'));
});
+ this.refInMemoryButton = document.createElement('button');
+ this.refClipboardInstance = new ClipboardJS(this.refInMemoryButton);
+ this.refClipboardInstance.on('success', () => {
+ toast(s__('GlobalShortcuts|Copied reference to clipboard.'));
+ });
+ this.refClipboardInstance.on('error', () => {
+ toast(s__('GlobalShortcuts|Unable to copy the reference at this time.'));
+ });
+
this.bindCommands([
[ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')],
[ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')],
@@ -37,6 +47,7 @@ export default class ShortcutsIssuable extends Shortcuts {
[ISSUABLE_COMMENT_OR_REPLY, ShortcutsIssuable.replyWithSelectedText],
[ISSUABLE_EDIT_DESCRIPTION, ShortcutsIssuable.editIssue],
[MR_COPY_SOURCE_BRANCH_NAME, () => this.copyBranchName()],
+ [ISSUABLE_COPY_REF, () => this.copyIssuableRef()],
]);
/**
@@ -163,9 +174,20 @@ export default class ShortcutsIssuable extends Shortcuts {
const branchName = button?.dataset.clipboardText;
if (branchName) {
- this.inMemoryButton.dataset.clipboardText = branchName;
+ this.branchInMemoryButton.dataset.clipboardText = branchName;
+
+ this.branchInMemoryButton.dispatchEvent(new CustomEvent('click'));
+ }
+ }
+
+ async copyIssuableRef() {
+ const refButton = document.querySelector('.js-copy-reference');
+ const copiedRef = refButton?.dataset.clipboardText;
+
+ if (copiedRef) {
+ this.refInMemoryButton.dataset.clipboardText = copiedRef;
- this.inMemoryButton.dispatchEvent(new CustomEvent('click'));
+ this.refInMemoryButton.dispatchEvent(new CustomEvent('click'));
}
}
}
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 5468e42b6b3..86c0f34215e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -25,6 +25,7 @@ import {
AWS_TOKEN_CONSTANTS,
ADD_CI_VARIABLE_MODAL_ID,
AWS_TIP_DISMISSED_COOKIE_NAME,
+ AWS_TIP_TITLE,
AWS_TIP_MESSAGE,
CONTAINS_VARIABLE_REFERENCE_MESSAGE,
defaultVariableState,
@@ -62,10 +63,6 @@ export default {
},
mixins: [glFeatureFlagsMixin(), trackingMixin],
inject: [
- 'awsLogoSvgPath',
- 'awsTipCommandsLink',
- 'awsTipDeployLink',
- 'awsTipLearnLink',
'containsVariableReferenceLink',
'environmentScopeLink',
'isProtectedByDefault',
@@ -295,6 +292,7 @@ export default {
},
},
i18n: {
+ awsTipTitle: AWS_TIP_TITLE,
awsTipMessage: AWS_TIP_MESSAGE,
containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
defaultScope: allEnvironments.text,
@@ -305,6 +303,9 @@ export default {
flagLink: helpPagePath('ci/variables/index', {
anchor: 'define-a-cicd-variable-in-the-ui',
}),
+ oidcLink: helpPagePath('ci/cloud_services/index', {
+ anchor: 'oidc-authorization-with-your-cloud-provider',
+ }),
modalId: ADD_CI_VARIABLE_MODAL_ID,
tokens: awsTokens,
tokenList: awsTokenList,
@@ -322,6 +323,23 @@ export default {
@hidden="resetModalHandler"
@shown="onShow"
>
+ <gl-collapse :visible="isTipVisible">
+ <gl-alert
+ :title="$options.i18n.awsTipTitle"
+ variant="warning"
+ class="gl-mb-5"
+ data-testid="aws-guidance-tip"
+ @dismiss="dismissTip"
+ >
+ <gl-sprintf :message="$options.i18n.awsTipMessage">
+ <template #link="{ content }">
+ <gl-link :href="$options.oidcLink">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </gl-collapse>
<form>
<gl-form-combobox
v-model="variable.key"
@@ -468,45 +486,7 @@ export default {
</gl-form-checkbox>
</gl-form-group>
</form>
- <gl-collapse :visible="isTipVisible">
- <gl-alert
- :title="__('Deploying to AWS is easy with GitLab')"
- variant="tip"
- data-testid="aws-guidance-tip"
- @dismiss="dismissTip"
- >
- <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3">
- <div>
- <p>
- <gl-sprintf :message="$options.i18n.awsTipMessage">
- <template #deployLink="{ content }">
- <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link>
- </template>
- <template #commandsLink="{ content }">
- <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- <p>
- <gl-button
- :href="awsTipLearnLink"
- target="_blank"
- category="secondary"
- variant="confirm"
- class="gl-overflow-wrap-break"
- >{{ __('Learn more about deploying to AWS') }}</gl-button
- >
- </p>
- </div>
- <img
- class="gl-mt-3"
- :alt="__('Amazon Web Services Logo')"
- :src="awsLogoSvgPath"
- height="32"
- />
- </div>
- </gl-alert>
- </gl-collapse>
+
<gl-alert
v-if="containsVariableReference"
:title="__('Value might contain a variable reference')"
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index cebc0c0b388..825b39e0cf9 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -40,8 +40,9 @@ export const instanceString = 'Instance';
export const projectString = 'Project';
export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed';
-export const AWS_TIP_MESSAGE = __(
- '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.',
+export const AWS_TIP_TITLE = s__('CiVariable|Use OIDC to securely connect to cloud services');
+export const AWS_TIP_MESSAGE = s__(
+ 'CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}',
);
export const EVENT_LABEL = 'ci_variable_modal';
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index e47b41ceae5..9342f57f2d8 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -10,10 +10,6 @@ import { generateCacheConfig, resolvers } from './graphql/settings';
const mountCiVariableListApp = (containerEl) => {
const {
- awsLogoSvgPath,
- awsTipCommandsLink,
- awsTipDeployLink,
- awsTipLearnLink,
containsVariableReferenceLink,
endpoint,
environmentScopeLink,
@@ -57,10 +53,6 @@ const mountCiVariableListApp = (containerEl) => {
el: containerEl,
apolloProvider,
provide: {
- awsLogoSvgPath,
- awsTipCommandsLink,
- awsTipDeployLink,
- awsTipLearnLink,
containsVariableReferenceLink,
endpoint,
environmentScopeLink,
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index a1b264cfe54..0871d543d46 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -1,13 +1,5 @@
<script>
-import {
- GlAlert,
- GlAvatar,
- GlAvatarLink,
- GlBadge,
- GlButton,
- GlTable,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlAvatar, GlAvatarLink, GlBadge, GlButton, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { thWidthPercent } from '~/lib/utils/table_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -25,7 +17,6 @@ export default {
},
components: {
ClipboardButton,
- GlAlert,
GlAvatar,
GlAvatarLink,
GlBadge,
@@ -53,28 +44,32 @@ export default {
{
key: 'token',
label: s__('Pipelines|Token'),
- thClass: thWidthPercent(70),
+ thClass: thWidthPercent(60),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'description',
label: s__('Pipelines|Description'),
- thClass: thWidthPercent(15),
+ thClass: thWidthPercent(20),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'owner',
label: s__('Pipelines|Owner'),
thClass: thWidthPercent(5),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'lastUsed',
label: s__('Pipelines|Last Used'),
- thClass: thWidthPercent(5),
+ thClass: thWidthPercent(10),
+ tdClass: 'gl-vertical-align-middle!',
},
{
key: 'actions',
- label: '',
+ label: __('Actions'),
tdClass: 'gl-text-right gl-white-space-nowrap',
- thClass: thWidthPercent(5),
+ thClass: `gl-text-right ${thWidthPercent(5)}`,
},
],
computed: {
@@ -88,9 +83,22 @@ export default {
return '*'.repeat(47);
},
},
+ mounted() {
+ const revealButton = document.querySelector('[data-testid="reveal-hide-values-button"]');
+ if (revealButton) {
+ if (this.triggers.length === 0) {
+ revealButton.style.display = 'none';
+ }
+
+ revealButton.addEventListener('click', () => {
+ this.toggleHiddenState(revealButton);
+ });
+ }
+ },
methods: {
- toggleHiddenState() {
+ toggleHiddenState(element) {
this.areValuesHidden = !this.areValuesHidden;
+ element.innerText = this.valuesButtonText;
},
},
};
@@ -102,7 +110,8 @@ export default {
v-if="hasTriggers"
:fields="$options.fields"
:items="triggers"
- class="triggers-list"
+ class="triggers-list gl-mb-0"
+ stacked="md"
responsive
>
<template #cell(token)="{ item }">
@@ -116,8 +125,8 @@ export default {
:title="$options.i18n.copyTrigger"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
- <div class="gl-display-inline-block gl-ml-3">
- <gl-badge v-if="!item.canAccessProject" variant="danger">
+ <div v-if="!item.canAccessProject" class="gl-display-inline-block gl-ml-3">
+ <gl-badge variant="danger">
<span
v-gl-tooltip.viewport
boundary="viewport"
@@ -132,7 +141,7 @@ export default {
:title="item.description"
truncate-target="child"
placement="top"
- class="gl-max-w-15 gl-display-flex"
+ class="gl-max-w-15 gl-display-inline-flex"
>
<div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div>
</tooltip-on-truncate>
@@ -157,6 +166,7 @@ export default {
:title="$options.i18n.editButton"
:aria-label="$options.i18n.editButton"
icon="pencil"
+ category="tertiary"
data-testid="edit-btn"
:href="item.editProjectTriggerPath"
/>
@@ -164,32 +174,24 @@ export default {
:title="$options.i18n.revokeButton"
:aria-label="$options.i18n.revokeButton"
icon="remove"
+ category="tertiary"
:data-confirm="$options.i18n.revokeButtonConfirm"
data-method="delete"
data-confirm-btn-variant="danger"
rel="nofollow"
- class="gl-ml-3"
data-testid="trigger_revoke_button"
data-qa-selector="trigger_revoke_button"
:href="item.projectTriggerPath"
/>
</template>
</gl-table>
- <gl-alert
+ <div
v-else
- variant="warning"
- :dismissible="false"
- :show-icon="false"
+ class="gl-new-card-empty gl-px-5 gl-py-4"
data-testid="no_triggers_content"
data-qa-selector="no_triggers_content"
>
{{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }}
- </gl-alert>
- <gl-button
- v-if="hasTriggers"
- data-testid="reveal-hide-values-button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</gl-button
- >
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index d05311cb1db..8177720b172 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -374,6 +374,7 @@ export default {
<template v-if="isMrSidebarMoved">
<gl-dropdown-item
:data-clipboard-text="issuableReference"
+ button-class="js-copy-reference"
data-testid="copy-reference"
@click="copyReference"
>{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
@@ -486,6 +487,7 @@ export default {
<template v-if="isMrSidebarMoved">
<gl-dropdown-item
:data-clipboard-text="issuableReference"
+ button-class="js-copy-reference"
data-testid="copy-reference"
@click="copyReference"
>{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
diff --git a/app/assets/javascripts/lib/print_markdown_dom.js b/app/assets/javascripts/lib/print_markdown_dom.js
new file mode 100644
index 00000000000..fb5ea09b6c8
--- /dev/null
+++ b/app/assets/javascripts/lib/print_markdown_dom.js
@@ -0,0 +1,50 @@
+function getPrintContent(target, ignoreSelectors) {
+ const cloneDom = target.cloneNode(true);
+ cloneDom.querySelectorAll('details').forEach((detail) => {
+ detail.setAttribute('open', '');
+ });
+
+ if (Array.isArray(ignoreSelectors) && ignoreSelectors.length > 0) {
+ cloneDom.querySelectorAll(ignoreSelectors.join(',')).forEach((ignoredNode) => {
+ ignoredNode.remove();
+ });
+ }
+
+ cloneDom.querySelectorAll('img').forEach((img) => {
+ img.setAttribute('loading', 'eager');
+ });
+
+ return cloneDom.innerHTML;
+}
+
+function getTitleContent(title) {
+ const titleElement = document.createElement('h2');
+ titleElement.className = 'gl-mt-0 gl-mb-5';
+ titleElement.innerText = title;
+ return titleElement.outerHTML;
+}
+
+export default async function printMarkdownDom({
+ target,
+ title,
+ ignoreSelectors = [],
+ stylesheet = [],
+}) {
+ const printJS = (await import('print-js')).default;
+
+ const printContent = getPrintContent(target, ignoreSelectors);
+
+ const titleElement = title ? getTitleContent(title) : '';
+
+ const markdownElement = `<div class="md">${printContent}</div>`;
+
+ const printable = titleElement + markdownElement;
+
+ printJS({
+ printable,
+ type: 'raw-html',
+ documentTitle: title,
+ scanStyles: false,
+ css: stylesheet,
+ });
+}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue
new file mode 100644
index 00000000000..4d13f25c4cb
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { __ } from '~/locale';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ },
+ inject: ['target', 'title', 'stylesheet'],
+ computed: {
+ dropdownItems() {
+ return [
+ {
+ text: __('Print as PDF'),
+ action: this.print,
+ },
+ ];
+ },
+ },
+ methods: {
+ print() {
+ printMarkdownDom({
+ target: document.querySelector(this.target),
+ title: this.title,
+ stylesheet: this.stylesheet,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-disclosure-dropdown
+ :items="dropdownItems"
+ icon="ellipsis_v"
+ category="tertiary"
+ placement="right"
+ no-caret
+ />
+</template>
diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js
index 9906cb595f8..9bc399d07b3 100644
--- a/app/assets/javascripts/pages/shared/wikis/show.js
+++ b/app/assets/javascripts/pages/shared/wikis/show.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Wikis from './wikis';
import WikiContent from './components/wiki_content.vue';
+import WikiExport from './components/wiki_export.vue';
const mountWikiContentApp = () => {
const el = document.querySelector('.js-async-wiki-page-content');
@@ -20,8 +21,28 @@ const mountWikiContentApp = () => {
}
};
+const mountWikiExportApp = () => {
+ const el = document.querySelector('#js-export-actions');
+
+ if (!el) return false;
+ const { target, title, stylesheet } = JSON.parse(el.dataset.options);
+
+ return new Vue({
+ el,
+ provide: {
+ target,
+ title,
+ stylesheet,
+ },
+ render(createElement) {
+ return createElement(WikiExport);
+ },
+ });
+};
+
export const mountApplications = () => {
// eslint-disable-next-line no-new
new Wikis();
mountWikiContentApp();
+ mountWikiExportApp();
};
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue
index f90633c6e03..a1c1b1141a7 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue
@@ -8,7 +8,9 @@ import {
I18N_CARD_TITLE,
I18N_GENERIC_ERROR,
I18N_FEEDBACK_PARAGRAPH,
+ I18N_TOAST_SAVED,
} from '../custom_email_constants';
+import CustomEmailForm from './custom_email_form.vue';
export default {
components: {
@@ -18,11 +20,13 @@ export default {
GlSprintf,
GlLink,
GlCard,
+ CustomEmailForm,
},
FEEDBACK_ISSUE_URL,
I18N_LOADING_LABEL,
I18N_CARD_TITLE,
I18N_FEEDBACK_PARAGRAPH,
+ I18N_TOAST_SAVED,
props: {
incomingEmail: {
type: String,
@@ -38,6 +42,7 @@ export default {
data() {
return {
loading: true,
+ submitting: false,
customEmail: null,
enabled: false,
verificationState: null,
@@ -47,6 +52,11 @@ export default {
alertMessage: null,
};
},
+ computed: {
+ customEmailNotSetUp() {
+ return !this.enabled && this.verificationState === null && this.customEmail === null;
+ },
+ },
mounted() {
this.getCustomEmailDetails();
},
@@ -76,6 +86,21 @@ export default {
this.smtpAddress = data.custom_email_smtp_address;
this.errorMessage = data.error_message;
},
+ onSaveCustomEmail(requestData) {
+ this.alertMessage = null;
+ this.submitting = true;
+
+ axios
+ .post(this.customEmailEndpoint, requestData)
+ .then(({ data }) => {
+ this.updateData(data);
+ this.$toast.show(this.$options.I18N_TOAST_SAVED);
+ })
+ .catch(this.handleRequestError)
+ .finally(() => {
+ this.submitting = false;
+ });
+ },
},
};
</script>
@@ -108,11 +133,20 @@ export default {
<gl-alert
v-if="alertMessage"
variant="warning"
- class="gl-mt-n5 gl-mx-n5"
+ class="gl-mt-n5 gl-mb-4 gl-mx-n5"
@dismiss="dismissAlert"
>
{{ alertMessage }}
</gl-alert>
+
+ <!-- Use v-show to preserve form data after verification failure
+ without the need to maintain a state in this component. -->
+ <custom-email-form
+ v-show="customEmailNotSetUp && !loading"
+ :incoming-email="incomingEmail"
+ :submitting="submitting"
+ @submit="onSaveCustomEmail"
+ />
</template>
<template #footer>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue
new file mode 100644
index 00000000000..7088627a487
--- /dev/null
+++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue
@@ -0,0 +1,291 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInputGroup, GlFormInput } from '@gitlab/ui';
+import { isEmptyValue, hasMinimumLength, isIntegerGreaterThan, isEmail } from '~/lib/utils/forms';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import {
+ I18N_FORM_INTRODUCTION_PARAGRAPH,
+ I18N_FORM_CUSTOM_EMAIL_LABEL,
+ I18N_FORM_CUSTOM_EMAIL_DESCRIPTION,
+ I18N_FORM_FORWARDING_LABEL,
+ I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE,
+ I18N_FORM_SMTP_ADDRESS_LABEL,
+ I18N_FORM_SMTP_PORT_LABEL,
+ I18N_FORM_SMTP_PORT_DESCRIPTION,
+ I18N_FORM_SMTP_USERNAME_LABEL,
+ I18N_FORM_SMTP_PASSWORD_LABEL,
+ I18N_FORM_SMTP_PASSWORD_DESCRIPTION,
+ I18N_FORM_SUBMIT_LABEL,
+ I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PORT,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD,
+} from '../custom_email_constants';
+
+export default {
+ components: {
+ ClipboardButton,
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlFormInput,
+ },
+ I18N_FORM_INTRODUCTION_PARAGRAPH,
+ I18N_FORM_CUSTOM_EMAIL_LABEL,
+ I18N_FORM_CUSTOM_EMAIL_DESCRIPTION,
+ I18N_FORM_FORWARDING_LABEL,
+ I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE,
+ I18N_FORM_SMTP_ADDRESS_LABEL,
+ I18N_FORM_SMTP_PORT_LABEL,
+ I18N_FORM_SMTP_PORT_DESCRIPTION,
+ I18N_FORM_SMTP_USERNAME_LABEL,
+ I18N_FORM_SMTP_PASSWORD_LABEL,
+ I18N_FORM_SMTP_PASSWORD_DESCRIPTION,
+ I18N_FORM_SUBMIT_LABEL,
+ I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PORT,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME,
+ I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD,
+ props: {
+ incomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ submitting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ customEmail: '',
+ forwardingConfigured: false,
+ smtpAddress: '',
+ smtpPort: '587',
+ smtpUsername: '',
+ smtpPassword: '',
+ validationState: {
+ customEmail: null,
+ smtpAddress: null,
+ smtpPort: true,
+ smtpUsername: null,
+ smtpPassword: null,
+ },
+ };
+ },
+ computed: {
+ isFormValid() {
+ return Object.values(this.validationState).every(Boolean);
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.triggerVerification();
+
+ if (!this.isFormValid) {
+ return;
+ }
+
+ this.$emit('submit', this.getRequestFormData());
+ },
+ getRequestFormData() {
+ return {
+ custom_email: this.customEmail,
+ smtp_address: this.smtpAddress,
+ smtp_port: this.smtpPort,
+ smtp_username: this.smtpUsername,
+ smtp_password: this.smtpPassword,
+ };
+ },
+ onCustomEmailChange() {
+ this.validateCustomEmail();
+
+ if (this.validationState.customEmail && isEmptyValue(this.smtpUsername)) {
+ this.smtpUsername = this.customEmail;
+ this.validateSmtpUsername();
+ }
+ },
+ validateCustomEmail() {
+ this.validationState.customEmail = isEmail(this.customEmail);
+ },
+ validateSmtpAddress() {
+ this.validationState.smtpAddress = !isEmptyValue(this.smtpAddress);
+ },
+ validateSmtpPort() {
+ this.validationState.smtpPort = isIntegerGreaterThan(this.smtpPort, 0);
+ },
+ validateSmtpUsername() {
+ this.validationState.smtpUsername = !isEmptyValue(this.smtpUsername);
+ },
+ validateSmtpPassword() {
+ this.validationState.smtpPassword = hasMinimumLength(this.smtpPassword, 8);
+ },
+ triggerVerification() {
+ this.validateCustomEmail();
+ this.validateSmtpAddress();
+ this.validateSmtpPort();
+ this.validateSmtpUsername();
+ this.validateSmtpPassword();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p>{{ $options.I18N_FORM_INTRODUCTION_PARAGRAPH }}</p>
+ <gl-form class="js-quick-submit" @submit.prevent="onSubmit">
+ <gl-form-group
+ :label="$options.I18N_FORM_FORWARDING_LABEL"
+ label-for="custom-email-form-forwarding"
+ class="gl-mt-3"
+ >
+ <gl-form-input-group>
+ <gl-form-input
+ id="custom-email-form-forwarding"
+ ref="service-desk-incoming-email"
+ type="text"
+ data-testid="custom-email-form-forwarding"
+ :aria-label="$options.I18N_FORM_FORWARDING_LABEL"
+ :value="incomingEmail"
+ :disabled="true"
+ />
+ <template #append>
+ <clipboard-button
+ :title="$options.I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE"
+ :text="incomingEmail"
+ css-class="input-group-text"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL"
+ label-for="custom-email-form-custom-email"
+ data-testid="form-group-custom-email"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL"
+ class="gl-mt-3"
+ :description="$options.I18N_FORM_CUSTOM_EMAIL_DESCRIPTION"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-custom-email"
+ v-model.trim="customEmail"
+ data-testid="form-custom-email"
+ :aria-label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL"
+ placeholder="contact@example.com"
+ type="email"
+ :state="validationState.customEmail"
+ :required="true"
+ :disabled="submitting"
+ @change="onCustomEmailChange"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_ADDRESS_LABEL"
+ label-for="custom-email-form-smtp-address"
+ data-testid="form-group-smtp-address"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS"
+ class="gl-mt-3"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-smtp-address"
+ v-model.trim="smtpAddress"
+ data-testid="form-smtp-address"
+ :aria-label="$options.I18N_FORM_SMTP_ADDRESS_LABEL"
+ placeholder="smtp.example.com"
+ type="email"
+ :state="validationState.smtpAddress"
+ :required="true"
+ :disabled="submitting"
+ @change="validateSmtpAddress"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_PORT_LABEL"
+ label-for="custom-email-form-smtp-port"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PORT"
+ class="gl-mt-3"
+ :description="$options.I18N_FORM_SMTP_PORT_DESCRIPTION"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-smtp-port"
+ v-model.trim="smtpPort"
+ data-testid="form-smtp-port"
+ :aria-label="$options.I18N_FORM_SMTP_PORT_LABEL"
+ placeholder="587"
+ type="number"
+ :state="validationState.smtpPort"
+ :required="true"
+ :disabled="submitting"
+ @change="validateSmtpPort"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_USERNAME_LABEL"
+ label-for="custom-email-form-smtp-username"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME"
+ class="gl-mt-3"
+ >
+ <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
+ <gl-form-input
+ id="custom-email-form-smtp-username"
+ v-model.trim="smtpUsername"
+ data-testid="form-smtp-username"
+ :aria-label="$options.I18N_FORM_SMTP_USERNAME_LABEL"
+ placeholder="contact@example.com"
+ :state="validationState.smtpUsername"
+ :required="true"
+ :disabled="submitting"
+ @change="validateSmtpUsername"
+ />
+ <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.I18N_FORM_SMTP_PASSWORD_LABEL"
+ label-for="custom-email-form-smtp-password"
+ :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD"
+ class="gl-mt-3"
+ :description="$options.I18N_FORM_SMTP_PASSWORD_DESCRIPTION"
+ >
+ <gl-form-input
+ id="custom-email-form-smtp-password"
+ v-model.trim="smtpPassword"
+ data-testid="form-smtp-password"
+ :aria-label="$options.I18N_FORM_SMTP_PASSWORD_LABEL"
+ type="password"
+ :state="validationState.smtpPassword"
+ :required="true"
+ :disabled="submitting"
+ @change="validateSmtpPassword"
+ />
+ </gl-form-group>
+
+ <gl-button
+ type="submit"
+ variant="confirm"
+ class="gl-mt-5"
+ data-testid="form-submit"
+ :disabled="!isFormValid"
+ :loading="submitting"
+ @click="onSubmit"
+ >
+ {{ $options.I18N_FORM_SUBMIT_LABEL }}
+ </gl-button>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js
index 9770a1f4df9..cc5dc8af2e4 100644
--- a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js
+++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js
@@ -8,3 +8,46 @@ export const I18N_FEEDBACK_PARAGRAPH = s__(
'ServiceDesk|Please share your feedback on this feature in the %{linkStart}feedback issue%{linkEnd}',
);
export const I18N_GENERIC_ERROR = __('An error occurred. Please try again.');
+
+export const I18N_TOAST_SAVED = s__(
+ 'ServiceDesk|Saved custom email address and started verification.',
+);
+
+export const I18N_FORM_INTRODUCTION_PARAGRAPH = s__(
+ 'ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials.',
+);
+export const I18N_FORM_FORWARDING_LABEL = s__(
+ 'ServiceDesk|Service Desk email address to forward emails to',
+);
+export const I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE = s__(
+ 'ServiceDesk|Copy Service Desk email address',
+);
+export const I18N_FORM_CUSTOM_EMAIL_LABEL = s__('ServiceDesk|Custom email address');
+export const I18N_FORM_CUSTOM_EMAIL_DESCRIPTION = s__(
+ 'ServiceDesk|Email address your customers can use to send support requests. It must support sub-addressing.',
+);
+export const I18N_FORM_SMTP_ADDRESS_LABEL = s__('ServiceDesk|SMTP host');
+export const I18N_FORM_SMTP_PORT_LABEL = s__('ServiceDesk|SMTP port');
+export const I18N_FORM_SMTP_PORT_DESCRIPTION = s__(
+ 'ServiceDesk|Common ports are 587 when using TLS, and 25 when not.',
+);
+export const I18N_FORM_SMTP_USERNAME_LABEL = s__('ServiceDesk|SMTP username');
+export const I18N_FORM_SMTP_PASSWORD_LABEL = s__('ServiceDesk|SMTP password');
+export const I18N_FORM_SMTP_PASSWORD_DESCRIPTION = s__('ServiceDesk|Minimum 8 characters long.');
+export const I18N_FORM_SUBMIT_LABEL = s__('ServiceDesk|Save and test connection');
+
+export const I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL = s__(
+ 'ServiceDesk|Custom email is required and must be a valid email address.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS = s__(
+ 'ServiceDesk|SMTP address is required and must be resolvable.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_PORT = s__(
+ 'ServiceDesk|SMTP port is required and must be a port number larger than 0.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME = s__(
+ 'ServiceDesk|SMTP username is required.',
+);
+export const I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD = s__(
+ 'ServiceDesk|SMTP password is required and must be at least 8 characters long.',
+);
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index dd9585734db..c4d4f42576f 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -1,7 +1,10 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ServiceDeskRoot from './components/service_desk_root.vue';
+Vue.use(GlToast);
+
export default () => {
const el = document.querySelector('.js-service-desk-setting-root');
diff --git a/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue
new file mode 100644
index 00000000000..a15c8ee2e9f
--- /dev/null
+++ b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+} from '../constants';
+
+export default {
+ i18n: {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+ },
+ components: {
+ GlEmptyState,
+ },
+ inject: ['emptyStateSvgPath'],
+ props: {
+ hasSearch: {
+ type: Boolean,
+ required: true,
+ },
+ isOpenTab: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ content() {
+ if (this.hasSearch) {
+ return {
+ title: noSearchResultsTitle,
+ description: noSearchResultsDescription,
+ svgHeight: 150,
+ };
+ } else if (this.isOpenTab) {
+ return { title: noOpenIssuesTitle, description: infoBannerUserNote };
+ }
+
+ return { title: noClosedIssuesTitle, svgHeight: 150 };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :description="content.description"
+ :title="content.title"
+ :svg-path="emptyStateSvgPath"
+ :svg-height="content.svgHeight"
+ />
+</template>
diff --git a/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue
new file mode 100644
index 00000000000..0679d31a8b8
--- /dev/null
+++ b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import {
+ noIssuesSignedOutButtonText,
+ infoBannerTitle,
+ infoBannerUserNote,
+ infoBannerAdminNote,
+ learnMore,
+} from '../constants';
+
+export default {
+ i18n: {
+ noIssuesSignedOutButtonText,
+ infoBannerTitle,
+ infoBannerUserNote,
+ infoBannerAdminNote,
+ learnMore,
+ },
+ serviceDeskHelpPagePath: helpPagePath('user/project/service_desk/index'),
+ components: {
+ GlEmptyState,
+ GlLink,
+ },
+ inject: [
+ 'emptyStateSvgPath',
+ 'isSignedIn',
+ 'signInPath',
+ 'canAdminIssues',
+ 'isServiceDeskEnabled',
+ 'serviceDeskEmailAddress',
+ ],
+ computed: {
+ canSeeEmailAddress() {
+ return this.canAdminIssues && this.isServiceDeskEnabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isSignedIn">
+ <gl-empty-state
+ :title="$options.i18n.infoBannerTitle"
+ :svg-path="emptyStateSvgPath"
+ content-class="gl-max-w-80!"
+ >
+ <template #description>
+ <p v-if="canSeeEmailAddress">
+ {{ $options.i18n.infoBannerAdminNote }} <br /><code>{{ serviceDeskEmailAddress }}</code>
+ </p>
+ <p>{{ $options.i18n.infoBannerUserNote }}</p>
+ <gl-link :href="$options.serviceDeskHelpPagePath" target="_blank">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.infoBannerTitle"
+ :svg-path="emptyStateSvgPath"
+ :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
+ :primary-button-link="signInPath"
+ content-class="gl-max-w-80!"
+ >
+ <template #description>
+ <p>{{ $options.i18n.infoBannerUserNote }}</p>
+ <gl-link :href="$options.serviceDeskHelpPagePath">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
index e4b8142e153..32b219a9fa9 100644
--- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
+++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue
@@ -1,5 +1,4 @@
<script>
-import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { isEmpty } from 'lodash';
@@ -44,7 +43,6 @@ import searchProjectMilestonesQuery from '../queries/search_project_milestones.q
import {
errorFetchingCounts,
errorFetchingIssues,
- noSearchNoFilterTitle,
searchPlaceholder,
SERVICE_DESK_BOT_USERNAME,
STATUS_OPEN,
@@ -63,19 +61,21 @@ import {
confidentialityTokenBase,
} from '../search_tokens';
import InfoBanner from './info_banner.vue';
+import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue';
+import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue';
export default {
i18n: {
errorFetchingCounts,
errorFetchingIssues,
- noSearchNoFilterTitle,
searchPlaceholder,
},
issuableListTabs,
components: {
- GlEmptyState,
IssuableList,
InfoBanner,
+ EmptyStateWithAnyIssues,
+ EmptyStateWithoutAnyIssues,
},
mixins: [glFeatureFlagMixin()],
inject: [
@@ -188,6 +188,9 @@ export default {
[STATUS_ALL]: allIssues?.count,
};
},
+ isOpenTab() {
+ return this.state === STATUS_OPEN;
+ },
urlParams() {
return {
sort: urlSortParams[this.sortKey],
@@ -200,7 +203,10 @@ export default {
};
},
isInfoBannerVisible() {
- return this.isServiceDeskSupported && this.hasAnyIssues;
+ return this.isServiceDeskSupported && this.hasAnyServiceDeskIssues;
+ },
+ hasAnyServiceDeskIssues() {
+ return this.hasSearch || Boolean(this.tabCounts.all);
},
hasOrFeature() {
return this.glFeatures.orIssuableQueries;
@@ -404,6 +410,7 @@ export default {
<section>
<info-banner v-if="isInfoBannerVisible" />
<issuable-list
+ v-if="hasAnyServiceDeskIssues"
namespace="service-desk"
recent-searches-storage-key="service-desk-issues"
:error="issuesError"
@@ -423,11 +430,10 @@ export default {
@filter="handleFilter"
>
<template #empty-state>
- <gl-empty-state
- :svg-path="emptyStateSvgPath"
- :title="$options.i18n.noSearchNoFilterTitle"
- />
+ <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" />
</template>
</issuable-list>
+
+ <empty-state-without-any-issues v-else />
</section>
</template>
diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js
index 54615f466f3..a83c0d9ca57 100644
--- a/app/assets/javascripts/service_desk/constants.js
+++ b/app/assets/javascripts/service_desk/constants.js
@@ -228,7 +228,13 @@ export const filtersMap = {
export const errorFetchingCounts = __('An error occurred while getting issue counts');
export const errorFetchingIssues = __('An error occurred while loading issues');
-export const noSearchNoFilterTitle = __('Please select at least one filter to see results');
+export const noOpenIssuesTitle = __('There are no open issues');
+export const noClosedIssuesTitle = __('There are no closed issues');
+export const noIssuesSignedOutButtonText = __('Register / Sign In');
+export const noSearchResultsDescription = __(
+ 'To widen your search, change or remove filters above',
+);
+export const noSearchResultsTitle = __('Sorry, your filter produced no results');
export const searchPlaceholder = __('Search or filter results...');
export const infoBannerTitle = s__(
'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab',
@@ -238,7 +244,7 @@ export const infoBannerUserNote = s__(
'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.',
);
export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk');
-export const learnMore = __('Learn more');
+export const learnMore = __('Learn more about Service Desk');
export const titles = __('Titles');
export const descriptions = __('Descriptions');
export const no = __('No');
diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js
index ab96b550613..afb2d0e8de3 100644
--- a/app/assets/javascripts/service_desk/index.js
+++ b/app/assets/javascripts/service_desk/index.js
@@ -23,6 +23,7 @@ export async function mountServiceDeskListApp() {
projectDataFullPath,
projectDataIsProject,
projectDataIsSignedIn,
+ projectDataSignInPath,
projectDataHasAnyIssues,
projectDataInitialSort,
serviceDeskEmailAddress,
@@ -68,6 +69,7 @@ export async function mountServiceDeskListApp() {
serviceDeskHelpPath,
isServiceDeskSupported: parseBoolean(isServiceDeskSupported),
isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled),
+ signInPath: projectDataSignInPath,
hasAnyIssues: parseBoolean(projectDataHasAnyIssues),
initialSort: projectDataInitialSort,
},
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 84181a00f34..c3662c3e6ea 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -1,3 +1,9 @@
+@import 'framework/variables';
+@import 'framework/variables_overrides';
+
+@import '@gitlab/ui/src/scss/variables';
+@import '@gitlab/ui/src/scss/utility-mixins/index';
+
.md h1,
.md h2,
.md h3,
@@ -20,6 +26,35 @@
font-weight: 600;
}
+.md {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+
+ // fix blockquote style in print
+ blockquote {
+ &::before {
+ position: absolute;
+ top: 0;
+ left: -4px;
+ content: ' ';
+ height: 100%;
+ width: 4px;
+ background-color: $white-dark;
+ }
+
+ position: relative;
+ font-size: inherit;
+ @include gl-text-gray-700;
+ @include gl-py-3;
+ @include gl-pl-6;
+ @include gl-my-3;
+ @include gl-mx-0;
+ @include gl-inset-border-l-4-gray-100;
+ margin-left: 4px;
+ border: 0 !important;
+ }
+}
+
header,
nav,
nav.navbar-collapse,
@@ -40,6 +75,7 @@ ul.notes-form,
.note-action-button,
.right-sidebar,
.flash-container,
+copy-code,
#js-peek {
display: none !important;
}
diff --git a/app/events/repositories/default_branch_changed_event.rb b/app/events/repositories/default_branch_changed_event.rb
new file mode 100644
index 00000000000..3519fb4be86
--- /dev/null
+++ b/app/events/repositories/default_branch_changed_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Repositories
+ class DefaultBranchChangedEvent < ::Gitlab::EventStore::Event
+ def schema
+ {
+ 'type' => 'object',
+ 'properties' => {
+ 'container_id' => { 'type' => 'integer' },
+ 'container_type' => { 'type' => 'string' }
+ },
+ 'required' => %w[container_id container_type]
+ }
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/pipeline_trigger/create.rb b/app/graphql/mutations/ci/pipeline_trigger/create.rb
new file mode 100644
index 00000000000..042f9b26dd0
--- /dev/null
+++ b/app/graphql/mutations/ci/pipeline_trigger/create.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module PipelineTrigger
+ class Create < BaseMutation
+ graphql_name 'PipelineTriggerCreate'
+
+ include FindsProject
+
+ authorize :admin_build
+
+ argument :project_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project that the pipeline trigger token to mutate is in.'
+
+ argument :description, GraphQL::Types::String,
+ required: true,
+ description: 'Description of the pipeline trigger token.'
+
+ field :pipeline_trigger, Types::Ci::PipelineTriggerType,
+ null: true,
+ description: 'Mutated pipeline trigger token.'
+
+ def resolve(project_path:, description:)
+ project = authorized_find!(project_path)
+
+ trigger = project.triggers.create(owner: current_user, description: description)
+
+ {
+ pipeline_trigger: trigger,
+ errors: trigger.errors.full_messages
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 9a6b3861103..a9fce9e212d 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -143,6 +143,7 @@ module Types
mount_mutation Mutations::Ci::PipelineSchedule::Play
mount_mutation Mutations::Ci::PipelineSchedule::Create
mount_mutation Mutations::Ci::PipelineSchedule::Update
+ mount_mutation Mutations::Ci::PipelineTrigger::Create, alpha: { milestone: '16.3' }
mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate
mount_mutation Mutations::Ci::Job::ArtifactsDestroy
mount_mutation Mutations::Ci::Job::Play
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index d614d6c4584..0e7381882b5 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -119,6 +119,9 @@ module HasRepository
def after_repository_change_head
reload_default_branch
+
+ Gitlab::EventStore.publish(
+ Repositories::DefaultBranchChangedEvent.new(data: { container_id: id, container_type: self.class.name }))
end
def after_change_head_branch_does_not_exist(branch)
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 7e5e8d8f961..65f9e6c2342 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -23,10 +23,6 @@
maskable_raw_regex: ci_variable_maskable_raw_regex,
maskable_regex: ci_variable_maskable_regex,
protected_by_default: ci_variable_protected_by_default?.to_s,
- aws_logo_svg_path: image_path('aws_logo.svg'),
- aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-ecs'),
- aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'use-an-image-to-run-aws-commands'),
- aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md'),
contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'prevent-cicd-variable-expansion'),
masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'),
environment_scope_link: help_page_path('ci/environments/index', anchor: 'limit-the-environment-scope-of-a-cicd-variable') } }
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 19f5ae4e6b3..62040703c8f 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -70,10 +70,10 @@
%section.settings.no-animate#js-pipeline-triggers{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- = _("Pipeline triggers")
+ = _("Pipeline trigger tokens")
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- %p.gl-text-secondary
+ %p.gl-text-secondary.gl-mb-0
= _("Trigger a pipeline for a branch or tag by generating a trigger token and using it with an API call. The token impersonates a user's project access and permissions.")
= link_to _('Learn more.'), help_page_path('ci/triggers/index'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index b621f1ab3ed..b7e226b009c 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -1,3 +1,4 @@
+- show_cancel_button = local_assigns.fetch(:show_cancel_button, false)
= gitlab_ui_form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f|
= form_errors(@trigger)
@@ -9,3 +10,6 @@
= f.label :key, s_("Trigger|Description"), class: "label-bold"
= f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: s_("Trigger|Trigger description")
= f.submit btn_text, pajamas_button: true
+ - if show_cancel_button
+ = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do
+ = _('Cancel')
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index b68aad24b50..7b6915b7b85 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,17 +1,38 @@
+- add_form_class = 'gl-display-none' if !form_errors(@trigger)
+- hide_class = 'gl-display-none' if form_errors(@trigger)
+
.row.gl-mt-3.gl-mb-3
.col-lg-12
- = render Pajamas::CardComponent.new do |c|
+ = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card js-toggle-container' }, header_options: { class: 'gl-new-card-header gl-flex-wrap gl-sm-flex-nowrap' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c|
- c.with_header do
- = _("Manage your project's triggers")
+ .gl-new-card-title-wrapper
+ %h5.gl-new-card-title
+ = _("Active pipeline trigger tokens")
+ .gl-new-card-count
+ = sprite_icon('token', css_class: 'gl-mr-2')
+ = @triggers.size
+ .gl-new-card-actions.gl-display-flex.gl-justify-content-end.gl-w-full.gl-sm-w-auto.gl-mt-3.gl-sm-mt-0
+ = render Pajamas::ButtonComponent.new(size: :small, category: :tertiary, button_options: { data: { testid: 'reveal-hide-values-button' } }) do
+ = _('Reveal values')
+ = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: "gl-ml-2 js-toggle-button js-toggle-content #{hide_class}" }) do
+ = _('Add new token')
+
- c.with_body do
- = render 'projects/triggers/form', btn_text: _('Add trigger')
- .gl-mb-5
+ .gl-new-card-add-form.gl-m-3.js-toggle-content{ class: add_form_class }
+ %h4.gl-mt-0
+ = _('Add new pipeline trigger token')
+ = render 'projects/triggers/form', btn_text: _('Create pipeline trigger token'), show_cancel_button: true
+
#js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- - c.with_footer do
- %p
- = _("These examples show how to trigger this project's pipeline for a branch or tag.")
- %p.light
+ %details.gl-mt-5.gl-border.gl-rounded-base
+ %summary.gl-py-3.gl-px-5.gl-font-weight-semibold
+ = _("View trigger token usage examples")
+ .gl-p-5
+ %p.gl-text-secondary
+ = _("These examples show common methods of triggering a pipeline with a pipeline trigger token. The URL and ID for this project is prefilled.")
+
+ %p.gl-text-secondary
= _('In each example, replace %{code_start}TOKEN%{code_end} with the trigger token you generated and replace %{code_start}REF_NAME%{code_end} with the branch or tag name.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
%h5.gl-mt-3
@@ -40,10 +61,10 @@
%h5.gl-mt-3
= _('Pass job variables')
- %p.light
+ %p.gl-text-secondary
= _('To pass variables to the triggered pipeline, add %{code_start}variables[VARIABLE]=VALUE%{code_end} to the API request.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
- %p.light
+ %p.gl-text-secondary
= _('cURL:')
%pre
@@ -54,7 +75,7 @@
-F "ref=REF_NAME" \
-F "variables[RUN_NIGHTLY_BUILD]=true" \
#{builds_trigger_url(@project.id)}
- %p.light
+ %p.gl-text-secondary
= _('Webhook:')
%pre.gl-mb-0
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index 28699ca27f3..be1f43f44de 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -12,6 +12,8 @@
.nav-controls.pb-md-3.pb-lg-0
= render 'shared/wikis/main_links'
+ - if Feature.enabled?(:print_wiki, current_user)
+ #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } }
- if @page.historical?
= render Pajamas::AlertComponent.new(variant: :warning,
diff --git a/config/feature_flags/development/print_wiki.yml b/config/feature_flags/development/print_wiki.yml
new file mode 100644
index 00000000000..e04d7dd84bf
--- /dev/null
+++ b/config/feature_flags/development/print_wiki.yml
@@ -0,0 +1,8 @@
+---
+name: print_wiki
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125260
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/414691
+milestone: '16.3'
+type: development
+group: group::knowledge
+default_enabled: false
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 10da221b6d9..b54653c5237 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -565,6 +565,8 @@
- 1
- - sbom_reports
- 1
+- - search_elastic_default_branch_changed
+ - 1
- - search_elastic_group_association_deletion
- 1
- - search_namespace_index_integrity
diff --git a/doc/administration/geo/setup/index.md b/doc/administration/geo/setup/index.md
index 8ac64a963bb..cb318783128 100644
--- a/doc/administration/geo/setup/index.md
+++ b/doc/administration/geo/setup/index.md
@@ -34,17 +34,28 @@ If both Geo sites are based on the [1K reference architecture](../../reference_a
- [Using Linux package PostgreSQL instances](database.md) .
- [Using external PostgreSQL instances](external_database.md)
1. [Configure GitLab](../replication/configuration.md) to set the **primary** and **secondary** sites.
-1. Recommended: [Configure unified URLs](../secondary_proxy/index.md#set-up-a-unified-url-for-geo-sites) to use a single, unified URL for all Geo sites.
-1. Optional: [Configure Object storage replication](../replication/object_storage.md)
-1. Optional: [Configure a secondary LDAP server](../../auth/ldap/index.md) for the **secondary** sites. See [notes on LDAP](../index.md#ldap).
-1. Optional: [Configure Container Registry for the secondary site](../replication/container_registry.md).
1. Follow the [Using a Geo Site](../replication/usage.md) guide.
+Depending on your GitLab deployment, [additional configuration](#additional-configuration) for LDAP, object storage, and the Container Registry might be required.
+
### Multi-node Geo sites
If one or more of your sites is using the [2K reference architecture](../../reference_architectures/2k_users.md) or larger, see
[Configure Geo for multiple nodes](../replication/multiple_servers.md).
+Depending on your GitLab deployment, [additional configuration](#additional-configuration) for LDAP, object storage, and the Container Registry might be required.
+
+### Additional configuration
+
+Depending on how you use GitLab, the following configuration might be required:
+
+- If the **primary** site uses object storage, [configure object storage replication](../replication/object_storage.md) for the **secondary** sites.
+- If you use LDAP, [configure a secondary LDAP server](../../auth/ldap/index.md) for the **secondary** sites.
+ For more information, see [LDAP with Geo](../replication/single_sign_on.md#ldap).
+- If you use the Container Registry, [configure the Container Registry for replication](../replication/container_registry.md) on the **primary** and **secondary** sites.
+
+You should [configure unified URLs](../secondary_proxy/index.md#set-up-a-unified-url-for-geo-sites) to use a single, unified URL for all Geo sites.
+
## Using GitLab Charts
[Configure the GitLab chart with GitLab Geo](https://docs.gitlab.com/charts/advanced/geo/).
diff --git a/doc/administration/monitoring/performance/grafana_configuration.md b/doc/administration/monitoring/performance/grafana_configuration.md
index 8b3720ca8a9..8d345bc62f1 100644
--- a/doc/administration/monitoring/performance/grafana_configuration.md
+++ b/doc/administration/monitoring/performance/grafana_configuration.md
@@ -6,10 +6,11 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Grafana Configuration **(FREE SELF)**
-> [Deprecated](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/7772) in GitLab 16.0.
+> - Grafana bundled with GitLab was [deprecated](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/7772) in GitLab 16.0.
+> - Grafana bundled with GitLab was [removed](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/7772) in GitLab 16.3.
WARNING:
-Bundled Grafana was deprecated GitLab 16.0 and is no longer supported. It will be removed in GitLab 16.3.
+Grafana bundled with GitLab was deprecated GitLab 16.0 and removed in GitLab 16.3.
For more information, see [deprecation notes](#deprecation-of-bundled-grafana).
[Grafana](https://grafana.com/) is a tool that enables you to visualize time
@@ -23,7 +24,7 @@ Bundled Grafana was an optional service for Linux package installations that pro
The version of Grafana that is bundled with Linux package installations is no longer supported. If you're using the
bundled Grafana, you should switch to a newer version from [Grafana Labs](https://grafana.com/grafana/).
-### Switch to new Grafana instance
+## Switch to new Grafana instance
To switch away from bundled Grafana to a newer version of Grafana from Grafana Labs:
@@ -32,53 +33,16 @@ To switch away from bundled Grafana to a newer version of Grafana from Grafana L
1. [Import the existing dashboards](https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/#import-a-dashboard) in the new Grafana instance.
1. [Configure GitLab](#integrate-with-gitlab-ui) to use the new Grafana instance.
-### Temporary workaround
+## Import GitLab dashboards
-In GitLab versions 16.0 to 16.2, you can still force Linux package installations to enable and configure Grafana by
-setting the following:
-
-- `grafana['enable'] = true`.
-- `grafana['enable_deprecated_service'] = true`.
-
-You see a deprecation message when reconfiguring GitLab.
-
-## Configure Grafana
-
-Prerequisites:
-
-- Grafana installed.
-
-1. Log in to Grafana as the administration user.
-1. Select **Data Sources** from the **Configuration** menu.
-1. Select **Add data source**.
-1. Select the required data source type. For example, [Prometheus](../prometheus/index.md#prometheus-as-a-grafana-data-source).
-1. Complete the details for the data source and select **Save & Test**.
-
-Grafana should indicate the data source is working.
-
-## Import dashboards
-
-You can now import a set of default dashboards to start displaying information.
-GitLab has published a set of default
-[Grafana dashboards](https://gitlab.com/gitlab-org/grafana-dashboards) to get you started. To use
-them:
+You can import a set of default dashboards to start displaying information. GitLab has published a set of default
+[Grafana dashboards](https://gitlab.com/gitlab-org/grafana-dashboards) to get you started. To use them:
1. Clone the repository, or download a ZIP file or tarball.
-1. Follow these steps to import each JSON file individually:
-
- 1. Log in to Grafana as the administration user.
- 1. Select **Manage** from the **Dashboards** menu.
- 1. Select **Import**, then **Upload JSON file**.
- 1. Locate the JSON file to import and select **Choose for Upload**. Select **Import**.
- 1. After the dashboard is imported, select the **Save dashboard** icon in the top bar.
+1. Follow these steps to [import each dashboard JSON file individually](https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/#import-a-dashboard)
-If you don't save the dashboard after importing it, the dashboard is removed
-when you navigate away from the page. Repeat this process for each dashboard you wish to import.
-
-Alternatively, you can import all the dashboards into your Grafana
-instance. For more information about this process, see the
-[README of the Grafana dashboards](https://gitlab.com/gitlab-org/grafana-dashboards)
-repository.
+Alternatively, you can import all the dashboards into your Grafana instance. For more information about this process,
+see the [GitLab Grafana dashboards](https://gitlab.com/gitlab-org/grafana-dashboards).
## Integrate with GitLab UI
@@ -92,10 +56,7 @@ GitLab sidebar:
1. On the left sidebar, select **Settings > Metrics and profiling**
and expand **Metrics - Grafana**.
1. Select the **Add a link to Grafana** checkbox.
-1. Configure the **Grafana URL**:
- - If Grafana is enabled through a Linux package installation and on the same server,
- leave **Grafana URL** unchanged. It should be `/-/grafana`.
- - Otherwise, enter the full URL of the Grafana instance.
+1. Configure the **Grafana URL**. Enter the full URL of the Grafana instance.
1. Select **Save changes**.
GitLab displays your link in the **Main menu > Admin > Monitoring > Metrics Dashboard**.
@@ -119,58 +80,3 @@ configuration screen:
- No scopes appear.
- The `read_user` scope is included.
-
-> Versions of GitLab prior 13.10 use the API scope instead of `read_user`. In versions of GitLab
-> prior to 13.10, the API scope:
->
-> - Is required to access Grafana through the GitLab OAuth provider.
-> - Is set by enabling the Grafana application as shown in [Integration with GitLab UI](#integrate-with-gitlab-ui).
-
-## Security Update
-
-Users running GitLab version 12.0 or later should immediately upgrade to one of the
-following security releases due to a known vulnerability with the embedded Grafana dashboard:
-
-- 12.0.6
-- 12.1.6
-
-After upgrading, the Grafana dashboard is disabled, and the location of your
-existing Grafana data is changed from `/var/opt/gitlab/grafana/data/` to
-`/var/opt/gitlab/grafana/data.bak.#{Date.today}/`.
-
-To prevent the data from being relocated, you can run the following command prior to upgrading:
-
-```shell
-echo "0" > /var/opt/gitlab/grafana/CVE_reset_status
-```
-
-To reinstate your old data, move it back into its original location:
-
-```shell
-sudo mv /var/opt/gitlab/grafana/data.bak.xxxx/ /var/opt/gitlab/grafana/data/
-```
-
-However, you should **not** reinstate your old data _except_ under one of the following conditions:
-
-1. If you're certain that you changed your default administration password when you enabled Grafana.
-1. If you run GitLab in a private network, accessed only by trusted users, and your
- Grafana login page has not been exposed to the internet.
-
-If you require access to your old Grafana data but don't meet one of these criteria, you may consider:
-
-1. Reinstating it temporarily.
-1. [Exporting the dashboards](https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/#export-and-import-dashboards) you need.
-1. Refreshing the data and [re-importing your dashboards](https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/#export-and-import-dashboards).
-
-WARNING:
-These actions pose a temporary vulnerability while your old Grafana data is in use.
-Deciding to take any of these actions should be weighed carefully with your need to access
-existing data and dashboards.
-
-For more information and further mitigation details, refer to our
-[blog post on the security release](https://about.gitlab.com/releases/2019/08/12/critical-security-release-gitlab-12-dot-1-dot-6-released/).
-
-Read more on:
-
-- [Introduction to GitLab Performance Monitoring](index.md)
-- [GitLab Configuration](gitlab_configuration.md)
diff --git a/doc/administration/package_information/postgresql_versions.md b/doc/administration/package_information/postgresql_versions.md
index 101e1549d19..3a499be43b3 100644
--- a/doc/administration/package_information/postgresql_versions.md
+++ b/doc/administration/package_information/postgresql_versions.md
@@ -31,6 +31,7 @@ Read more about update policies and warnings in the PostgreSQL
| GitLab version | PostgreSQL versions | Default version for fresh installs | Default version for upgrades | Notes |
| -------------- | ------------------- | ---------------------------------- | ---------------------------- | ----- |
+| 16.2.0 | 13.11, 14.8 | 13.11 | 13.11 | For upgrades, users can manually upgrade to 14.8 following the [upgrade documentation](https://docs.gitlab.com/omnibus/settings/database.html#gitlab-162-and-later). |
| 16.0.2 | 13.11 | 13.11 | 13.11 | |
| 16.0.0 | 13.8 | 13.8 | 13.8 | |
| 15.11.7 | 13.11 | 13.11 | 12.12 | |
diff --git a/doc/administration/sidekiq/sidekiq_troubleshooting.md b/doc/administration/sidekiq/sidekiq_troubleshooting.md
index 2929ccfc761..9ae2a59251a 100644
--- a/doc/administration/sidekiq/sidekiq_troubleshooting.md
+++ b/doc/administration/sidekiq/sidekiq_troubleshooting.md
@@ -109,7 +109,7 @@ Gather data on the state of the Sidekiq workers with the following Ruby script.
```shell
cat > /etc/cron.d/sidekiqcheck <<EOF
- */5 * * * * root /opt/gitlab/bin/gitlab-rails runner /var/opt/gitlab/sidekiqcheck.rb > /tmp/sidekiqcheck_$(date '+\%Y\%m\%d-\%H:\%M').out
+ */5 * * * * root /opt/gitlab/bin/gitlab-rails runner /var/opt/gitlab/sidekiqcheck.rb > /tmp/sidekiqcheck_$(date '+\%Y\%m\%d-\%H:\%M').out 2>&1
EOF
```
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 12997fe9b9e..86bfd2d3f13 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -5356,6 +5356,30 @@ Input type: `PipelineScheduleUpdateInput`
| <a id="mutationpipelinescheduleupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationpipelinescheduleupdatepipelineschedule"></a>`pipelineSchedule` | [`PipelineSchedule`](#pipelineschedule) | Updated pipeline schedule. |
+### `Mutation.pipelineTriggerCreate`
+
+WARNING:
+**Introduced** in 16.3.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Input type: `PipelineTriggerCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationpipelinetriggercreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationpipelinetriggercreatedescription"></a>`description` | [`String!`](#string) | Description of the pipeline trigger token. |
+| <a id="mutationpipelinetriggercreateprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project that the pipeline trigger token to mutate is in. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationpipelinetriggercreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationpipelinetriggercreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| <a id="mutationpipelinetriggercreatepipelinetrigger"></a>`pipelineTrigger` | [`PipelineTrigger`](#pipelinetrigger) | Mutated pipeline trigger token. |
+
### `Mutation.projectCiCdSettingsUpdate`
Input type: `ProjectCiCdSettingsUpdateInput`
diff --git a/doc/ci/triggers/index.md b/doc/ci/triggers/index.md
index c955097f6f5..0b2b46f28fe 100644
--- a/doc/ci/triggers/index.md
+++ b/doc/ci/triggers/index.md
@@ -28,8 +28,9 @@ To create a trigger token:
1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your project.
1. Select **Settings > CI/CD**.
-1. Expand **Pipeline triggers**.
-1. Enter a description and select **Add trigger**.
+1. Expand **Pipeline trigger tokens**.
+1. Select **Add new token**
+1. Enter a description and select **Create pipeline trigger token**.
- You can view and copy the full token for all triggers you have created.
- You can only see the first 4 characters for tokens created by other project members.
diff --git a/doc/ci/variables/predefined_variables.md b/doc/ci/variables/predefined_variables.md
index 2cf6fe485ae..2d8994113a4 100644
--- a/doc/ci/variables/predefined_variables.md
+++ b/doc/ci/variables/predefined_variables.md
@@ -64,7 +64,7 @@ as it can cause the pipeline to behave unexpectedly.
| `CI_ENVIRONMENT_ACTION` | 13.11 | all | The action annotation specified for this job's environment. Available if [`environment:action`](../yaml/index.md#environmentaction) is set. Can be `start`, `prepare`, or `stop`. |
| `CI_ENVIRONMENT_TIER` | 14.0 | all | The [deployment tier of the environment](../environments/index.md#deployment-tier-of-environments) for this job. |
| `CI_RELEASE_DESCRIPTION` | 15.5 | all | The description of the release. Available only on pipelines for tags. Description length is limited to first 1024 characters.|
-| `CI_GITLAB_FIPS_MODE` | 14.10 | all | The configuration setting for whether FIPS mode is enabled in the GitLab instance. |
+| `CI_GITLAB_FIPS_MODE` | 14.10 | all | Only available if [FIPS mode](../../development/fips_compliance.md) is enabled in the GitLab instance. `true` when available. |
| `CI_HAS_OPEN_REQUIREMENTS` | 13.1 | all | Only available if the pipeline's project has an open [requirement](../../user/project/requirements/index.md). `true` when available. |
| `CI_JOB_ID` | 9.0 | all | The internal ID of the job, unique across all jobs in the GitLab instance. |
| `CI_JOB_IMAGE` | 12.9 | 12.9 | The name of the Docker image running the job. |
@@ -76,7 +76,7 @@ as it can cause the pipeline to behave unexpectedly.
| `CI_JOB_NAME_SLUG` | 15.4 | all | `CI_JOB_NAME_SLUG` in lowercase, shortened to 63 bytes, and with everything except `0-9` and `a-z` replaced with `-`. No leading / trailing `-`. Use in paths. |
| `CI_JOB_STAGE` | 9.0 | 0.5 | The name of the job's stage. |
| `CI_JOB_STATUS` | all | 13.5 | The status of the job as each runner stage is executed. Use with [`after_script`](../yaml/index.md#after_script). Can be `success`, `failed`, or `canceled`. |
-| `CI_JOB_TIMEOUT` | 15.7 | 15.7 | The job timeout value. |
+| `CI_JOB_TIMEOUT` | 15.7 | 15.7 | The job timeout, in seconds. |
| `CI_JOB_TOKEN` | 9.0 | 1.2 | A token to authenticate with [certain API endpoints](../jobs/ci_job_token.md). The token is valid as long as the job is running. |
| `CI_JOB_URL` | 11.1 | 0.5 | The job details URL. |
| `CI_JOB_STARTED_AT` | 13.10 | all | The UTC datetime when a job started, in [ISO 8601](https://www.rfc-editor.org/rfc/rfc3339#appendix-A) format. |
diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md
index 372a8980ba1..440068d55c2 100644
--- a/doc/development/api_graphql_styleguide.md
+++ b/doc/development/api_graphql_styleguide.md
@@ -47,6 +47,11 @@ The GraphQL framework has some specific gotchas to be aware of, and domain exper
If you are asked to review a merge request that modifies any GraphQL files or adds an endpoint, please have a look at
[our GraphQL review guide](graphql_guide/reviewing.md).
+## Reading GraphQL logs
+
+See the [Reading GraphQL logs](graphql_guide/monitoring.md) guide for tips on how to inspect logs
+of GraphQL requests and monitor the performance of your GraphQL queries.
+
## Authentication
Authentication happens through the `GraphqlController`, right now this
@@ -2404,7 +2409,3 @@ elimination of laziness, where needed.
For dealing with lazy values without forcing them, use
`Gitlab::Graphql::Lazy.with_value`.
-
-## Monitoring GraphQL
-
-See the [Monitoring GraphQL](graphql_guide/monitoring.md) guide for tips on how to inspect logs of GraphQL requests and monitor the performance of your GraphQL queries.
diff --git a/doc/development/graphql_guide/monitoring.md b/doc/development/graphql_guide/monitoring.md
index 566cc26ba1c..f92963dbdee 100644
--- a/doc/development/graphql_guide/monitoring.md
+++ b/doc/development/graphql_guide/monitoring.md
@@ -4,12 +4,7 @@ group: Import and Integrate
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
-# Monitoring GraphQL
-
-This page gives tips on how to analyze GraphQL data in our monitoring tools.
-Please contribute your own tips to improve this document.
-
-## Kibana
+# Reading GraphQL logs
We use Kibana to filter GraphQL query logs. Sign in to [Kibana](https://log.gprd.gitlab.net/)
with a `@gitlab.com` email address.
@@ -20,7 +15,7 @@ In Kibana we can inspect two kinds of GraphQL logs:
- Logs of the full request, which due to [query multiplexing](https://graphql-ruby.org/queries/multiplex.html)
may have executed multiple queries.
-### Logs of each GraphQL query
+## Logs of each GraphQL query
In a [multiplex query](https://graphql-ruby.org/queries/multiplex.html), each individual query
is logged separately. We can use subcomponent filtering to inspect these logs.
@@ -49,11 +44,11 @@ which already has a set of Kibana fields selected. Some relevant Kibana fields i
| `json.query_analysis.duration_s` | Duration of query execution in seconds. |
| `json.query_analysis.complexity` | The [complexity](../api_graphql_styleguide.md#max-complexity) score of the query. |
-#### Useful filters
+### Useful filters
Combine the [subcomponent filter](#logs-of-each-graphql-query) with the following Kibana filters to further interrogate the query logs.
-##### Queries that used a particular field
+#### Queries that used a particular field
Filter logs by queries that used a particular field:
@@ -64,13 +59,13 @@ Filter logs by queries that used a particular field:
appears in [our GraphQL API resources documentation](../../api/graphql/reference/index.md).
1. Select **Refresh**.
-##### Queries that used a deprecated field
+#### Queries that used a deprecated field
Filter logs of queries that used a particular deprecated field by following the
[steps above](#queries-that-used-a-particular-field) but use the `json.graphql.used_deprecated_fields`
filter instead.
-### Logs of the full request
+## Logs of the full request
The full request logs encompass log data for all [multiplexed queries](https://graphql-ruby.org/queries/multiplex.html)
in the request, as well as data from time spent outside of `GraphQLController#execute`.
diff --git a/doc/development/testing_guide/testing_levels.md b/doc/development/testing_guide/testing_levels.md
index 480a53bbefe..4e4dc671c03 100644
--- a/doc/development/testing_guide/testing_levels.md
+++ b/doc/development/testing_guide/testing_levels.md
@@ -197,16 +197,8 @@ graph RL
#### What to mock in component tests
-- **DOM**:
- Operating on the real DOM is significantly slower than on the virtual DOM.
-- **Properties and state of the component under test**:
- Similar to testing classes, modifying the properties directly (rather than relying on methods of the component) avoids side effects.
-- **Vuex store**:
- To avoid side effects and keep component tests simple, Vuex stores are replaced with mocks.
-- **All server requests**:
- Similar to unit tests, when running component tests, the backend may not be reachable, so all outgoing requests need to be mocked.
-- **Asynchronous background operations**:
- Similar to unit tests, background operations cannot be stopped or waited on. This means they continue running in the following tests and cause side effects.
+- **Side effects**:
+ Anything that can change external state (for example, a network request) should be mocked.
- **Child components**:
Every component is tested individually, so child components are mocked.
See also [`shallowMount()`](https://v1.test-utils.vuejs.org/api/#shallowmount)
@@ -215,8 +207,10 @@ graph RL
- **Methods or computed properties of the component under test**:
By mocking part of the component under test, the mocks are tested and not the real component.
-- **Functions and classes independent from Vue**:
- All plain JavaScript code is already covered by unit tests and needs not to be mocked in component tests.
+- **Vuex**:
+ Keep Vuex unmocked to avoid fragile and false-positive tests.
+ Set the Vuex to a proper state using mutations.
+ Mock the side-effects, not the Vuex actions.
## Integration tests
diff --git a/doc/update/versions/gitlab_16_changes.md b/doc/update/versions/gitlab_16_changes.md
index fcb09decceb..d86a6b1b612 100644
--- a/doc/update/versions/gitlab_16_changes.md
+++ b/doc/update/versions/gitlab_16_changes.md
@@ -11,6 +11,31 @@ Ensure you review these instructions and any specific instructions for your inst
For more information about upgrading GitLab Helm Chart, see [the release notes for 7.0](https://docs.gitlab.com/charts/releases/7_0.html).
+## 16.3.0
+
+### Linux package installations
+
+Specific information applies to Linux package installations:
+
+- In GitLab 16.0, we [announced](https://about.gitlab.com/releases/2023/05/22/gitlab-16-0-released/#omnibus-improvements) an upgraded base Docker image,
+ which has a new version of OpenSSH Server. An unintended consequence of the new version is that it disables accepting SSH RSA SHA-1 signatures by default. This issue should only
+ impact users using very outdated SSH clients.
+
+ To avoid problems with SHA-1 signatures being unavailable, users should update their SSH clients because using SHA-1 signatures is discouraged by the upstream library for security
+ reasons.
+
+ To allow for a transition period where users can't immediately upgrade their SSH clients, GitLab 16.3 and later has support for a `GITLAB_ALLOW_SHA1_RSA` environment variable in
+ the `Dockerfile`. If `GITLAB_ALLOW_SHA1_RSA` is set to `true`, this deprecated support is reactivated.
+
+ Because we want to foster security best practices and follow the upstream recommendation, this environment variable will only be available until GitLab 17.0, when we plan to
+ drop support for it.
+
+ For more information, see:
+
+ - [OpenSSH 8.8 release notes](https://www.openssh.com/txt/release-8.8).
+ - [An informal explanation](https://gitlab.com/gitlab-org/gitlab/-/issues/416714#note_1482388504).
+ - `omnibus-gitlab` [merge request 7035](https://gitlab.com/gitlab-org/omnibus-gitlab/-/merge_requests/7035), which introduces the environment variable.
+
## 16.2.0
- Legacy LDAP configuration settings may cause
diff --git a/doc/user/application_security/dependency_list/index.md b/doc/user/application_security/dependency_list/index.md
index 83fa4227946..72229bedf78 100644
--- a/doc/user/application_security/dependency_list/index.md
+++ b/doc/user/application_security/dependency_list/index.md
@@ -68,6 +68,9 @@ The dependency list shows the path between a dependency and a top-level dependen
to, if any. There are many possible paths connecting a transient dependency to top-level
dependencies, but the user interface shows only one of the shortest paths.
+NOTE:
+The dependency path is only displayed for dependencies that have vulnerabilities.
+
![Dependency path](img/yarn_dependency_path_v13_6.png)
Dependency paths are supported for the following package managers:
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index e3ee3271035..906969474d0 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -309,6 +309,7 @@ These public tools include:
- [Beeceptor](https://beeceptor.com) to create a temporary HTTPS endpoint and inspect incoming payloads
- [Webhook.site](https://webhook.site) to review incoming payloads
+- [Webhook Tester](https://webhook-test.com) to inspect and debug incoming payloads
### GitLab Development Kit (GDK)
diff --git a/doc/user/search/advanced_search.md b/doc/user/search/advanced_search.md
index 5a9f75f1d6c..9b398194c48 100644
--- a/doc/user/search/advanced_search.md
+++ b/doc/user/search/advanced_search.md
@@ -87,13 +87,17 @@ In user search, a [fuzzy query](https://www.elastic.co/guide/en/elasticsearch/re
## Known issues
- You can only search files smaller than 1 MB.
+ For more information, see [issue 195764](https://gitlab.com/gitlab-org/gitlab/-/issues/195764).
For self-managed GitLab instances, an administrator can
[change this limit](../../integration/advanced_search/elasticsearch.md#advanced-search-configuration).
- You can only use advanced search on the default branch of a project.
+ For more information, see [issue 229966](https://gitlab.com/gitlab-org/gitlab/-/issues/229966).
- The search query must not contain any of the following characters:
```plaintext
. , : ; / ` ' = ? $ & ^ | < > ( ) { } [ ] @
```
+ For more information, see [issue 325234](https://gitlab.com/gitlab-org/gitlab/-/issues/325234).
- Search results show only the first match in a file.
+ For more information, see [issue 668](https://gitlab.com/gitlab-org/gitlab/-/issues/668).
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 26d60a767e9..68f55630e60 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -676,9 +676,6 @@ msgstr ""
msgid "%{days} days until tags are automatically removed"
msgstr ""
-msgid "%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}."
-msgstr ""
-
msgid "%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}"
msgstr ""
@@ -2686,6 +2683,9 @@ msgstr ""
msgid "Active personal access tokens"
msgstr ""
+msgid "Active pipeline trigger tokens"
+msgstr ""
+
msgid "Active project access tokens"
msgstr ""
@@ -2911,6 +2911,9 @@ msgstr ""
msgid "Add new key"
msgstr ""
+msgid "Add new pipeline trigger token"
+msgstr ""
+
msgid "Add new token"
msgstr ""
@@ -2980,9 +2983,6 @@ msgstr ""
msgid "Add topics to projects to help users find them."
msgstr ""
-msgid "Add trigger"
-msgstr ""
-
msgid "Add variable"
msgstr ""
@@ -4852,9 +4852,6 @@ msgstr ""
msgid "Amazon EKS integration allows you to provision EKS clusters from GitLab."
msgstr ""
-msgid "Amazon Web Services Logo"
-msgstr ""
-
msgid "An %{link_start}alert%{link_end} with the same fingerprint is already open. To change the status of this alert, resolve the linked alert."
msgstr ""
@@ -10221,6 +10218,9 @@ msgstr ""
msgid "CiVariable|Define a CI/CD variable in the UI"
msgstr ""
+msgid "CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}"
+msgstr ""
+
msgid "CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query."
msgstr ""
@@ -10230,6 +10230,9 @@ msgstr ""
msgid "CiVariable|Search environments"
msgstr ""
+msgid "CiVariable|Use OIDC to securely connect to cloud services"
+msgstr ""
+
msgid "CiVariable|Variable %{key} has been deleted."
msgstr ""
@@ -13658,6 +13661,9 @@ msgstr ""
msgid "Create phone verification exemption"
msgstr ""
+msgid "Create pipeline trigger token"
+msgstr ""
+
msgid "Create project"
msgstr ""
@@ -16104,9 +16110,6 @@ msgstr ""
msgid "Deploying to"
msgstr ""
-msgid "Deploying to AWS is easy with GitLab"
-msgstr ""
-
msgid "Deployment"
msgstr ""
@@ -21751,9 +21754,15 @@ msgstr ""
msgid "GlobalSearch|in %{scope}"
msgstr ""
+msgid "GlobalShortcuts|Copied reference to clipboard."
+msgstr ""
+
msgid "GlobalShortcuts|Copied source branch name to clipboard."
msgstr ""
+msgid "GlobalShortcuts|Unable to copy the reference at this time."
+msgstr ""
+
msgid "GlobalShortcuts|Unable to copy the source branch name at this time."
msgstr ""
@@ -27576,6 +27585,9 @@ msgstr ""
msgid "Learn more about Needs relationships"
msgstr ""
+msgid "Learn more about Service Desk"
+msgstr ""
+
msgid "Learn more about Web Terminal"
msgstr ""
@@ -27588,9 +27600,6 @@ msgstr ""
msgid "Learn more about custom project templates"
msgstr ""
-msgid "Learn more about deploying to AWS"
-msgstr ""
-
msgid "Learn more about deploying to a cluster"
msgstr ""
@@ -28454,9 +28463,6 @@ msgstr ""
msgid "Manage usage"
msgstr ""
-msgid "Manage your project's triggers"
-msgstr ""
-
msgid "Manage your subscription"
msgstr ""
@@ -33948,6 +33954,9 @@ msgstr ""
msgid "Pipeline subscriptions trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project."
msgstr ""
+msgid "Pipeline trigger tokens"
+msgstr ""
+
msgid "Pipeline triggers"
msgstr ""
@@ -35451,6 +35460,9 @@ msgstr ""
msgid "Primary Action"
msgstr ""
+msgid "Print as PDF"
+msgstr ""
+
msgid "Print codes"
msgstr ""
@@ -43275,9 +43287,21 @@ msgstr ""
msgid "ServiceDesk|Cannot update custom email"
msgstr ""
+msgid "ServiceDesk|Common ports are 587 when using TLS, and 25 when not."
+msgstr ""
+
msgid "ServiceDesk|Configure a custom email address"
msgstr ""
+msgid "ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials."
+msgstr ""
+
+msgid "ServiceDesk|Copy Service Desk email address"
+msgstr ""
+
+msgid "ServiceDesk|Custom email address"
+msgstr ""
+
msgid "ServiceDesk|Custom email address could not be verified."
msgstr ""
@@ -43293,6 +43317,12 @@ msgstr ""
msgid "ServiceDesk|Custom email does not exist"
msgstr ""
+msgid "ServiceDesk|Custom email is required and must be a valid email address."
+msgstr ""
+
+msgid "ServiceDesk|Email address your customers can use to send support requests. It must support sub-addressing."
+msgstr ""
+
msgid "ServiceDesk|Enable Service Desk"
msgstr ""
@@ -43302,12 +43332,48 @@ msgstr ""
msgid "ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation."
msgstr ""
+msgid "ServiceDesk|Minimum 8 characters long."
+msgstr ""
+
msgid "ServiceDesk|Parameters missing"
msgstr ""
msgid "ServiceDesk|Please share your feedback on this feature in the %{linkStart}feedback issue%{linkEnd}"
msgstr ""
+msgid "ServiceDesk|SMTP address is required and must be resolvable."
+msgstr ""
+
+msgid "ServiceDesk|SMTP host"
+msgstr ""
+
+msgid "ServiceDesk|SMTP password"
+msgstr ""
+
+msgid "ServiceDesk|SMTP password is required and must be at least 8 characters long."
+msgstr ""
+
+msgid "ServiceDesk|SMTP port"
+msgstr ""
+
+msgid "ServiceDesk|SMTP port is required and must be a port number larger than 0."
+msgstr ""
+
+msgid "ServiceDesk|SMTP username"
+msgstr ""
+
+msgid "ServiceDesk|SMTP username is required."
+msgstr ""
+
+msgid "ServiceDesk|Save and test connection"
+msgstr ""
+
+msgid "ServiceDesk|Saved custom email address and started verification."
+msgstr ""
+
+msgid "ServiceDesk|Service Desk email address to forward emails to"
+msgstr ""
+
msgid "ServiceDesk|Service Desk is not enabled"
msgstr ""
@@ -47635,7 +47701,7 @@ msgstr ""
msgid "These dates affect how your epics appear in the roadmap. Set a fixed date or one inherited from the milestones assigned to issues in this epic."
msgstr ""
-msgid "These examples show how to trigger this project's pipeline for a branch or tag."
+msgid "These examples show common methods of triggering a pipeline with a pipeline trigger token. The URL and ID for this project is prefilled."
msgstr ""
msgid "These existing issues have a similar title. It might be better to comment there instead of creating another similar issue."
@@ -51650,6 +51716,9 @@ msgstr ""
msgid "View the latest successful deployment to this environment"
msgstr ""
+msgid "View trigger token usage examples"
+msgstr ""
+
msgid "View usage details"
msgstr ""
diff --git a/package.json b/package.json
index a768f677799..cc9924cc606 100644
--- a/package.json
+++ b/package.json
@@ -180,6 +180,7 @@
"popper.js": "^1.16.1",
"portal-vue": "^2.1.7",
"postcss": "8.4.14",
+ "print-js": "^1.6.0",
"prosemirror-markdown": "1.11.1",
"raphael": "^2.2.7",
"raw-loader": "^4.0.2",
diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb
index 923967c52c0..6aa7788b8e1 100644
--- a/spec/features/issues/service_desk_spec.rb
+++ b/spec/features/issues/service_desk_spec.rb
@@ -184,6 +184,42 @@ RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :service_des
stub_feature_flags(frontend_caching: true)
end
+ context 'when there are no issues' do
+ describe 'service desk empty state' do
+ it 'displays the large empty state, documentation, and the email address' do
+ visit service_desk_project_issues_path(project)
+
+ aggregate_failures do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_text('Use Service Desk to connect with your users')
+ expect(page).to have_link('Learn more about Service Desk', href: help_page_path('user/project/service_desk/index'))
+ expect(page).not_to have_link('Enable Service Desk')
+ expect(page).to have_content(project.service_desk_address)
+ end
+ end
+
+ context 'when user does not have permission to edit project settings' do
+ before do
+ user_2 = create(:user)
+
+ project.add_guest(user_2)
+ sign_in(user_2)
+ visit service_desk_project_issues_path(project)
+ end
+
+ it 'displays the large info box and the documentation link' do
+ aggregate_failures do
+ expect(page).to have_css('.empty-state')
+ expect(page).to have_text('Use Service Desk to connect with your users')
+ expect(page).to have_link('Learn more about Service Desk', href: help_page_path('user/project/service_desk/index'))
+ expect(page).not_to have_link('Enable Service Desk')
+ expect(page).not_to have_content(project.service_desk_address)
+ end
+ end
+ end
+ end
+ end
+
context 'when there are issues' do
let_it_be(:project) { create(:project, :private, service_desk_enabled: true) }
let_it_be(:other_user) { create(:user) }
@@ -197,7 +233,7 @@ RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :service_des
it 'displays the small info box, documentation, a button to configure service desk, and the address' do
aggregate_failures do
- expect(page).to have_link('Learn more', href: help_page_path('user/project/service_desk'))
+ expect(page).to have_link('Learn more about Service Desk', href: help_page_path('user/project/service_desk'))
expect(page).not_to have_link('Enable Service Desk')
expect(page).to have_content(project.service_desk_address)
end
diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb
index 903211ec250..52df4bfece2 100644
--- a/spec/features/triggers_spec.rb
+++ b/spec/features/triggers_spec.rb
@@ -26,16 +26,18 @@ RSpec.describe 'Triggers', :js, feature_category: :continuous_integration do
describe 'triggers page' do
describe 'create trigger workflow' do
it 'prevents adding new trigger with no description' do
+ click_button 'Add new token'
fill_in 'trigger_description', with: ''
- click_button 'Add trigger'
+ click_button 'Create pipeline trigger token'
# See if input has error due to empty value
expect(page.find('form.gl-show-field-errors .gl-field-error')).to be_visible
end
it 'adds new trigger with description' do
+ click_button 'Add new token'
fill_in 'trigger_description', with: 'trigger desc'
- click_button 'Add trigger'
+ click_button 'Create pipeline trigger token'
aggregate_failures 'display creation notice and trigger is created' do
expect(page.find('[data-testid="alert-info"]')).to have_content 'Trigger was created successfully.'
@@ -100,6 +102,7 @@ RSpec.describe 'Triggers', :js, feature_category: :continuous_integration do
describe 'show triggers workflow' do
it 'contains trigger description placeholder' do
+ click_button 'Add new token'
expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description'
end
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
index 445fb637076..7dce23f72c0 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlFormInput } from '@gitlab/ui';
+import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
@@ -10,6 +10,8 @@ import {
EVENT_LABEL,
EVENT_ACTION,
ENVIRONMENT_SCOPE_LINK_TITLE,
+ AWS_TIP_TITLE,
+ AWS_TIP_MESSAGE,
groupString,
instanceString,
projectString,
@@ -28,10 +30,6 @@ describe('Ci variable modal', () => {
const mockVariables = mockVariablesWithScopes(instanceString);
const defaultProvide = {
- awsLogoSvgPath: '/logo',
- awsTipCommandsLink: '/tips',
- awsTipDeployLink: '/deploy',
- awsTipLearnLink: '/learn-link',
containsVariableReferenceLink: '/reference',
environmentScopeLink: '/help/environments',
glFeatures: {
@@ -171,7 +169,7 @@ describe('Ci variable modal', () => {
it('does not show AWS guidance tip', () => {
const tip = findAWSTip();
- expect(tip.exists()).toBe(true);
+
expect(tip.isVisible()).toBe(false);
});
});
@@ -184,13 +182,18 @@ describe('Ci variable modal', () => {
key: AWS_ACCESS_KEY_ID,
value: 'AKIAIOSFODNN7EXAMPLEjdhy',
};
- createComponent({ mountFn: mountExtended, props: { selectedVariable: AWSKeyVariable } });
+ createComponent({
+ mountFn: shallowMountExtended,
+ props: { selectedVariable: AWSKeyVariable },
+ });
});
it('shows AWS guidance tip', () => {
const tip = findAWSTip();
- expect(tip.exists()).toBe(true);
+
expect(tip.isVisible()).toBe(true);
+ expect(tip.props('title')).toBe(AWS_TIP_TITLE);
+ expect(tip.findComponent(GlSprintf).attributes('message')).toBe(AWS_TIP_MESSAGE);
});
});
diff --git a/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
index f1df4208fa2..6ce86852095 100644
--- a/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
+++ b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
@@ -1,6 +1,7 @@
import { GlTable, GlBadge } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import TriggersList from '~/ci_settings_pipeline_triggers/components/triggers_list.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -25,17 +26,26 @@ describe('TriggersList', () => {
const findInvalidBadge = (i) => findCell(i, 0).findComponent(GlBadge);
const findEditBtn = (i) => findRowAt(i).find('[data-testid="edit-btn"]');
const findRevokeBtn = (i) => findRowAt(i).find('[data-testid="trigger_revoke_button"]');
- const findRevealHideButton = () => wrapper.findByTestId('reveal-hide-values-button');
+ const findRevealHideButton = () =>
+ document.querySelector('[data-testid="reveal-hide-values-button"]');
describe('With triggers set', () => {
beforeEach(async () => {
+ setHTMLFixture(`
+ <button data-testid="reveal-hide-values-button">Reveal values</button>
+ `);
+
createComponent();
await nextTick();
});
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('displays a table with expected headers', () => {
- const headers = ['Token', 'Description', 'Owner', 'Last Used', ''];
+ const headers = ['Token', 'Description', 'Owner', 'Last Used', 'Actions'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
@@ -44,16 +54,16 @@ describe('TriggersList', () => {
it('displays a "Reveal/Hide values" button', async () => {
const revealHideButton = findRevealHideButton();
- expect(revealHideButton.exists()).toBe(true);
- expect(revealHideButton.text()).toBe('Reveal values');
+ expect(Boolean(revealHideButton)).toBe(true);
+ expect(revealHideButton.innerText).toBe('Reveal values');
- await revealHideButton.vm.$emit('click');
+ await revealHideButton.click();
- expect(revealHideButton.text()).toBe('Hide values');
+ expect(revealHideButton.innerText).toBe('Hide values');
});
it('displays a table with rows', async () => {
- await findRevealHideButton().vm.$emit('click');
+ await findRevealHideButton().click();
expect(findRows()).toHaveLength(triggers.length);
diff --git a/spec/frontend/lib/print_markdown_dom_spec.js b/spec/frontend/lib/print_markdown_dom_spec.js
new file mode 100644
index 00000000000..7f28417228e
--- /dev/null
+++ b/spec/frontend/lib/print_markdown_dom_spec.js
@@ -0,0 +1,102 @@
+import printJS from 'print-js';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+jest.mock('print-js', () => jest.fn());
+
+describe('print util', () => {
+ describe('print markdown dom', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `<div id='target'></div>`;
+ });
+
+ const getTarget = () => document.getElementById('target');
+
+ const contentValues = [
+ {
+ title: 'test title',
+ expectedTitle: '<h2 class="gl-mt-0 gl-mb-5">test title</h2>',
+ content: '',
+ expectedContent: '<div class="md"></div>',
+ },
+ {
+ title: 'test title',
+ expectedTitle: '<h2 class="gl-mt-0 gl-mb-5">test title</h2>',
+ content: '<p>test content</p>',
+ expectedContent: '<div class="md"><p>test content</p></div>',
+ },
+ {
+ title: 'test title',
+ expectedTitle: '<h2 class="gl-mt-0 gl-mb-5">test title</h2>',
+ content: '<details><summary>test detail</summary><p>test detail content</p></details>',
+ expectedContent:
+ '<div class="md"><details open=""><summary>test detail</summary><p>test detail content</p></details></div>',
+ },
+ {
+ title: undefined,
+ expectedTitle: '',
+ content: '',
+ expectedContent: '<div class="md"></div>',
+ },
+ {
+ title: undefined,
+ expectedTitle: '',
+ content: '<p>test content</p>',
+ expectedContent: '<div class="md"><p>test content</p></div>',
+ },
+ {
+ title: undefined,
+ expectedTitle: '',
+ content: '<details><summary>test detail</summary><p>test detail content</p></details>',
+ expectedContent:
+ '<div class="md"><details open=""><summary>test detail</summary><p>test detail content</p></details></div>',
+ },
+ ];
+
+ it.each(contentValues)(
+ 'should print with title ($title) and content ($content)',
+ async ({ title, expectedTitle, content, expectedContent }) => {
+ const target = getTarget();
+ target.innerHTML = content;
+ const stylesheet = 'test stylesheet';
+
+ await printMarkdownDom({
+ target,
+ title,
+ stylesheet,
+ });
+
+ expect(printJS).toHaveBeenCalledWith({
+ printable: expectedTitle + expectedContent,
+ type: 'raw-html',
+ documentTitle: title,
+ scanStyles: false,
+ css: stylesheet,
+ });
+ },
+ );
+ });
+
+ describe('ignore selectors', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `<div id='target'><div><div class='ignore-me'></div></div></div>`;
+ });
+
+ it('should ignore dom if ignoreSelectors', async () => {
+ const target = document.getElementById('target');
+ const ignoreSelectors = ['.ignore-me'];
+
+ await printMarkdownDom({
+ target,
+ ignoreSelectors,
+ });
+
+ expect(printJS).toHaveBeenCalledWith({
+ printable: '<div class="md"><div></div></div>',
+ type: 'raw-html',
+ documentTitle: undefined,
+ scanStyles: false,
+ css: [],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js
new file mode 100644
index 00000000000..b7002412561
--- /dev/null
+++ b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js
@@ -0,0 +1,48 @@
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import WikiExport from '~/pages/shared/wikis/components/wiki_export.vue';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+jest.mock('~/lib/print_markdown_dom');
+
+describe('pages/shared/wikis/components/wiki_export', () => {
+ let wrapper;
+
+ const createComponent = (provide) => {
+ wrapper = shallowMount(WikiExport, {
+ provide,
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findPrintItem = () =>
+ findDropdown()
+ .props('items')
+ .find((x) => x.text === 'Print as PDF');
+
+ describe('print', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '<div id="content-body">Content</div>';
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('should print the content', () => {
+ createComponent({
+ target: '#content-body',
+ title: 'test title',
+ stylesheet: [],
+ });
+
+ findPrintItem().action();
+
+ expect(printMarkdownDom).toHaveBeenCalledWith({
+ target: document.querySelector('#content-body'),
+ title: 'test title',
+ stylesheet: [],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js
new file mode 100644
index 00000000000..6c5dcc3ff5c
--- /dev/null
+++ b/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js
@@ -0,0 +1,199 @@
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CustomEmailForm from '~/projects/settings_service_desk/components/custom_email_form.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE } from '~/projects/settings_service_desk/custom_email_constants';
+
+describe('CustomEmailForm', () => {
+ let wrapper;
+
+ const defaultProps = {
+ incomingEmail: 'incoming@example.com',
+ submitting: false,
+ };
+
+ const findForm = () => wrapper.find('form');
+ const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
+ const findInputByTestId = (testId) => wrapper.findByTestId(testId).find('input');
+ const findCustomEmailInput = () => findInputByTestId('form-custom-email');
+ const findSmtpAddressInput = () => findInputByTestId('form-smtp-address');
+ const findSmtpPortInput = () => findInputByTestId('form-smtp-port');
+ const findSmtpUsernameInput = () => findInputByTestId('form-smtp-username');
+ const findSmtpPasswordInput = () => findInputByTestId('form-smtp-password');
+ const findSubmit = () => wrapper.findByTestId('form-submit');
+
+ const clickButtonAndExpectNoSubmitEvent = async () => {
+ await nextTick();
+ findForm().trigger('submit');
+
+ expect(findSubmit().find('button').attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('submit')).toEqual(undefined);
+ };
+
+ const createWrapper = (props = {}) => {
+ wrapper = extendedWrapper(mount(CustomEmailForm, { propsData: { ...defaultProps, ...props } }));
+ };
+
+ it('renders a copy to clipboard button', () => {
+ createWrapper();
+
+ expect(findClipboardButton().exists()).toBe(true);
+ expect(findClipboardButton().props()).toEqual(
+ expect.objectContaining({
+ title: I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE,
+ text: defaultProps.incomingEmail,
+ }),
+ );
+ });
+
+ it('form inputs are disabled when submitting', () => {
+ createWrapper({ submitting: true });
+
+ expect(findCustomEmailInput().attributes('disabled')).toBeDefined();
+ expect(findSmtpAddressInput().attributes('disabled')).toBeDefined();
+ expect(findSmtpPortInput().attributes('disabled')).toBeDefined();
+ expect(findSmtpUsernameInput().attributes('disabled')).toBeDefined();
+ expect(findSmtpPasswordInput().attributes('disabled')).toBeDefined();
+ expect(findSubmit().props('loading')).toBe(true);
+ });
+
+ describe('form validation and submit event', () => {
+ it('is invalid when form inputs are empty', async () => {
+ createWrapper();
+
+ await nextTick();
+ findForm().trigger('submit');
+
+ expect(wrapper.emitted('submit')).toEqual(undefined);
+ });
+
+ describe('with inputs set', () => {
+ beforeEach(() => {
+ createWrapper();
+
+ findCustomEmailInput().setValue('user@example.com');
+ findCustomEmailInput().trigger('change');
+
+ findSmtpAddressInput().setValue('smtp.example.com');
+ findSmtpAddressInput().trigger('change');
+
+ findSmtpPortInput().setValue('587');
+ findSmtpPortInput().trigger('change');
+
+ findSmtpUsernameInput().setValue('user@example.com');
+ findSmtpUsernameInput().trigger('change');
+
+ findSmtpPasswordInput().setValue('supersecret');
+ findSmtpPasswordInput().trigger('change');
+ });
+
+ it('is invalid when malformed email provided', async () => {
+ findCustomEmailInput().setValue('userexample.com');
+ findCustomEmailInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findCustomEmailInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when email is not set', async () => {
+ findCustomEmailInput().setValue('');
+ findCustomEmailInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findCustomEmailInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when smtp address is not set', async () => {
+ findSmtpAddressInput().setValue('');
+ findSmtpAddressInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpAddressInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when smtp port is not set', async () => {
+ findSmtpPortInput().setValue('');
+ findSmtpPortInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpPortInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when smtp port is not an integer', async () => {
+ findSmtpPortInput().setValue('20m2');
+ findSmtpPortInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpPortInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when smtp port is 0', async () => {
+ findSmtpPortInput().setValue('0');
+ findSmtpPortInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpPortInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when smtp username is not set', async () => {
+ findSmtpUsernameInput().setValue('');
+ findSmtpUsernameInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpUsernameInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when password is too short', async () => {
+ findSmtpPasswordInput().setValue('2short');
+ findSmtpPasswordInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpPasswordInput().classes()).toContain('is-invalid');
+ });
+
+ it('is invalid when password is not set', async () => {
+ findSmtpPasswordInput().setValue('');
+ findSmtpPasswordInput().trigger('change');
+
+ await clickButtonAndExpectNoSubmitEvent();
+ expect(findSmtpPasswordInput().classes()).toContain('is-invalid');
+ });
+
+ it('sets smtpUsername automatically when empty based on customEmail', async () => {
+ const email = 'support@example.com';
+
+ findSmtpUsernameInput().setValue('');
+ findSmtpUsernameInput().trigger('change');
+
+ findCustomEmailInput().setValue(email);
+ findCustomEmailInput().trigger('change');
+
+ await nextTick();
+
+ expect(findSmtpUsernameInput().element.value).toBe(email);
+ expect(wrapper.html()).not.toContain('is-invalid');
+ });
+
+ it('is valid and emits submit event with form data', async () => {
+ await nextTick();
+
+ expect(wrapper.html()).not.toContain('is-invalid');
+
+ findForm().trigger('submit');
+
+ expect(wrapper.emitted('submit')).toEqual([
+ [
+ {
+ custom_email: 'user@example.com',
+ smtp_address: 'smtp.example.com',
+ smtp_password: 'supersecret',
+ smtp_port: '587',
+ smtp_username: 'user@example.com',
+ },
+ ],
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js
index f167d2e9d6e..4517508f5df 100644
--- a/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js
@@ -7,11 +7,17 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { HTTP_STATUS_OK, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import CustomEmail from '~/projects/settings_service_desk/components/custom_email.vue';
+import CustomEmailForm from '~/projects/settings_service_desk/components/custom_email_form.vue';
import {
FEEDBACK_ISSUE_URL,
I18N_GENERIC_ERROR,
+ I18N_TOAST_SAVED,
} from '~/projects/settings_service_desk/custom_email_constants';
-import { MOCK_CUSTOM_EMAIL_EMPTY } from './mock_data';
+import {
+ MOCK_CUSTOM_EMAIL_EMPTY,
+ MOCK_CUSTOM_EMAIL_STARTED,
+ MOCK_CUSTOM_EMAIL_FORM_SUBMIT,
+} from './mock_data';
describe('CustomEmail', () => {
let axiosMock;
@@ -22,10 +28,17 @@ describe('CustomEmail', () => {
customEmailEndpoint: '/flightjs/Flight/-/service_desk/custom_email',
};
+ const showToast = jest.fn();
+
const createWrapper = (props = {}) => {
wrapper = extendedWrapper(
mount(CustomEmail, {
propsData: { ...defaultProps, ...props },
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
}),
);
};
@@ -40,6 +53,7 @@ describe('CustomEmail', () => {
afterEach(() => {
axiosMock.restore();
+ showToast.mockReset();
});
it('displays link to feedback issue', () => {
@@ -52,7 +66,7 @@ describe('CustomEmail', () => {
beforeEach(() => {
axiosMock
.onGet(defaultProps.customEmailEndpoint)
- .reply(HTTP_STATUS_OK, MOCK_CUSTOM_EMAIL_EMPTY);
+ .replyOnce(HTTP_STATUS_OK, MOCK_CUSTOM_EMAIL_EMPTY);
createWrapper();
});
@@ -64,6 +78,40 @@ describe('CustomEmail', () => {
// loading completed
expect(findLoadingIcon().exists()).toBe(false);
});
+
+ it('displays form', async () => {
+ await waitForPromises();
+
+ expect(wrapper.findComponent(CustomEmailForm).exists()).toBe(true);
+ });
+
+ describe('when CustomEmailForm emits submit event with valid params', () => {
+ beforeEach(() => {
+ axiosMock
+ .onPost(defaultProps.customEmailEndpoint)
+ .replyOnce(HTTP_STATUS_OK, MOCK_CUSTOM_EMAIL_STARTED);
+ });
+
+ it('creates custom email', async () => {
+ createWrapper();
+ await nextTick();
+
+ const spy = jest.spyOn(axios, 'post');
+
+ wrapper.findComponent(CustomEmailForm).vm.$emit('submit', MOCK_CUSTOM_EMAIL_FORM_SUBMIT);
+
+ expect(wrapper.findComponent(CustomEmailForm).emitted('submit')).toEqual([
+ [MOCK_CUSTOM_EMAIL_FORM_SUBMIT],
+ ]);
+ await waitForPromises();
+
+ expect(spy).toHaveBeenCalledWith(
+ defaultProps.customEmailEndpoint,
+ MOCK_CUSTOM_EMAIL_FORM_SUBMIT,
+ );
+ expect(showToast).toHaveBeenCalledWith(I18N_TOAST_SAVED);
+ });
+ });
});
describe('when initial resource loading returns 404', () => {
diff --git a/spec/frontend/projects/settings_service_desk/components/mock_data.js b/spec/frontend/projects/settings_service_desk/components/mock_data.js
index ea88a6cfccd..87fbd354041 100644
--- a/spec/frontend/projects/settings_service_desk/components/mock_data.js
+++ b/spec/frontend/projects/settings_service_desk/components/mock_data.js
@@ -15,3 +15,20 @@ export const MOCK_CUSTOM_EMAIL_EMPTY = {
custom_email_smtp_address: null,
error_message: null,
};
+
+export const MOCK_CUSTOM_EMAIL_STARTED = {
+ custom_email: 'user@example.com',
+ custom_email_enabled: false,
+ custom_email_verification_state: 'started',
+ custom_email_verification_error: null,
+ custom_email_smtp_address: 'smtp.example.com',
+ error_message: null,
+};
+
+export const MOCK_CUSTOM_EMAIL_FORM_SUBMIT = {
+ custom_email: 'user@example.com',
+ smtp_address: 'smtp.example.com',
+ smtp_password: 'supersecret',
+ smtp_port: '587',
+ smtp_username: 'user@example.com',
+};
diff --git a/spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js b/spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js
new file mode 100644
index 00000000000..ce8a78767d4
--- /dev/null
+++ b/spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js
@@ -0,0 +1,74 @@
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import EmptyStateWithAnyIssues from '~/service_desk/components/empty_state_with_any_issues.vue';
+import {
+ noSearchResultsTitle,
+ noSearchResultsDescription,
+ infoBannerUserNote,
+ noOpenIssuesTitle,
+ noClosedIssuesTitle,
+} from '~/service_desk/constants';
+
+describe('EmptyStateWithAnyIssues component', () => {
+ let wrapper;
+
+ const defaultProvide = {
+ emptyStateSvgPath: 'empty/state/svg/path',
+ newIssuePath: 'new/issue/path',
+ showNewIssueLink: false,
+ };
+
+ const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ const mountComponent = (props = {}) => {
+ wrapper = shallowMount(EmptyStateWithAnyIssues, {
+ propsData: {
+ hasSearch: true,
+ isOpenTab: true,
+ ...props,
+ },
+ provide: defaultProvide,
+ });
+ };
+
+ describe('when there is a search (with no results)', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: noSearchResultsDescription,
+ title: noSearchResultsTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+
+ describe('when "Open" tab is active', () => {
+ beforeEach(() => {
+ mountComponent({ hasSearch: false });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: infoBannerUserNote,
+ title: noOpenIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+
+ describe('when "Closed" tab is active', () => {
+ beforeEach(() => {
+ mountComponent({ hasSearch: false, isClosedTab: true, isOpenTab: false });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: noClosedIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js b/spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js
new file mode 100644
index 00000000000..bf4951c7310
--- /dev/null
+++ b/spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js
@@ -0,0 +1,89 @@
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import EmptyStateWithoutAnyIssues from '~/service_desk/components/empty_state_without_any_issues.vue';
+import { infoBannerTitle, noIssuesSignedOutButtonText, learnMore } from '~/service_desk/constants';
+
+describe('EmptyStateWithoutAnyIssues component', () => {
+ let wrapper;
+
+ const defaultProvide = {
+ emptyStateSvgPath: 'empty/state/svg/path',
+ isSignedIn: true,
+ signInPath: 'sign/in/path',
+ canAdminIssues: true,
+ isServiceDeskEnabled: true,
+ serviceDeskEmailAddress: 'email@address.com',
+ };
+
+ const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findGlLink = () => wrapper.findComponent(GlLink);
+ const findIssuesHelpPageLink = () => wrapper.findByRole('link', { name: learnMore });
+
+ const mountComponent = ({ provide = {} } = {}) => {
+ wrapper = mountExtended(EmptyStateWithoutAnyIssues, {
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ });
+ };
+
+ describe('when signed in', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: infoBannerTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ contentClass: 'gl-max-w-80!',
+ });
+ });
+
+ it('renders description with service desk docs link', () => {
+ expect(findIssuesHelpPageLink().attributes('href')).toBe(
+ EmptyStateWithoutAnyIssues.serviceDeskHelpPagePath,
+ );
+ });
+
+ it('renders email address, when user can admin issues and service desk is enabled', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress);
+ });
+
+ it('does not render email address, when user can not admin issues', () => {
+ mountComponent({ provide: { canAdminIssues: false } });
+
+ expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress);
+ });
+
+ it('does not render email address, when service desk is not setup', () => {
+ mountComponent({ provide: { isServiceDeskEnabled: false } });
+
+ expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress);
+ });
+ });
+
+ describe('when signed out', () => {
+ beforeEach(() => {
+ mountComponent({ provide: { isSignedIn: false } });
+ });
+
+ it('renders empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: infoBannerTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ primaryButtonText: noIssuesSignedOutButtonText,
+ primaryButtonLink: defaultProvide.signInPath,
+ contentClass: 'gl-max-w-80!',
+ });
+ });
+
+ it('renders service desk docs link', () => {
+ expect(findGlLink().attributes('href')).toBe(
+ EmptyStateWithoutAnyIssues.serviceDeskHelpPagePath,
+ );
+ expect(findGlLink().text()).toBe(learnMore);
+ });
+ });
+});
diff --git a/spec/frontend/service_desk/components/service_desk_list_app_spec.js b/spec/frontend/service_desk/components/service_desk_list_app_spec.js
index 0a7b2376db7..5c3b7095447 100644
--- a/spec/frontend/service_desk/components/service_desk_list_app_spec.js
+++ b/spec/frontend/service_desk/components/service_desk_list_app_spec.js
@@ -17,6 +17,9 @@ import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_servi
import getServiceDeskIssuesCountsQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql';
import ServiceDeskListApp from '~/service_desk/components/service_desk_list_app.vue';
import InfoBanner from '~/service_desk/components/info_banner.vue';
+import EmptyStateWithAnyIssues from '~/service_desk/components/empty_state_with_any_issues.vue';
+import EmptyStateWithoutAnyIssues from '~/service_desk/components/empty_state_without_any_issues.vue';
+
import {
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
@@ -29,6 +32,7 @@ import {
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
getServiceDeskIssuesQueryResponse,
+ getServiceDeskIssuesQueryEmptyResponse,
getServiceDeskIssuesCountsQueryResponse,
filteredTokens,
urlParams,
@@ -70,6 +74,9 @@ describe('CE ServiceDeskListApp', () => {
const mockServiceDeskIssuesQueryResponseHandler = jest
.fn()
.mockResolvedValue(defaultQueryResponse);
+ const mockServiceDeskIssuesQueryEmptyResponseHandler = jest
+ .fn()
+ .mockResolvedValue(getServiceDeskIssuesQueryEmptyResponse);
const mockServiceDeskIssuesCountsQueryResponseHandler = jest
.fn()
.mockResolvedValue(getServiceDeskIssuesCountsQueryResponse);
@@ -143,21 +150,48 @@ describe('CE ServiceDeskListApp', () => {
expect(findInfoBanner().exists()).toBe(true);
});
- it('does not render when Service Desk is not supported and has any number of issues', async () => {
+ it('does not render when Service Desk is not supported and has any number of issues', () => {
wrapper = createComponent({ provide: { isServiceDeskSupported: false } });
- await waitForPromises();
expect(findInfoBanner().exists()).toBe(false);
});
- it('does not render, when there are no issues', async () => {
- wrapper = createComponent({ provide: { hasAnyIssues: false } });
- await waitForPromises();
+ it('does not render, when there are no issues', () => {
+ wrapper = createComponent({
+ serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler,
+ });
expect(findInfoBanner().exists()).toBe(false);
});
});
+ describe('Empty states', () => {
+ describe('when there are issues', () => {
+ it('shows EmptyStateWithAnyIssues component', () => {
+ setWindowLocation(locationSearch);
+ wrapper = createComponent({
+ serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler,
+ });
+
+ expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({
+ hasSearch: true,
+ isOpenTab: true,
+ });
+ });
+ });
+
+ describe('when there are no issues', () => {
+ it('shows EmptyStateWithoutAnyIssues component', () => {
+ wrapper = createComponent({
+ provide: { hasAnyIssues: false },
+ serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler,
+ });
+
+ expect(wrapper.findComponent(EmptyStateWithoutAnyIssues).exists()).toBe(true);
+ });
+ });
+ });
+
describe('Initial url params', () => {
describe('search', () => {
it('is set from the url params', () => {
@@ -169,10 +203,11 @@ describe('CE ServiceDeskListApp', () => {
});
describe('state', () => {
- it('is set from the url params', () => {
+ it('is set from the url params', async () => {
const initialState = STATUS_ALL;
setWindowLocation(`?state=${initialState}`);
wrapper = createComponent();
+ await waitForPromises();
expect(findIssuableList().props('currentTab')).toBe(initialState);
});
@@ -199,6 +234,7 @@ describe('CE ServiceDeskListApp', () => {
describe('when user is signed out', () => {
beforeEach(() => {
wrapper = createComponent({ provide: { isSignedIn: false } });
+ return waitForPromises();
});
it('does not render My-Reaction or Confidential tokens', () => {
@@ -221,6 +257,7 @@ describe('CE ServiceDeskListApp', () => {
};
wrapper = createComponent();
+ return waitForPromises();
});
it('renders all tokens alphabetically', () => {
@@ -243,9 +280,10 @@ describe('CE ServiceDeskListApp', () => {
describe('Events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
- beforeEach(() => {
+ beforeEach(async () => {
wrapper = createComponent();
router.push = jest.fn();
+ await waitForPromises();
findIssuableList().vm.$emit('click-tab', STATUS_CLOSED);
});
@@ -265,6 +303,7 @@ describe('CE ServiceDeskListApp', () => {
it('updates IssuableList with url params', async () => {
wrapper = createComponent();
router.push = jest.fn();
+ await waitForPromises();
findIssuableList().vm.$emit('filter', filteredTokens);
await nextTick();
@@ -278,10 +317,10 @@ describe('CE ServiceDeskListApp', () => {
describe('Errors', () => {
describe.each`
- error | responseHandler | message
- ${'fetching issues'} | ${'serviceDeskIssuesQueryResponseHandler'} | ${ServiceDeskListApp.i18n.errorFetchingIssues}
- ${'fetching issue counts'} | ${'serviceDeskIssuesCountsQueryResponseHandler'} | ${ServiceDeskListApp.i18n.errorFetchingCounts}
- `('when there is an error $error', ({ responseHandler, message }) => {
+ error | responseHandler
+ ${'fetching issues'} | ${'serviceDeskIssuesQueryResponseHandler'}
+ ${'fetching issue counts'} | ${'serviceDeskIssuesCountsQueryResponseHandler'}
+ `('when there is an error $error', ({ responseHandler }) => {
beforeEach(() => {
wrapper = createComponent({
[responseHandler]: jest.fn().mockRejectedValue(new Error('ERROR')),
@@ -290,14 +329,13 @@ describe('CE ServiceDeskListApp', () => {
});
it('shows an error message', () => {
- expect(findIssuableList().props('error')).toBe(message);
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR'));
});
});
});
describe('When providing token for labels', () => {
- it('passes function to fetchLatestLabels property if frontend caching is enabled', () => {
+ it('passes function to fetchLatestLabels property if frontend caching is enabled', async () => {
wrapper = createComponent({
provide: {
glFeatures: {
@@ -305,11 +343,12 @@ describe('CE ServiceDeskListApp', () => {
},
},
});
+ await waitForPromises();
expect(typeof findLabelsToken().fetchLatestLabels).toBe('function');
});
- it('passes null to fetchLatestLabels property if frontend caching is disabled', () => {
+ it('passes null to fetchLatestLabels property if frontend caching is disabled', async () => {
wrapper = createComponent({
provide: {
glFeatures: {
@@ -317,6 +356,7 @@ describe('CE ServiceDeskListApp', () => {
},
},
});
+ await waitForPromises();
expect(findLabelsToken().fetchLatestLabels).toBe(null);
});
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_trigger/create_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_trigger/create_spec.rb
new file mode 100644
index 00000000000..1af12d51e1e
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_trigger/create_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'PipelineTriggerCreate', feature_category: :continuous_integration do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:project) { create(:project) }
+
+ let(:mutation) { graphql_mutation(:pipeline_trigger_create, params) }
+ let(:project_path) { project.full_path }
+ let(:description) { 'Ye old pipeline trigger token' }
+
+ let(:params) do
+ {
+ project_path: project_path,
+ description: description
+ }
+ end
+
+ subject { post_graphql_mutation(mutation, current_user: user) }
+
+ context 'when unauthorized' do
+ it 'returns an error' do
+ subject
+
+ expect(graphql_errors).not_to be_empty
+ expect(graphql_errors[0]['message'])
+ .to eq(
+ "The resource that you are attempting to access does not exist " \
+ "or you don't have permission to perform this action"
+ )
+ end
+ end
+
+ context 'when authorized' do
+ before_all do
+ project.add_owner(user)
+ end
+
+ context 'when the params are invalid' do
+ let(:description) { nil }
+
+ it 'does not create a pipeline trigger token and returns an error' do
+ expect { subject }.not_to change { project.triggers.count }
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors.to_s).to include('provided invalid value for description (Expected value to not be null)')
+ end
+ end
+
+ context 'when the params are valid' do
+ it 'creates a pipeline trigger token' do
+ expect { subject }.to change { project.triggers.count }.by(1)
+ expect(graphql_errors.to_s).to eql("")
+ end
+
+ it 'returns the new pipeline trigger token' do
+ subject
+
+ expect(graphql_data_at(:pipeline_trigger_create, :pipeline_trigger)).to match a_hash_including(
+ 'owner' => a_hash_including(
+ 'id' => user.to_global_id.to_s,
+ 'username' => user.username,
+ 'name' => user.name
+ ),
+ 'description' => description,
+ "canAccessProject" => true,
+ "hasTokenExposed" => true,
+ "lastUsed" => nil
+ )
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index a5151925c52..bfcc5a565c7 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe Issues::UpdateService, :mailer, feature_category: :team_planning
let_it_be(:project, reload: true) { create(:project, :repository, group: group) }
let_it_be(:label) { create(:label, title: 'a', project: project) }
let_it_be(:label2) { create(:label, title: 'b', project: project) }
+ let_it_be(:label3) { create(:label, title: 'c', project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let(:issue) do
@@ -992,75 +993,17 @@ RSpec.describe Issues::UpdateService, :mailer, feature_category: :team_planning
end
context 'updating labels' do
- let(:label3) { create(:label, project: project) }
- let(:result) { described_class.new(container: project, current_user: user, params: params).execute(issue).reload }
-
- context 'when add_label_ids and label_ids are passed' do
- let(:params) { { label_ids: [label.id], add_label_ids: [label3.id] } }
-
- before do
- issue.update!(labels: [label2])
- end
-
- it 'replaces the labels with the ones in label_ids and adds those in add_label_ids' do
- expect(result.label_ids).to contain_exactly(label.id, label3.id)
- end
- end
-
- context 'when remove_label_ids and label_ids are passed' do
- let(:params) { { label_ids: [label.id, label2.id, label3.id], remove_label_ids: [label.id] } }
-
- before do
- issue.update!(labels: [label, label3])
- end
-
- it 'replaces the labels with the ones in label_ids and removes those in remove_label_ids' do
- expect(result.label_ids).to contain_exactly(label2.id, label3.id)
- end
- end
-
- context 'when add_label_ids and remove_label_ids are passed' do
- let(:params) { { add_label_ids: [label3.id], remove_label_ids: [label.id] } }
-
- before do
- issue.update!(labels: [label])
- end
-
- it 'adds the passed labels' do
- expect(result.label_ids).to include(label3.id)
- end
-
- it 'removes the passed labels' do
- expect(result.label_ids).not_to include(label.id)
- end
- end
-
- context 'when same id is passed as add_label_ids and remove_label_ids' do
- let(:params) { { add_label_ids: [label.id], remove_label_ids: [label.id] } }
-
- context 'for a label assigned to an issue' do
- it 'removes the label' do
- issue.update!(labels: [label])
-
- expect(result.label_ids).to be_empty
- end
- end
-
- context 'for a label not assigned to an issue' do
- it 'does not add the label' do
- expect(result.label_ids).to be_empty
- end
- end
- end
+ let(:label_a) { label }
+ let(:label_b) { label2 }
+ let(:label_c) { label3 }
+ let(:issuable) { issue }
- context 'when duplicate label titles are given' do
- let(:params) do
- { labels: [label3.title, label3.title] }
- end
+ it_behaves_like 'updating issuable labels'
+ it_behaves_like 'keeps issuable labels sorted after update'
+ it_behaves_like 'broadcasting issuable labels updates'
- it 'assigns the label once' do
- expect(result.labels).to contain_exactly(label3)
- end
+ def update_issuable(update_params)
+ update_issue(update_params)
end
end
@@ -1513,19 +1456,6 @@ RSpec.describe Issues::UpdateService, :mailer, feature_category: :team_planning
end
end
- context 'labels are updated' do
- let(:label_a) { label }
- let(:label_b) { label2 }
- let(:issuable) { issue }
-
- it_behaves_like 'keeps issuable labels sorted after update'
- it_behaves_like 'broadcasting issuable labels updates'
-
- def update_issuable(update_params)
- update_issue(update_params)
- end
- end
-
it_behaves_like 'issuable record that supports quick actions' do
let(:existing_issue) { create(:issue, project: project) }
let(:issuable) { described_class.new(container: project, current_user: user, params: params).execute(existing_issue) }
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index 79f608a4614..7e0bd340fcd 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -1300,11 +1300,13 @@ RSpec.describe MergeRequests::UpdateService, :mailer, feature_category: :code_re
let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_merge_request) }
end
- context 'labels are updated' do
+ context 'updating labels' do
let(:label_a) { label }
let(:label_b) { create(:label, title: 'b', project: project) }
+ let(:label_c) { create(:label, title: 'c', project: project) }
let(:issuable) { merge_request }
+ it_behaves_like 'updating issuable labels'
it_behaves_like 'keeps issuable labels sorted after update'
it_behaves_like 'broadcasting issuable labels updates'
diff --git a/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb b/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb
index 0a07c9d677b..187c0b3ab43 100644
--- a/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/has_repository_shared_examples.rb
@@ -162,10 +162,24 @@ RSpec.shared_examples 'model with repository' do
end
describe '#after_repository_change_head' do
+ let(:event) { instance_double('Repositories::DefaultBranchChangedEvent') }
+ let(:event_data) { { container_id: stubbed_container.id, container_type: stubbed_container.class.name } }
+
it 'calls #reload_default_branch' do
expect(stubbed_container).to receive(:reload_default_branch)
stubbed_container.after_repository_change_head
end
+
+ it 'publishes an Repositories::DefaultBranchChangedEvent event' do
+ allow(Repositories::DefaultBranchChangedEvent)
+ .to receive(:new)
+ .with(data: event_data)
+ .and_return(event)
+
+ expect(Gitlab::EventStore).to receive(:publish).with(event).once
+
+ stubbed_container.after_repository_change_head
+ end
end
end
diff --git a/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb b/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb
index 85a05bbe56d..77e4f9a98bb 100644
--- a/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable/issuable_update_service_shared_examples.rb
@@ -64,6 +64,76 @@ RSpec.shared_examples 'issuable update service' do
end
end
+RSpec.shared_examples 'updating issuable labels' do
+ context 'when add_label_ids and label_ids are passed' do
+ let(:params) { { label_ids: [label_a.id], add_label_ids: [label_c.id] } }
+
+ it 'replaces the labels with the ones in label_ids and adds those in add_label_ids' do
+ issuable.update!(labels: [label_b])
+ update_issuable(params)
+
+ expect(issuable.label_ids).to contain_exactly(label_a.id, label_c.id)
+ end
+ end
+
+ context 'when remove_label_ids and label_ids are passed' do
+ let(:params) { { label_ids: [label_a.id, label_b.id, label_c.id], remove_label_ids: [label_a.id] } }
+
+ it 'replaces the labels with the ones in label_ids and removes those in remove_label_ids' do
+ issuable.update!(labels: [label_a, label_c])
+ update_issuable(params)
+
+ expect(issuable.label_ids).to contain_exactly(label_b.id, label_c.id)
+ end
+ end
+
+ context 'when add_label_ids and remove_label_ids are passed' do
+ let(:params) { { add_label_ids: [label_c.id], remove_label_ids: [label_a.id] } }
+
+ before do
+ issuable.update!(labels: [label_a])
+ update_issuable(params)
+ end
+
+ it 'adds the passed labels' do
+ expect(issuable.label_ids).to include(label_c.id)
+ end
+
+ it 'removes the passed labels' do
+ expect(issuable.label_ids).not_to include(label_a.id)
+ end
+ end
+
+ context 'when same id is passed as add_label_ids and remove_label_ids' do
+ let(:params) { { add_label_ids: [label_a.id], remove_label_ids: [label_a.id] } }
+
+ context 'for a label assigned to an issue' do
+ it 'removes the label' do
+ issuable.update!(labels: [label_a])
+ update_issuable(params)
+
+ expect(issuable.label_ids).to be_empty
+ end
+ end
+
+ context 'for a label not assigned to an issue' do
+ it 'does not add the label' do
+ expect(issuable.label_ids).to be_empty
+ end
+ end
+ end
+
+ context 'when duplicate label titles are given' do
+ let(:params) { { labels: [label_c.title, label_c.title] } }
+
+ it 'assigns the label once' do
+ update_issuable(params)
+
+ expect(issuable.labels).to contain_exactly(label_c)
+ end
+ end
+end
+
RSpec.shared_examples 'keeps issuable labels sorted after update' do
before do
update_issuable(label_ids: [label_b.id])
diff --git a/yarn.lock b/yarn.lock
index 2beff3c1b74..8cd3137a593 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10487,6 +10487,11 @@ pretty@^2.0.0:
extend-shallow "^2.0.1"
js-beautify "^1.6.12"
+print-js@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/print-js/-/print-js-1.6.0.tgz#692b046cf31992b46afa6c6d8a9db1c69d431d1f"
+ integrity sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==
+
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"