Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-10-04 06:10:22 +00:00
parent 7feabd8d8e
commit b502933498
36 changed files with 1250 additions and 232 deletions

View File

@ -0,0 +1,50 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { getGlobalAlerts, setGlobalAlerts, removeGlobalAlertById } from '~/lib/utils/global_alerts';
export default {
name: 'GlobalAlerts',
components: { GlAlert },
data() {
return {
alerts: [],
};
},
mounted() {
const { page } = document.body.dataset;
const alerts = getGlobalAlerts();
const alertsToPersist = alerts.filter((alert) => alert.persistOnPages.length);
const alertsToRender = alerts.filter(
(alert) => alert.persistOnPages.length === 0 || alert.persistOnPages.includes(page),
);
this.alerts = alertsToRender;
// Once we render the global alerts, we re-set the global alerts to only store persistent alerts for the next load.
setGlobalAlerts(alertsToPersist);
},
methods: {
onDismiss(index) {
const alert = this.alerts[index];
this.alerts.splice(index, 1);
removeGlobalAlertById(alert.id);
},
},
};
</script>
<template>
<div v-if="alerts.length">
<gl-alert
v-for="(alert, index) in alerts"
:key="alert.id"
:variant="alert.variant"
:title="alert.title"
:dismissible="alert.dismissible"
@dismiss="onDismiss(index)"
>{{ alert.message }}</gl-alert
>
</div>
</template>

View File

@ -0,0 +1,17 @@
import Vue from 'vue';
import GlobalAlerts from './components/global_alerts.vue';
export const initGlobalAlerts = () => {
const el = document.getElementById('js-global-alerts');
if (!el) return false;
return new Vue({
el,
name: 'GlobalAlertsRoot',
render(createElement) {
return createElement(GlobalAlerts);
},
});
};

View File

@ -9,6 +9,7 @@ import './quick_submit';
import './requires_input';
import initPageShortcuts from './shortcuts';
import { initToastMessages } from './toasts';
import { initGlobalAlerts } from './global_alerts';
import './toggler_behavior';
import './preview_markdown';
@ -24,6 +25,8 @@ initCollapseSidebarOnWindowResize();
initToastMessages();
initGlobalAlerts();
window.requestIdleCallback(
() => {
// Check if we have to Load GFM Input

View File

@ -36,6 +36,7 @@ export default {
},
data() {
return {
customEnvScope: null,
isDropdownShown: false,
selectedEnvironment: '',
searchTerm: '',
@ -68,13 +69,20 @@ export default {
filtered = uniq([...filtered, '*']);
}
// add custom env scope if it matches the search term
if (this.customEnvScope && this.customEnvScope.startsWith(this.searchTerm)) {
filtered = uniq([...filtered, this.customEnvScope]);
}
return filtered.sort().map((environment) => ({
value: environment,
text: environment,
}));
},
shouldRenderCreateButton() {
return this.searchTerm && !this.environments.includes(this.searchTerm);
return (
this.searchTerm && ![...this.environments, this.customEnvScope].includes(this.searchTerm)
);
},
shouldRenderDivider() {
return (
@ -98,7 +106,7 @@ export default {
this.selectedEnvironment = selected;
},
createEnvironmentScope() {
this.$emit('create-environment-scope', this.searchTerm);
this.customEnvScope = this.searchTerm;
this.selectEnvironment(this.searchTerm);
},
toggleDropdownShown(isShown) {

View File

@ -11,9 +11,11 @@ import {
GlFormTextarea,
GlIcon,
GlLink,
GlModal,
GlModalDirective,
GlSprintf,
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { __, s__, sprintf } from '~/locale';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
@ -36,10 +38,11 @@ import { awsTokenList } from './ci_variable_autocomplete_tokens';
const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL });
export const i18n = {
addVariable: s__('CiVariables|Add Variable'),
addVariable: s__('CiVariables|Add variable'),
cancel: __('Cancel'),
defaultScope: allEnvironments.text,
editVariable: s__('CiVariables|Edit Variable'),
deleteVariable: s__('CiVariables|Delete variable'),
editVariable: s__('CiVariables|Edit variable'),
environments: __('Environments'),
environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
expandedField: s__('CiVariables|Expand variable reference'),
@ -51,6 +54,7 @@ export const i18n = {
maskedDescription: s__(
'CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements.',
),
modalDeleteMessage: s__('CiVariables|Do you want to delete the variable %{key}?'),
protectedField: s__('CiVariables|Protect variable'),
protectedDescription: s__(
'CiVariables|Export variable to pipelines running on protected branches and tags only.',
@ -86,8 +90,12 @@ export default {
GlFormTextarea,
GlIcon,
GlLink,
GlModal,
GlSprintf,
},
directives: {
GlModalDirective,
},
mixins: [trackingMixin],
inject: ['environmentScopeLink', 'isProtectedByDefault', 'maskableRawRegex', 'maskableRegex'],
props: {
@ -170,6 +178,9 @@ export default {
modalActionText() {
return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable;
},
removeVariableMessage() {
return sprintf(this.$options.i18n.modalDeleteMessage, { key: this.variable.key });
},
},
watch: {
variable: {
@ -188,6 +199,13 @@ export default {
close() {
this.$emit('close-form');
},
deleteVariable() {
this.$emit('delete-variable', this.variable);
this.close();
},
setEnvironmentScope(scope) {
this.variable = { ...this.variable, environmentScope: scope };
},
getTrackingErrorProperty() {
if (this.isValueEmpty) {
return null;
@ -225,164 +243,206 @@ export default {
}),
i18n,
variableOptions,
deleteModal: {
actionPrimary: {
text: __('Delete'),
attributes: {
variant: 'danger',
},
},
actionSecondary: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
};
</script>
<template>
<gl-drawer
open
data-testid="ci-variable-drawer"
:header-height="getDrawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
@close="close"
>
<template #title>
<h2 class="gl-m-0">{{ modalActionText }}</h2>
</template>
<gl-form-group
:label="$options.i18n.type"
label-for="ci-variable-type"
class="gl-border-none"
:class="{
'gl-mb-n5': !hideEnvironmentScope,
'gl-mb-n1': hideEnvironmentScope,
}"
<div>
<gl-drawer
open
data-testid="ci-variable-drawer"
:header-height="getDrawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
@close="close"
>
<gl-form-select
id="ci-variable-type"
v-model="variable.variableType"
:options="$options.variableOptions"
/>
</gl-form-group>
<gl-form-group
v-if="!hideEnvironmentScope"
class="gl-border-none gl-mb-n5"
label-for="ci-variable-env"
data-testid="environment-scope"
>
<template #label>
<div class="gl-display-flex gl-align-items-center">
<span class="gl-mr-2">
{{ $options.i18n.environments }}
</span>
<gl-link
class="gl-display-flex"
:title="$options.i18n.environmentScopeLinkTitle"
:href="environmentScopeLink"
target="_blank"
data-testid="environment-scope-link"
>
<gl-icon name="question-o" :size="14" />
</gl-link>
</div>
<template #title>
<h2 class="gl-m-0">{{ modalActionText }}</h2>
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
class="gl-mb-5"
has-env-scope-query
:are-environments-loading="areEnvironmentsLoading"
:environments="environments"
:selected-environment-scope="variable.environmentScope"
/>
<gl-form-input
v-else
:value="$options.i18n.defaultScope"
class="gl-w-full gl-mb-5"
readonly
/>
</gl-form-group>
<gl-form-group class="gl-border-none gl-mb-n8">
<template #label>
<div class="gl-display-flex gl-align-items-center gl-mb-n3">
<span class="gl-mr-2">
{{ $options.i18n.flags }}
</span>
<gl-link
class="gl-display-flex"
:title="$options.i18n.flagsLinkTitle"
:href="$options.flagLink"
target="_blank"
>
<gl-icon name="question-o" :size="14" />
</gl-link>
</div>
</template>
<gl-form-checkbox v-model="variable.protected" data-testid="ci-variable-protected-checkbox">
{{ $options.i18n.protectedField }}
<p class="gl-text-secondary">
{{ $options.i18n.protectedDescription }}
</p>
</gl-form-checkbox>
<gl-form-checkbox v-model="variable.masked" data-testid="ci-variable-masked-checkbox">
{{ $options.i18n.maskedField }}
<p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p>
</gl-form-checkbox>
<gl-form-checkbox
data-testid="ci-variable-expanded-checkbox"
:checked="isExpanded"
@change="setRaw"
<gl-form-group
:label="$options.i18n.type"
label-for="ci-variable-type"
class="gl-border-none"
:class="{
'gl-mb-n5': !hideEnvironmentScope,
'gl-mb-n1': hideEnvironmentScope,
}"
>
{{ $options.i18n.expandedField }}
<p class="gl-text-secondary">
<gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
</gl-form-checkbox>
</gl-form-group>
<gl-form-combobox
v-model="variable.key"
:token-list="$options.awsTokenList"
:label-text="$options.i18n.key"
class="gl-border-none gl-pb-0! gl-mb-n5"
data-testid="ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
<gl-form-group
:label="$options.i18n.value"
label-for="ci-variable-value"
class="gl-border-none gl-mb-n2"
data-testid="ci-variable-value-label"
:invalid-feedback="maskedReqsNotMetText"
:state="isValueValid"
>
<gl-form-textarea
id="ci-variable-value"
v-model="variable.value"
class="gl-border-none gl-font-monospace!"
rows="3"
max-rows="10"
data-testid="ci-variable-value"
data-qa-selector="ci_variable_value_field"
spellcheck="false"
<gl-form-select
id="ci-variable-type"
v-model="variable.variableType"
:options="$options.variableOptions"
/>
</gl-form-group>
<gl-form-group
v-if="!hideEnvironmentScope"
class="gl-border-none gl-mb-n5"
label-for="ci-variable-env"
data-testid="environment-scope"
>
<template #label>
<div class="gl-display-flex gl-align-items-center">
<span class="gl-mr-2">
{{ $options.i18n.environments }}
</span>
<gl-link
class="gl-display-flex"
:title="$options.i18n.environmentScopeLinkTitle"
:href="environmentScopeLink"
target="_blank"
data-testid="environment-scope-link"
>
<gl-icon name="question-o" :size="14" />
</gl-link>
</div>
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
class="gl-mb-5"
has-env-scope-query
:are-environments-loading="areEnvironmentsLoading"
:environments="environments"
:selected-environment-scope="variable.environmentScope"
@select-environment="setEnvironmentScope"
@search-environment-scope="$emit('search-environment-scope', $event)"
/>
<gl-form-input
v-else
:value="$options.i18n.defaultScope"
class="gl-w-full gl-mb-5"
readonly
/>
</gl-form-group>
<gl-form-group class="gl-border-none gl-mb-n8">
<template #label>
<div class="gl-display-flex gl-align-items-center gl-mb-n3">
<span class="gl-mr-2">
{{ $options.i18n.flags }}
</span>
<gl-link
class="gl-display-flex"
:title="$options.i18n.flagsLinkTitle"
:href="$options.flagLink"
target="_blank"
>
<gl-icon name="question-o" :size="14" />
</gl-link>
</div>
</template>
<gl-form-checkbox v-model="variable.protected" data-testid="ci-variable-protected-checkbox">
{{ $options.i18n.protectedField }}
<p class="gl-text-secondary">
{{ $options.i18n.protectedDescription }}
</p>
</gl-form-checkbox>
<gl-form-checkbox v-model="variable.masked" data-testid="ci-variable-masked-checkbox">
{{ $options.i18n.maskedField }}
<p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p>
</gl-form-checkbox>
<gl-form-checkbox
data-testid="ci-variable-expanded-checkbox"
:checked="isExpanded"
@change="setRaw"
>
{{ $options.i18n.expandedField }}
<p class="gl-text-secondary">
<gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
</gl-form-checkbox>
</gl-form-group>
<gl-form-combobox
v-model="variable.key"
:token-list="$options.awsTokenList"
:label-text="$options.i18n.key"
class="gl-border-none gl-pb-0! gl-mb-n5"
data-testid="ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
<p v-if="variable.raw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip">
{{ $options.i18n.valueFeedback.rawHelpText }}
</p>
</gl-form-group>
<gl-alert
v-if="hasVariableReference"
:title="$options.i18n.variableReferenceTitle"
:dismissible="false"
variant="warning"
class="gl-mx-4 gl-pl-9! gl-border-bottom-0"
data-testid="has-variable-reference-alert"
<gl-form-group
:label="$options.i18n.value"
label-for="ci-variable-value"
class="gl-border-none gl-mb-n2"
data-testid="ci-variable-value-label"
:invalid-feedback="maskedReqsNotMetText"
:state="isValueValid"
>
<gl-form-textarea
id="ci-variable-value"
v-model="variable.value"
class="gl-border-none gl-font-monospace!"
rows="3"
max-rows="10"
data-testid="ci-variable-value"
data-qa-selector="ci_variable_value_field"
spellcheck="false"
/>
<p
v-if="variable.raw"
class="gl-mt-2 gl-mb-0 text-secondary"
data-testid="raw-variable-tip"
>
{{ $options.i18n.valueFeedback.rawHelpText }}
</p>
</gl-form-group>
<gl-alert
v-if="hasVariableReference"
:title="$options.i18n.variableReferenceTitle"
:dismissible="false"
variant="warning"
class="gl-mx-4 gl-pl-9! gl-border-bottom-0"
data-testid="has-variable-reference-alert"
>
{{ $options.i18n.variableReferenceDescription }}
</gl-alert>
<div class="gl-display-flex gl-justify-content-end">
<gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close"
>{{ $options.i18n.cancel }}
</gl-button>
<gl-button
v-if="isEditing"
v-gl-modal-directive="`delete-variable-${variable.key}`"
variant="danger"
category="secondary"
class="gl-mr-3"
data-testid="ci-variable-delete-btn"
>{{ $options.i18n.deleteVariable }}</gl-button
>
<gl-button
category="primary"
variant="confirm"
:disabled="!canSubmit"
data-testid="ci-variable-confirm-btn"
@click="submit"
>{{ modalActionText }}
</gl-button>
</div>
</gl-drawer>
<gl-modal
ref="modal"
:modal-id="`delete-variable-${variable.key}`"
:title="$options.i18n.deleteVariable"
:action-primary="$options.deleteModal.actionPrimary"
:action-secondary="$options.deleteModal.actionSecondary"
data-testid="ci-variable-drawer-confirm-delete-modal"
@primary="deleteVariable"
>
{{ $options.i18n.variableReferenceDescription }}
</gl-alert>
<div class="gl-display-flex gl-justify-content-end">
<gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close"
>{{ $options.i18n.cancel }}
</gl-button>
<gl-button
category="primary"
variant="confirm"
:disabled="!canSubmit"
data-testid="ci-variable-confirm-btn"
@click="submit"
>{{ modalActionText }}
</gl-button>
</div>
</gl-drawer>
{{ removeVariableMessage }}
</gl-modal>
</div>
</template>

View File

@ -211,9 +211,6 @@ export default {
addVariable() {
this.$emit('add-variable', this.variable);
},
createEnvironmentScope(env) {
this.newEnvironments.push(env);
},
deleteVariable() {
this.$emit('delete-variable', this.variable);
},
@ -411,7 +408,6 @@ export default {
:selected-environment-scope="variable.environmentScope"
:environments="environmentsList"
@select-environment="setEnvironmentScope"
@create-environment-scope="createEnvironmentScope"
@search-environment-scope="$emit('search-environment-scope', $event)"
/>

View File

@ -0,0 +1,37 @@
export const GLOBAL_ALERTS_SESSION_STORAGE_KEY = 'vueGlobalAlerts';
/**
* Get global alerts from session storage
*/
export const getGlobalAlerts = () => {
return JSON.parse(sessionStorage.getItem(GLOBAL_ALERTS_SESSION_STORAGE_KEY) || '[]');
};
/**
* Set alerts in session storage
* @param {{id: String, title?: String, message: String, variant: String, dismissible?: Boolean, persistOnPages?: String[]}[]} alerts
*/
export const setGlobalAlerts = (alerts) => {
sessionStorage.setItem(
GLOBAL_ALERTS_SESSION_STORAGE_KEY,
JSON.stringify([
...alerts.map(({ dismissible = true, persistOnPages = [], ...alert }) => ({
dismissible,
persistOnPages,
...alert,
})),
]),
);
};
/**
* Remove global alert by id
* @param {String} id
*/
export const removeGlobalAlertById = (id) => {
const existingAlerts = getGlobalAlerts();
sessionStorage.setItem(
GLOBAL_ALERTS_SESSION_STORAGE_KEY,
JSON.stringify(existingAlerts.filter((alert) => alert.id !== id)),
);
};

View File

@ -1,3 +1,5 @@
import { getGlobalAlerts, setGlobalAlerts } from './global_alerts';
export const DASH_SCOPE = '-';
export const PATH_SEPARATOR = '/';
@ -721,6 +723,20 @@ export function visitUrl(destination, external = false) {
}
}
/**
* Navigates to a URL and display alerts.
*
* If destination is a querystring, it will be automatically transformed into a fully qualified URL.
* If the URL is not a safe URL (see isSafeURL implementation), this function will log an exception into Sentry.
*
* @param {*} destination - url to navigate to. This can be a fully qualified URL or a querystring.
* @param {{id: String, title?: String, message: String, variant: String, dismissible?: Boolean, persistOnPages?: String[]}[]} alerts - Alerts to display
*/
export function visitUrlWithAlerts(destination, alerts) {
setGlobalAlerts([...getGlobalAlerts(), ...alerts]);
visitUrl(destination);
}
export function refreshCurrentPage() {
visitUrl(window.location.href);
}

View File

@ -21,6 +21,8 @@ class ProjectSetting < ApplicationRecord
jitsu_administrator_email
], remove_with: '16.5', remove_after: '2023-09-22'
ignore_column :jitsu_key, remove_with: '16.7', remove_after: '2023-11-17'
attr_encrypted :cube_api_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_32,

View File

@ -10,6 +10,20 @@ module WorkItems
def children
work_item.work_item_children_by_relative_position
end
def self.quick_action_commands
[:set_parent]
end
def self.quick_action_params
[:set_parent]
end
def self.process_quick_action_param(param_name, value)
return super unless param_name == :set_parent && value
{ parent: value }
end
end
end
end

View File

@ -61,7 +61,7 @@ module Notes
service_errors = if service_response.respond_to?(:errors)
service_response.errors.full_messages
elsif service_response.respond_to?(:[]) && service_response[:status] == :error
service_response[:message]
Array.wrap(service_response[:message])
end
service_errors.blank? ? ServiceResponse.success : ServiceResponse.error(message: service_errors)

View File

@ -15,3 +15,4 @@
- elsif value
= render Pajamas::AlertComponent.new(variant: type_to_variant[key], dismissible: closable.include?(key), alert_options: {class: "flash-#{key}", data: { testid: "alert-#{type_to_variant[key]}" }}) do |c|
= c.with_body { value }
#js-global-alerts

View File

@ -1,8 +0,0 @@
---
name: deploy_key_for_protected_tags
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/110238
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/389237
milestone: '15.9'
type: development
group: group::source code
default_enabled: true

View File

@ -0,0 +1,26 @@
---
key_path: redis_hll_counters.quickactions.i_quickactions_set_parent_weekly
name: quickactions_set_parent_weekly
description: Count of WAU using the `/set_parent` quick action
product_section: dev
product_stage: plan
product_group: product_planning
value_type: number
status: active
milestone: "16.5"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122911
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_quickactions_set_parent
performance_indicator_type: []
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View File

@ -0,0 +1,26 @@
---
key_path: redis_hll_counters.quickactions.i_quickactions_set_parent_monthly
name: quickactions_set_parent_monthly
description: Count of WAU using the `/set_parent` quick action
product_section: dev
product_stage: plan
product_group: product_planning
value_type: number
status: active
milestone: "16.5"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/122911
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_quickactions_set_parent
performance_indicator_type: []
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class DropIndexBtreeNamespacesTraversalIds < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
TABLE_NAME = :namespaces
INDEX_NAME = :index_btree_namespaces_traversal_ids
def up
remove_concurrent_index_by_name TABLE_NAME, INDEX_NAME
end
def down
add_concurrent_index TABLE_NAME, :traversal_ids, name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
8e09b2216c8d64273e5025a813ee74e64ce549754f2e2b5f1aaf2d12a9bf5c95

View File

@ -31385,8 +31385,6 @@ CREATE INDEX index_boards_on_project_id ON boards USING btree (project_id);
CREATE INDEX index_broadcast_message_on_ends_at_and_broadcast_type_and_id ON broadcast_messages USING btree (ends_at, broadcast_type, id);
CREATE INDEX index_btree_namespaces_traversal_ids ON namespaces USING btree (traversal_ids);
CREATE INDEX index_bulk_import_batch_trackers_on_tracker_id ON bulk_import_batch_trackers USING btree (tracker_id);
CREATE INDEX index_bulk_import_configurations_on_bulk_import_id ON bulk_import_configurations USING btree (bulk_import_id);

View File

@ -76,7 +76,8 @@ test locally for verification.
To copy the name of all failed tests, at the top of the **Test summary** panel,
select **Copy failed tests**. The failed tests are listed as a string with the tests
separated by spaces.
separated by spaces. This option is only available if the JUnit report populates
the `<file>` attributes for failed tests.
To copy the name of a single failed test:

View File

@ -397,6 +397,29 @@ This approach has a few benefits:
- Accessing a global variable is not required, except in the application's
[entry point](#accessing-the-gl-object).
#### Redirecting to page and displaying alerts
If you need to redirect to another page and display alerts, you can use the [`visitUrlWithAlerts`](https://gitlab.com/gitlab-org/gitlab/-/blob/7063dce68b8231442567707024b2f29e48ce2f64/app/assets/javascripts/lib/utils/url_utility.js#L731) util.
This can be useful when you're redirecting to a newly created resource and showing a success alert.
By default the alerts will be cleared when the page is reloaded. If you need an alert to be persisted on a page you can set the
`persistOnPages` key to an array of Rails controller actions. To find the Rails controller action run `document.body.dataset.page` in your console.
Example:
```javascript
visitUrlWithAlerts('/dashboard/groups', [
{
id: 'resource-building-in-background',
message: 'Resource is being built in the background.',
variant: 'info',
persistOnPages: ['dashboard:groups:index'],
},
])
```
If you need to manually remove a persisted alert, you can use the [`removeGlobalAlertById`](https://gitlab.com/gitlab-org/gitlab/-/blob/7063dce68b8231442567707024b2f29e48ce2f64/app/assets/javascripts/lib/utils/global_alerts.js#L31) util.
### A folder for Components
This folder holds all components that are specific to this new feature.

View File

@ -162,6 +162,7 @@ To auto-format this table, use the VS Code Markdown Table formatter: `https://do
| `/relabel ~label1 ~label2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Replace current labels with those specified. |
| `/remove_due_date` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Remove due date. |
| `/reopen` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Reopen. |
| `/set_parent <work_item>` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Set parent work item to `<work_item>`. The `<work_item>` value should be in the format of `#iid`, `group/project#iid`, or a URL to a work item. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/420798) in GitLab 16.5. |
| `/shrug <comment>` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Append the comment with `¯\_(ツ)_/¯`. |
| `/subscribe` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Subscribe to notifications. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/420796) in GitLab 16.4 |
| `/tableflip <comment>` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Append the comment with `(╯°□°)╯︵ ┻━┻`. |

View File

@ -20,7 +20,13 @@ module Gitlab
return unless enabled?
observation.query_statistics = connection.execute(<<~SQL)
SELECT query, calls, total_time, max_time, mean_time, rows
SELECT
query,
calls,
total_exec_time + total_plan_time AS total_time,
max_exec_time + max_plan_time AS max_time,
mean_exec_time + mean_plan_time AS mean_time,
"rows"
FROM pg_stat_statements
WHERE pg_get_userbyid(userid) = current_user
ORDER BY total_time DESC

View File

@ -16,17 +16,6 @@ module Gitlab
attr_reader :deploy_key
def protected_tag_accessible_to?(ref, action:)
if Feature.enabled?(:deploy_key_for_protected_tags, project)
super
else
assert_project!
# a deploy key can always push a protected tag
# (which is not always the case when pushing to a protected branch)
true
end
end
def can_collaborate?(_ref)
assert_project!

View File

@ -27,6 +27,18 @@ module Gitlab
command :promote_to do |type_name|
@execution_message[:promote_to] = update_type(type_name, :promote_to)
end
desc { _('Change work item parent') }
explanation do |parent_param|
format(_("Change work item's parent to %{parent_ref}."), parent_ref: parent_param)
end
types WorkItem
params 'Parent #iid, reference or URL'
condition { supports_parent? && can_admin_link? }
command :set_parent do |parent_param|
@updates[:set_parent] = extract_work_item(parent_param)
@execution_message[:set_parent] = success_msg[:set_parent]
end
end
private
@ -52,6 +64,16 @@ module Gitlab
nil
end
def extract_work_item(params)
return if params.nil?
issuable_type = params.include?('work_items') ? :work_item : :issue
issuable = extract_references(params, issuable_type).first
return unless issuable
WorkItem.find(issuable.id)
end
def validate_promote_to(type)
return error_msg(:not_found, action: 'promote') unless type && supports_promote_to?(type.name)
return if current_user.can?(:"create_#{type.base_type}", quick_action_target)
@ -78,8 +100,8 @@ module Gitlab
def error_msg(reason, action: 'convert')
message = {
not_found: 'Provided type is not supported',
same_type: 'Types are the same',
forbidden: 'You have insufficient permissions'
forbidden: 'You have insufficient permissions',
same_type: 'Types are the same'
}.freeze
format(_("Failed to %{action} this work item: %{reason}."), { action: action, reason: message[reason] })
@ -88,9 +110,18 @@ module Gitlab
def success_msg
{
type: _('Type changed successfully.'),
promote_to: _("Work item promoted successfully.")
promote_to: _("Work item promoted successfully."),
set_parent: _('Work item parent set successfully')
}
end
def supports_parent?
::WorkItems::HierarchyRestriction.find_by_child_type_id(quick_action_target.work_item_type_id).present?
end
def can_admin_link?
current_user.can?(:admin_issue_link, quick_action_target)
end
end
end
end

View File

@ -9557,9 +9557,15 @@ msgstr ""
msgid "Change title"
msgstr ""
msgid "Change work item parent"
msgstr ""
msgid "Change work item type"
msgstr ""
msgid "Change work item's parent to %{parent_ref}."
msgstr ""
msgid "Change your password"
msgstr ""
@ -10242,9 +10248,6 @@ msgstr ""
msgid "CiStatusText|Warning"
msgstr ""
msgid "CiVariables|Add Variable"
msgstr ""
msgid "CiVariables|Add variable"
msgstr ""
@ -10263,7 +10266,7 @@ msgstr ""
msgid "CiVariables|Do you want to delete the variable %{key}?"
msgstr ""
msgid "CiVariables|Edit Variable"
msgid "CiVariables|Edit variable"
msgstr ""
msgid "CiVariables|Environments"
@ -53632,6 +53635,9 @@ msgstr ""
msgid "Work in progress limit"
msgstr ""
msgid "Work item parent set successfully"
msgstr ""
msgid "Work item promoted successfully."
msgstr ""

View File

@ -0,0 +1,135 @@
import { nextTick } from 'vue';
import { GlAlert } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GlobalAlerts from '~/behaviors/components/global_alerts.vue';
import { getGlobalAlerts, setGlobalAlerts, removeGlobalAlertById } from '~/lib/utils/global_alerts';
jest.mock('~/lib/utils/global_alerts');
describe('GlobalAlerts', () => {
const alert1 = {
dismissible: true,
persistOnPages: [],
id: 'foo',
variant: 'success',
title: 'Foo title',
message: 'Foo',
};
const alert2 = {
dismissible: true,
persistOnPages: [],
id: 'bar',
variant: 'danger',
message: 'Bar',
};
const alert3 = {
dismissible: true,
persistOnPages: ['dashboard:groups:index'],
id: 'baz',
variant: 'info',
message: 'Baz',
};
let wrapper;
const createComponent = async () => {
wrapper = shallowMountExtended(GlobalAlerts);
await nextTick();
};
const findAllAlerts = () => wrapper.findAllComponents(GlAlert);
describe('when there are alerts to display', () => {
beforeEach(() => {
getGlobalAlerts.mockImplementationOnce(() => [alert1, alert2]);
});
it('displays alerts and removes them from session storage', async () => {
await createComponent();
const alerts = findAllAlerts();
expect(alerts.at(0).text()).toBe('Foo');
expect(alerts.at(0).props()).toMatchObject({
title: 'Foo title',
variant: 'success',
dismissible: true,
});
expect(alerts.at(1).text()).toBe('Bar');
expect(alerts.at(1).props()).toMatchObject({
variant: 'danger',
dismissible: true,
});
expect(setGlobalAlerts).toHaveBeenCalledWith([]);
});
describe('when alert is dismissed', () => {
it('removes alert', async () => {
await createComponent();
wrapper.findComponent(GlAlert).vm.$emit('dismiss');
await nextTick();
expect(findAllAlerts().length).toBe(1);
expect(removeGlobalAlertById).toHaveBeenCalledWith(alert1.id);
});
});
});
describe('when alert has `persistOnPages` key set', () => {
const alerts = [alert3];
beforeEach(() => {
getGlobalAlerts.mockImplementationOnce(() => alerts);
});
describe('when page matches specified page', () => {
beforeEach(() => {
document.body.dataset.page = 'dashboard:groups:index';
});
afterEach(() => {
delete document.body.dataset.page;
});
it('renders alert and does not remove it from session storage', async () => {
await createComponent();
expect(wrapper.findComponent(GlAlert).text()).toBe('Baz');
expect(setGlobalAlerts).toHaveBeenCalledWith(alerts);
});
});
describe('when page does not match specified page', () => {
beforeEach(() => {
document.body.dataset.page = 'dashboard:groups:show';
});
afterEach(() => {
delete document.body.dataset.page;
});
it('does not render alert and does not remove it from session storage', async () => {
await createComponent();
expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
expect(setGlobalAlerts).toHaveBeenCalledWith(alerts);
});
});
});
describe('when there are no alerts to display', () => {
beforeEach(() => {
getGlobalAlerts.mockImplementationOnce(() => []);
});
it('renders nothing', async () => {
await createComponent();
expect(wrapper.html()).toBe('');
});
});
});

View File

@ -1,10 +1,4 @@
import {
GlListboxItem,
GlCollapsibleListbox,
GlDropdownDivider,
GlDropdownItem,
GlIcon,
} from '@gitlab/ui';
import { GlListboxItem, GlCollapsibleListbox, GlDropdownDivider, GlIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
@ -25,7 +19,7 @@ describe('Ci environments dropdown', () => {
const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon);
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findListboxText = () => findListbox().props('toggleText');
const findCreateWildcardButton = () => wrapper.findComponent(GlDropdownItem);
const findCreateWildcardButton = () => wrapper.findByTestId('create-wildcard-button');
const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice');
@ -188,16 +182,35 @@ describe('Ci environments dropdown', () => {
});
});
describe('when creating a new environment from a search term', () => {
const search = 'new-env';
describe('when creating a new environment scope from a search term', () => {
const searchTerm = 'new-env';
beforeEach(() => {
createComponent({ searchTerm: search });
createComponent({ searchTerm, props: { hasEnvScopeQuery: true } });
});
it('emits create-environment-scope', () => {
findCreateWildcardButton().vm.$emit('click');
it('sets new environment scope as the selected environment scope', async () => {
findCreateWildcardButton().trigger('click');
expect(wrapper.emitted('create-environment-scope')).toEqual([[search]]);
await findListbox().vm.$emit('search', searchTerm);
expect(findListbox().props('selected')).toBe(searchTerm);
});
it('includes new environment scope in search if it matches search term', async () => {
findCreateWildcardButton().trigger('click');
await findListbox().vm.$emit('search', searchTerm);
expect(findAllListboxItems()).toHaveLength(envs.length + 1);
expect(findListboxItemByIndex(1).text()).toBe(searchTerm);
});
it('excludes new environment scope in search if it does not match the search term', async () => {
findCreateWildcardButton().trigger('click');
await findListbox().vm.$emit('search', 'not-new-env');
expect(findAllListboxItems()).toHaveLength(envs.length);
});
});
});

View File

@ -1,4 +1,5 @@
import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { nextTick } from 'vue';
import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect, GlModal } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
@ -67,6 +68,8 @@ describe('CI Variable Drawer', () => {
};
const findConfirmBtn = () => wrapper.findByTestId('ci-variable-confirm-btn');
const findConfirmDeleteModal = () => wrapper.findComponent(GlModal);
const findDeleteBtn = () => wrapper.findByTestId('ci-variable-delete-btn');
const findDisabledEnvironmentScopeDropdown = () => wrapper.findComponent(GlFormInput);
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown);
@ -363,22 +366,118 @@ describe('CI Variable Drawer', () => {
});
it('title and confirm button renders the correct text', () => {
expect(findTitle().text()).toBe('Add Variable');
expect(findConfirmBtn().text()).toBe('Add Variable');
expect(findTitle().text()).toBe('Add variable');
expect(findConfirmBtn().text()).toBe('Add variable');
});
it('does not render delete button', () => {
expect(findDeleteBtn().exists()).toBe(false);
});
it('dispatches the add-variable event', async () => {
await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
await findProtectedCheckbox().vm.$emit('input', false);
await findExpandedCheckbox().vm.$emit('input', true);
await findMaskedCheckbox().vm.$emit('input', true);
await findValueField().vm.$emit('input', 'NEW_VALUE');
findConfirmBtn().vm.$emit('click');
expect(wrapper.emitted('add-variable')).toEqual([
[
{
environmentScope: '*',
key: 'NEW_VARIABLE',
masked: true,
protected: false,
raw: false, // opposite of expanded
value: 'NEW_VALUE',
variableType: 'ENV_VAR',
},
],
]);
});
});
describe('when editing a variable', () => {
beforeEach(() => {
createComponent({
props: { mode: EDIT_VARIABLE_ACTION },
props: { mode: EDIT_VARIABLE_ACTION, selectedVariable: mockProjectVariableFileType },
stubs: { GlDrawer },
});
});
it('title and confirm button renders the correct text', () => {
expect(findTitle().text()).toBe('Edit Variable');
expect(findConfirmBtn().text()).toBe('Edit Variable');
expect(findTitle().text()).toBe('Edit variable');
expect(findConfirmBtn().text()).toBe('Edit variable');
});
it('dispatches the edit-variable event', async () => {
await findValueField().vm.$emit('input', 'EDITED_VALUE');
findConfirmBtn().vm.$emit('click');
expect(wrapper.emitted('update-variable')).toEqual([
[
{
...mockProjectVariableFileType,
value: 'EDITED_VALUE',
},
],
]);
});
});
describe('when deleting a variable', () => {
beforeEach(() => {
createComponent({
mountFn: mountExtended,
props: { mode: EDIT_VARIABLE_ACTION, selectedVariable: mockProjectVariableFileType },
});
});
it('bubbles up the delete-variable event', async () => {
findDeleteBtn().vm.$emit('click');
await nextTick();
findConfirmDeleteModal().vm.$emit('primary');
expect(wrapper.emitted('delete-variable')).toEqual([[mockProjectVariableFileType]]);
});
});
describe('environment scope events', () => {
beforeEach(() => {
createComponent({
mountFn: mountExtended,
props: {
mode: EDIT_VARIABLE_ACTION,
selectedVariable: mockProjectVariableFileType,
areScopedVariablesAvailable: true,
hideEnvironmentScope: false,
},
});
});
it('sets the environment scope', async () => {
await findEnvironmentScopeDropdown().vm.$emit('select-environment', 'staging');
await findConfirmBtn().vm.$emit('click');
expect(wrapper.emitted('update-variable')).toEqual([
[
{
...mockProjectVariableFileType,
environmentScope: 'staging',
},
],
]);
});
it('bubbles up the search event', async () => {
await findEnvironmentScopeDropdown().vm.$emit('search-environment-scope', 'staging');
expect(wrapper.emitted('search-environment-scope')).toEqual([['staging']]);
});
});
});

View File

@ -0,0 +1,80 @@
import {
getGlobalAlerts,
setGlobalAlerts,
removeGlobalAlertById,
GLOBAL_ALERTS_SESSION_STORAGE_KEY,
} from '~/lib/utils/global_alerts';
describe('global alerts utils', () => {
describe('getGlobalAlerts', () => {
describe('when there are alerts', () => {
beforeEach(() => {
jest
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => '[{"id":"foo","variant":"danger","message":"Foo"}]');
});
it('returns alerts from session storage', () => {
expect(getGlobalAlerts()).toEqual([{ id: 'foo', variant: 'danger', message: 'Foo' }]);
});
});
describe('when there are no alerts', () => {
beforeEach(() => {
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null);
});
it('returns empty array', () => {
expect(getGlobalAlerts()).toEqual([]);
});
});
});
});
describe('setGlobalAlerts', () => {
it('sets alerts in session storage', () => {
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {});
setGlobalAlerts([
{
id: 'foo',
variant: 'danger',
message: 'Foo',
},
{
id: 'bar',
variant: 'success',
message: 'Bar',
persistOnPages: ['dashboard:groups:index'],
dismissible: false,
},
]);
expect(setItemSpy).toHaveBeenCalledWith(
GLOBAL_ALERTS_SESSION_STORAGE_KEY,
'[{"dismissible":true,"persistOnPages":[],"id":"foo","variant":"danger","message":"Foo"},{"dismissible":false,"persistOnPages":["dashboard:groups:index"],"id":"bar","variant":"success","message":"Bar"}]',
);
});
});
describe('removeGlobalAlertById', () => {
beforeEach(() => {
jest
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(
() =>
'[{"id":"foo","variant":"success","message":"Foo"},{"id":"bar","variant":"danger","message":"Bar"}]',
);
});
it('removes alert', () => {
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {});
removeGlobalAlertById('bar');
expect(setItemSpy).toHaveBeenCalledWith(
GLOBAL_ALERTS_SESSION_STORAGE_KEY,
'[{"id":"foo","variant":"success","message":"Foo"}]',
);
});
});

View File

@ -1,8 +1,20 @@
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as urlUtils from '~/lib/utils/url_utility';
import { setGlobalAlerts } from '~/lib/utils/global_alerts';
import { safeUrls, unsafeUrls } from './mock_data';
jest.mock('~/lib/utils/global_alerts', () => ({
getGlobalAlerts: jest.fn().mockImplementation(() => [
{
id: 'foo',
message: 'Foo',
variant: 'success',
},
]),
setGlobalAlerts: jest.fn(),
}));
const shas = {
valid: [
'ad9be38573f9ee4c4daec22673478c2dd1d81cd8',
@ -482,6 +494,48 @@ describe('URL utility', () => {
});
});
describe('visitUrlWithAlerts', () => {
let originalLocation;
beforeAll(() => {
originalLocation = window.location;
Object.defineProperty(window, 'location', {
writable: true,
value: {
assign: jest.fn(),
protocol: 'http:',
host: TEST_HOST,
},
});
});
afterAll(() => {
window.location = originalLocation;
});
it('sets alerts and then visits url', () => {
const url = '/foo/bar';
const alert = {
id: 'bar',
message: 'Bar',
variant: 'danger',
};
urlUtils.visitUrlWithAlerts(url, [alert]);
expect(setGlobalAlerts).toHaveBeenCalledWith([
{
id: 'foo',
message: 'Foo',
variant: 'success',
},
alert,
]);
expect(window.location.assign).toHaveBeenCalledWith(url);
});
});
describe('updateHistory', () => {
const state = { key: 'prop' };
const title = 'TITLE';

View File

@ -41,7 +41,13 @@ RSpec.describe Gitlab::Database::Migrations::Observers::QueryStatistics do
let(:result) { double }
let(:pgss_query) do
<<~SQL
SELECT query, calls, total_time, max_time, mean_time, rows
SELECT
query,
calls,
total_exec_time + total_plan_time AS total_time,
max_exec_time + max_plan_time AS max_time,
mean_exec_time + mean_plan_time AS mean_time,
"rows"
FROM pg_stat_statements
WHERE pg_get_userbyid(userid) = current_user
ORDER BY total_time DESC

View File

@ -23,16 +23,6 @@ RSpec.describe Gitlab::DeployKeyAccess, feature_category: :source_code_managemen
it 'returns false' do
expect(access.can_create_tag?('v0.1.2')).to be_falsey
end
context 'when deploy_key_for_protected_tags FF is disabled' do
before do
stub_feature_flags(deploy_key_for_protected_tags: false)
end
it 'allows to push the tag' do
expect(access.can_create_tag?('v0.1.2')).to be_truthy
end
end
end
context 'push tag that matches a protected tag pattern via a deploy key' do

View File

@ -164,7 +164,6 @@ project_setting:
- selective_code_owner_removals
- show_diff_preview_in_email
- suggested_reviewers_enabled
- jitsu_key
- mirror_branch_regex
- allow_pipeline_trigger_approve_deployment
- pages_unique_domain_enabled

View File

@ -334,6 +334,45 @@ RSpec.describe Notes::QuickActionsService, feature_category: :team_planning do
end
end
describe '/set_parent' do
let_it_be_with_reload(:noteable) { create(:work_item, :objective, project: project) }
let_it_be_with_reload(:parent) { create(:work_item, :objective, project: project) }
let_it_be(:note_text) { "/set_parent #{parent.to_reference}" }
let_it_be(:note) { create(:note, noteable: noteable, project: project, note: note_text) }
shared_examples 'sets work item parent' do
it 'leaves the note empty' do
expect(execute(note)).to be_empty
end
it 'sets work item parent' do
execute(note)
expect(parent.valid?).to be_truthy
expect(noteable.work_item_parent).to eq(parent)
end
end
context 'when using work item reference' do
let_it_be(:note_text) { "/set_parent #{project.full_path}#{parent.to_reference}" }
it_behaves_like 'sets work item parent'
end
context 'when using work item iid' do
let_it_be(:note_text) { "/set_parent #{parent.to_reference}" }
it_behaves_like 'sets work item parent'
end
context 'when using work item URL' do
let_it_be(:url) { "#{Gitlab.config.gitlab.url}/#{project.full_path}/work_items/#{parent.iid}" }
let_it_be(:note_text) { "/set_parent #{url}" }
it_behaves_like 'sets work item parent'
end
end
describe '/promote_to' do
shared_examples 'promotes work item' do |from:, to:|
it 'leaves the note empty' do

View File

@ -2478,6 +2478,26 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
end
it_behaves_like 'quick actions that change work item type'
context '/set_parent command' do
let_it_be(:parent) { create(:work_item, :issue, project: project) }
let_it_be(:work_item) { create(:work_item, :task, project: project) }
let_it_be(:parent_ref) { parent.to_reference(project) }
let(:content) { "/set_parent #{parent_ref}" }
it 'returns success message' do
_, _, message = service.execute(content, work_item)
expect(message).to eq('Work item parent set successfully')
end
it 'sets correct update params' do
_, updates, _ = service.execute(content, work_item)
expect(updates).to eq(set_parent: parent)
end
end
end
describe '#explain' do
@ -3022,6 +3042,55 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning
end
end
end
describe '/set_parent command' do
let_it_be(:parent) { create(:work_item, :issue, project: project) }
let_it_be(:work_item) { create(:work_item, :task, project: project) }
let_it_be(:parent_ref) { parent.to_reference(project) }
let(:command) { "/set_parent #{parent_ref}" }
shared_examples 'command is available' do
it 'explanation contains correct message' do
_, explanations = service.explain(command, work_item)
expect(explanations)
.to contain_exactly("Change work item's parent to #{parent_ref}.")
end
it 'contains command' do
expect(service.available_commands(work_item)).to include(a_hash_including(name: :set_parent))
end
end
shared_examples 'command is not available' do
it 'explanation is empty' do
_, explanations = service.explain(command, work_item)
expect(explanations).to eq([])
end
it 'does not contain command' do
expect(service.available_commands(work_item)).not_to include(a_hash_including(name: :set_parent))
end
end
context 'when user can admin link' do
it_behaves_like 'command is available'
context 'when work item type does not support a parent' do
let_it_be(:work_item) { build(:work_item, :incident, project: project) }
it_behaves_like 'command is not available'
end
end
context 'when user cannot admin link' do
subject(:service) { described_class.new(project, create(:user)) }
it_behaves_like 'command is not available'
end
end
end
describe '#available_commands' do

View File

@ -1,23 +1,237 @@
# frozen_string_literal: true
RSpec.shared_examples 'variable list drawer' do
it 'adds a new CI variable' do
click_button('Add variable')
it 'renders the list drawer' do
open_drawer
# For now, we just check that the drawer is displayed
expect(page).to have_selector('[data-testid="ci-variable-drawer"]')
end
# TODO: Add tests for ADDING a variable via drawer when feature is available
it 'adds a new CI variable' do
open_drawer
fill_variable('NEW_KEY', 'NEW_VALUE')
click_add_variable
wait_for_requests
page.within('[data-testid="ci-variable-table"]') do
expect(first(".js-ci-variable-row td[data-label='#{s_('CiVariables|Key')}']")).to have_content('NEW_KEY')
click_button('Reveal values')
expect(first(".js-ci-variable-row td[data-label='#{s_('CiVariables|Value')}']")).to have_content('NEW_VALUE')
end
end
it 'allows variable with empty value to be created' do
open_drawer
fill_variable('NEW_KEY')
page.within('[data-testid="ci-variable-drawer"]') do
expect(find_button('Add variable', disabled: false)).to be_present
end
end
it 'defaults to unmasked, expanded' do
open_drawer
fill_variable('NEW_KEY')
click_add_variable
wait_for_requests
page.within('[data-testid="ci-variable-table"]') do
key_column = first(".js-ci-variable-row:nth-child(1) td[data-label='#{s_('CiVariables|Key')}']")
expect(key_column).not_to have_content(s_('CiVariables|Masked'))
expect(key_column).to have_content(s_('CiVariables|Expanded'))
end
end
context 'with application setting for protected attribute' do
context 'when application setting is true' do
before do
stub_application_setting(protected_ci_variables: true)
visit page_path
end
it 'defaults to protected' do
open_drawer
page.within('[data-testid="ci-variable-drawer"]') do
expect(find('[data-testid="ci-variable-protected-checkbox"]')).to be_checked
end
end
end
context 'when application setting is false' do
before do
stub_application_setting(protected_ci_variables: false)
visit page_path
end
it 'defaults to unprotected' do
open_drawer
page.within('[data-testid="ci-variable-drawer"]') do
expect(find('[data-testid="ci-variable-protected-checkbox"]')).not_to be_checked
end
end
end
end
it 'edits a variable' do
key_column = first(".js-ci-variable-row td[data-label='#{s_('CiVariables|Key')}']")
value_column = first(".js-ci-variable-row td[data-label='#{s_('CiVariables|Value')}']")
expect(key_column).to have_content('test_key')
expect(key_column).not_to have_content(s_('CiVariables|Protected'))
expect(key_column).to have_content(s_('CiVariables|Masked'))
expect(key_column).to have_content(s_('CiVariables|Expanded'))
click_button('Edit')
fill_variable('EDITED_KEY', 'EDITED_VALUE')
toggle_protected
toggle_masked
toggle_expanded
click_button('Edit variable')
wait_for_requests
page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
expect(key_column).to have_content('EDITED_KEY')
expect(key_column).to have_content(s_('CiVariables|Protected'))
expect(key_column).not_to have_content(s_('CiVariables|Masked'))
expect(key_column).not_to have_content(s_('CiVariables|Expanded'))
click_button('Reveal values')
expect(value_column).to have_content('EDITED_VALUE')
end
end
it 'shows validation error for duplicate keys' do
open_drawer
fill_variable('NEW_KEY', 'NEW_VALUE')
click_add_variable
wait_for_requests
open_drawer
fill_variable('NEW_KEY', 'NEW_VALUE')
click_add_variable
wait_for_requests
expect(find('.flash-container')).to be_present
expect(find('[data-testid="alert-danger"]').text).to have_content('(NEW_KEY) has already been taken')
end
it 'shows validation error for unmaskable values' do
open_drawer
toggle_masked
fill_variable('EMPTY_MASK_KEY', '???')
expect(page).to have_content('This variable value does not meet the masking requirements.')
page.within('[data-testid="ci-variable-drawer"]') do
expect(find_button('Add variable', disabled: true)).to be_present
end
end
it 'handles multiple edits and a deletion' do
# Create two variables
open_drawer
fill_variable('akey', 'akeyvalue')
click_add_variable
wait_for_requests
open_drawer
fill_variable('zkey', 'zkeyvalue')
click_add_variable
wait_for_requests
expect(page).to have_selector('.js-ci-variable-row', count: 3)
# Remove the `akey` variable
page.within('[data-testid="ci-variable-table"]') do
page.within('.js-ci-variable-row:first-child') do
click_button('Edit')
end
end
# For now, we just check that the drawer is displayed
expect(page).to have_selector('[data-testid="ci-variable-drawer"]')
page.within('[data-testid="ci-variable-drawer"]') do
click_button('Delete variable') # opens confirmation modal
end
# TODO: Add tests for EDITING a variable via drawer when feature is available
page.within('[data-testid="ci-variable-drawer-confirm-delete-modal"]') do
click_button('Delete')
end
wait_for_requests
# Add another variable
open_drawer
fill_variable('ckey', 'ckeyvalue')
click_add_variable
wait_for_requests
# expect to find 3 rows of variables in alphabetical order
expect(page).to have_selector('.js-ci-variable-row', count: 3)
rows = all('.js-ci-variable-row')
expect(rows[0].find('td[data-label="Key"]')).to have_content('ckey')
expect(rows[1].find('td[data-label="Key"]')).to have_content('test_key')
expect(rows[2].find('td[data-label="Key"]')).to have_content('zkey')
end
private
def open_drawer
page.within('[data-testid="ci-variable-table"]') do
click_button('Add variable')
end
end
def click_add_variable
page.within('[data-testid="ci-variable-drawer"]') do
click_button('Add variable')
end
end
def fill_variable(key, value = '')
wait_for_requests
page.within('[data-testid="ci-variable-drawer"]') do
find('[data-testid="ci-variable-key"] input').set(key)
find('[data-testid="ci-variable-value"]').set(value) if value.present?
end
end
def toggle_protected
page.within('[data-testid="ci-variable-drawer"]') do
find('[data-testid="ci-variable-protected-checkbox"]').click
end
end
def toggle_masked
page.within('[data-testid="ci-variable-drawer"]') do
find('[data-testid="ci-variable-masked-checkbox"]').click
end
end
def toggle_expanded
page.within('[data-testid="ci-variable-drawer"]') do
find('[data-testid="ci-variable-expanded-checkbox"]').click
end
end
end