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:
-rw-r--r--Gemfile3
-rw-r--r--app/assets/javascripts/boards/components/board_blocked_icon.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue10
-rw-r--r--app/assets/javascripts/boards/constants.js4
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql17
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js2
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue73
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue136
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql13
-rw-r--r--app/assets/javascripts/pages/projects/project.js2
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue112
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_field.vue33
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue37
-rw-r--r--app/assets/javascripts/runner/constants.js4
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_form.vue243
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue199
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue156
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js2
-rw-r--r--app/controllers/admin/hook_logs_controller.rb37
-rw-r--r--app/controllers/admin/runners_controller.rb1
-rw-r--r--app/controllers/concerns/web_hooks/hook_log_actions.rb39
-rw-r--r--app/controllers/groups/runners_controller.rb3
-rw-r--r--app/controllers/projects/hook_logs_controller.rb27
-rw-r--r--app/controllers/projects/settings/integration_hook_logs_controller.rb10
-rw-r--r--app/controllers/search_controller.rb7
-rw-r--r--app/views/projects/forks/new.html.haml2
-rw-r--r--config/feature_flags/development/ci_variables_refactoring_to_variable.yml8
-rw-r--r--config/feature_flags/development/global_search_custom_slis.yml8
-rw-r--r--config/feature_flags/development/runner_list_stacked_layout.yml8
-rw-r--r--config/feature_flags/development/runner_list_stacked_layout_admin.yml8
-rw-r--r--config/initializers/zz_metrics.rb1
-rw-r--r--doc/architecture/blueprints/ci_data_decay/index.md2
-rw-r--r--doc/architecture/blueprints/ci_scale/index.md2
-rw-r--r--doc/ci/index.md59
-rw-r--r--doc/development/development_processes.md2
-rw-r--r--doc/development/documentation/restful_api_styleguide.md2
-rw-r--r--doc/development/export_csv.md2
-rw-r--r--doc/development/fe_guide/storybook.md4
-rw-r--r--doc/development/fips_compliance.md2
-rw-r--r--doc/development/integrations/secure.md2
-rw-r--r--doc/development/sec/index.md12
-rw-r--r--doc/tutorials/make_your_first_git_commit.md2
-rw-r--r--doc/user/admin_area/settings/rate_limit_on_pipelines_creation.md2
-rw-r--r--doc/user/application_security/container_scanning/index.md6
-rw-r--r--doc/user/group/saml_sso/group_sync.md4
-rw-r--r--doc/user/group/saml_sso/scim_setup.md2
-rw-r--r--doc/user/infrastructure/iac/troubleshooting.md4
-rw-r--r--doc/user/packages/container_registry/reduce_container_registry_data_transfer.md2
-rw-r--r--doc/user/permissions.md2
-rw-r--r--doc/user/project/import/index.md2
-rw-r--r--doc/user/project/integrations/github.md2
-rw-r--r--doc/user/project/settings/index.md2
-rw-r--r--lib/api/helpers/projects_helpers.rb1
-rw-r--r--lib/api/search.rb7
-rw-r--r--lib/gitlab/ci/config/entry/current_variables.rb49
-rw-r--r--lib/gitlab/ci/config/entry/legacy_variables.rb46
-rw-r--r--lib/gitlab/ci/config/entry/root.rb3
-rw-r--r--lib/gitlab/ci/config/entry/variable.rb98
-rw-r--r--lib/gitlab/ci/config/entry/variables.rb40
-rw-r--r--lib/gitlab/config/entry/composable_hash.rb10
-rw-r--r--lib/gitlab/config/entry/validators.rb13
-rw-r--r--lib/gitlab/metrics/global_search_slis.rb101
-rw-r--r--locale/gitlab.pot21
-rw-r--r--qa/qa/page/project/fork/new.rb29
-rw-r--r--qa/qa/resource/fork.rb2
-rw-r--r--spec/controllers/search_controller_spec.rb11
-rw-r--r--spec/features/admin/admin_runners_spec.rb2
-rw-r--r--spec/features/projects/fork_spec.rb5
-rw-r--r--spec/frontend/boards/components/board_blocked_icon_spec.js74
-rw-r--r--spec/frontend/boards/mock_data.js74
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_form_spec.js167
-rw-r--r--spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js177
-rw-r--r--spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js164
-rw-r--r--spec/frontend/runner/components/cells/runner_summary_field_spec.js49
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js62
-rw-r--r--spec/frontend/set_status_modal/set_status_form_spec.js167
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js8
-rw-r--r--spec/frontend/surveys/merge_request_performance/app_spec.js40
-rw-r--r--spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js13
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb3
-rw-r--r--spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb173
-rw-r--r--spec/lib/gitlab/ci/config/entry/processable_spec.rb32
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb27
-rw-r--r--spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/config/entry/variable_spec.rb212
-rw-r--r--spec/lib/gitlab/ci/config/entry/variables_spec.rb82
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb117
-rw-r--r--spec/lib/gitlab/config/entry/composable_hash_spec.rb9
-rw-r--r--spec/lib/gitlab/metrics/global_search_slis_spec.rb114
-rw-r--r--spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb23
-rw-r--r--spec/requests/admin/hook_logs_controller_spec.rb15
-rw-r--r--spec/requests/api/search_spec.rb11
-rw-r--r--spec/requests/projects/hook_logs_controller_spec.rb19
-rw-r--r--spec/requests/projects/settings/integration_hook_logs_controller_spec.rb20
-rw-r--r--spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb47
-rw-r--r--spec/tooling/danger/project_helper_spec.rb2
-rw-r--r--tooling/danger/project_helper.rb4
112 files changed, 3056 insertions, 697 deletions
diff --git a/Gemfile b/Gemfile
index 40f1674ba4d..3048b5a7871 100644
--- a/Gemfile
+++ b/Gemfile
@@ -385,7 +385,9 @@ group :development, :test do
gem 'haml_lint', '~> 0.40.0', require: false
gem 'bundler-audit', '~> 0.7.0.1', require: false
+ # Benchmarking & profiling
gem 'benchmark-ips', '~> 2.3.0', require: false
+ gem 'benchmark-memory', '~> 0.1', require: false
gem 'knapsack', '~> 1.21.1'
gem 'crystalball', '~> 0.7.0', require: false
@@ -460,7 +462,6 @@ gem 'ruby-prof', '~> 1.3.0'
gem 'stackprof', '~> 0.2.21', require: false
gem 'rbtrace', '~> 0.4', require: false
gem 'memory_profiler', '~> 0.9', require: false
-gem 'benchmark-memory', '~> 0.1', require: false
gem 'activerecord-explain-analyze', '~> 0.1', require: false
# OAuth
diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue
index 73b3db3b387..3f8a596abd8 100644
--- a/app/assets/javascripts/boards/components/board_blocked_icon.vue
+++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
@@ -10,10 +10,12 @@ export default {
i18n: {
issuableType: {
[issuableTypes.issue]: __('issue'),
+ [issuableTypes.epic]: __('epic'),
},
},
graphQLIdType: {
[issuableTypes.issue]: TYPE_ISSUE,
+ [issuableTypes.epic]: TYPE_EPIC,
},
referenceFormatter: {
[issuableTypes.issue]: (r) => r.split('/')[1],
@@ -40,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [issuableTypes.issue].includes(value);
+ return [issuableTypes.issue, issuableTypes.epic].includes(value);
},
},
},
@@ -53,14 +55,21 @@ export default {
return blockingIssuablesQueries[this.issuableType].query;
},
variables() {
+ if (this.isEpic) {
+ return {
+ fullPath: this.item.group.fullPath,
+ iid: Number(this.item.iid),
+ };
+ }
return {
id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
};
},
update(data) {
this.skip = true;
+ const issuable = this.isEpic ? data?.group?.issuable : data?.issuable;
- return data?.issuable?.blockingIssuables?.nodes || [];
+ return issuable?.blockingIssuables?.nodes || [];
},
error(error) {
const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
@@ -77,13 +86,16 @@ export default {
};
},
computed: {
+ isEpic() {
+ return this.issuableType === issuableTypes.epic;
+ },
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
return {
...i,
title: truncate(i.title, this.$options.textTruncateWidth),
- reference: referenceFormatter[this.issuableType](i.reference),
+ reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference),
};
});
},
@@ -106,6 +118,9 @@ export default {
},
);
},
+ blockIcon() {
+ return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked';
+ },
glIconId() {
return `blocked-icon-${this.uniqueId}`;
},
@@ -153,7 +168,7 @@ export default {
<gl-icon
:id="glIconId"
ref="icon"
- name="issue-block"
+ :name="blockIcon"
class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500"
data-testid="issue-blocked-icon"
@mouseenter="handleMouseEnter"
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 098d429a62a..92a623d65d4 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -274,16 +274,16 @@ export default {
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden"
>
<gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
- <work-item-type-icon
- v-if="showWorkItemTypeIcon"
- :work-item-type="item.type"
- show-tooltip-on-hover
- />
<span
v-if="item.referencePath"
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="item.type"
+ show-tooltip-on-hover
+ />
<tooltip-on-truncate
v-if="showReferencePath"
:title="itemReferencePath"
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index d745eed556f..ed22a375271 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
+import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
@@ -70,6 +71,9 @@ export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
},
+ [issuableTypes.epic]: {
+ query: boardBlockingEpicsQuery,
+ },
};
export const updateListQueries = {
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql
new file mode 100644
index 00000000000..071a6d7410f
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql
@@ -0,0 +1,17 @@
+query BoardBlockingEpics($fullPath: ID!, $iid: ID) {
+ group(fullPath: $fullPath) {
+ id
+ issuable: epic(iid: $iid) {
+ id
+ blockingIssuables: blockedByEpics {
+ nodes {
+ id
+ iid
+ title
+ reference(full: true)
+ webUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 9329bbcb2c7..2d4226ccd33 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -2,7 +2,7 @@ import { ContentEditor } from './index';
export default {
component: ContentEditor,
- title: 'content_editor/components/content_editor',
+ title: 'content_editor/content_editor',
};
const Template = (_, { argTypes }) => ({
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index f92a40e057f..6951a8a1bc3 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -3,15 +3,12 @@ import {
GlIcon,
GlLink,
GlForm,
- GlFormInputGroup,
- GlInputGroupText,
GlFormInput,
GlFormGroup,
GlFormTextarea,
GlButton,
GlFormRadio,
GlFormRadioGroup,
- GlFormSelect,
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
@@ -21,6 +18,7 @@ import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
+import ProjectNamespace from './project_namespace.vue';
const PRIVATE_VISIBILITY = 'private';
const INTERNAL_VISIBILITY = 'internal';
@@ -39,28 +37,18 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({
feedback: null,
});
-function sortNamespaces(namespaces) {
- if (!namespaces || !namespaces?.length) {
- return namespaces;
- }
-
- return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name));
-}
-
export default {
components: {
GlForm,
GlIcon,
GlLink,
GlButton,
- GlFormInputGroup,
- GlInputGroupText,
GlFormInput,
GlFormTextarea,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
- GlFormSelect,
+ ProjectNamespace,
},
directives: {
validation: validation(),
@@ -72,9 +60,6 @@ export default {
visibilityHelpPath: {
default: '',
},
- endpoint: {
- default: '',
- },
projectFullPath: {
default: '',
},
@@ -96,6 +81,9 @@ export default {
restrictedVisibilityLevels: {
default: [],
},
+ namespaceId: {
+ default: '',
+ },
},
data() {
const form = {
@@ -117,14 +105,10 @@ export default {
};
return {
isSaving: false,
- namespaces: [],
form,
};
},
computed: {
- projectUrl() {
- return `${gon.gitlab_url}/`;
- },
projectVisibilityLevel() {
return VISIBILITY_LEVEL[this.projectVisibility];
},
@@ -188,32 +172,30 @@ export default {
},
watch: {
// eslint-disable-next-line func-names
- 'form.fields.namespace.value': function () {
- this.form.fields.visibility.value =
- this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY;
- },
- // eslint-disable-next-line func-names
'form.fields.name.value': function (newVal) {
this.form.fields.slug.value = kebabCase(newVal);
},
},
- mounted() {
- this.fetchNamespaces();
- },
methods: {
- async fetchNamespaces() {
- const { data } = await axios.get(this.endpoint);
- this.namespaces = sortNamespaces(data.namespaces);
- },
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
},
getInitialVisibilityValue() {
return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility;
},
+ setNamespace(namespace) {
+ this.form.fields.visibility.value =
+ this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY;
+ this.form.fields.namespace.value = namespace;
+ this.form.fields.namespace.state = true;
+ },
async onSubmit() {
this.form.showValidation = true;
+ if (!this.form.fields.namespace.value) {
+ this.form.fields.namespace.state = false;
+ }
+
if (!this.form.state) {
return;
}
@@ -282,30 +264,7 @@ export default {
:state="form.fields.namespace.state"
:invalid-feedback="s__('ForkProject|Please select a namespace')"
>
- <gl-form-input-group>
- <template #prepend>
- <gl-input-group-text>
- {{ projectUrl }}
- </gl-input-group-text>
- </template>
- <gl-form-select
- id="fork-url"
- v-model="form.fields.namespace.value"
- v-validation:[form.showValidation]
- name="namespace"
- data-testid="fork-url-input"
- data-qa-selector="fork_namespace_dropdown"
- :state="form.fields.namespace.state"
- required
- >
- <template #first>
- <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
- </template>
- <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
- {{ namespace.full_name }}
- </option>
- </gl-form-select>
- </gl-form-input-group>
+ <project-namespace @select="setNamespace" />
</gl-form-group>
</div>
<div class="gl-flex-basis-half">
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
new file mode 100644
index 00000000000..2b3055ecd66
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -0,0 +1,136 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchForkableNamespaces from '../queries/search_forkable_namespaces.query.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+ },
+ apollo: {
+ project: {
+ query: searchForkableNamespaces,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ search: this.search,
+ };
+ },
+ skip() {
+ const { length } = this.search;
+ return length > 0 && length < MINIMUM_SEARCH_LENGTH;
+ },
+ error(error) {
+ createFlash({
+ message: s__(
+ 'ForkProject|Something went wrong while loading data. Please refresh the page to try again.',
+ ),
+ captureError: true,
+ error,
+ });
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ inject: ['projectFullPath'],
+ data() {
+ return {
+ project: {},
+ search: '',
+ selectedNamespace: null,
+ };
+ },
+ computed: {
+ rootUrl() {
+ return `${gon.gitlab_url}/`;
+ },
+ namespaces() {
+ return this.project.forkTargets?.nodes || [];
+ },
+ hasMatches() {
+ return this.namespaces.length;
+ },
+ dropdownText() {
+ return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace');
+ },
+ },
+ methods: {
+ handleDropdownShown() {
+ this.$refs.search.focusInput();
+ },
+ setNamespace(namespace) {
+ const id = getIdFromGraphQLId(namespace.id);
+
+ this.$emit('select', {
+ id,
+ name: namespace.name,
+ visibility: namespace.visibility,
+ });
+
+ this.selectedNamespace = { id, fullPath: namespace.fullPath };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group class="gl-w-full">
+ <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{
+ rootUrl
+ }}</gl-button>
+
+ <gl-dropdown
+ class="gl-flex-grow-1"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
+ data-qa-selector="select_namespace_dropdown"
+ data-testid="select_namespace_dropdown"
+ no-flip
+ @shown="handleDropdownShown"
+ >
+ <template #button-text>
+ <gl-truncate :text="dropdownText" position="start" with-tooltip />
+ </template>
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ :is-loading="$apollo.queries.project.loading"
+ data-qa-selector="select_namespace_dropdown_search_field"
+ data-testid="select_namespace_dropdown_search_field"
+ />
+ <template v-if="!$apollo.queries.project.loading">
+ <template v-if="hasMatches">
+ <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="namespace of namespaces"
+ :key="namespace.id"
+ data-qa-selector="select_namespace_dropdown_item"
+ @click="setNamespace(namespace)"
+ >
+ {{ namespace.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index cbf74f755e7..d3a5ce5390f 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import App from './components/app.vue';
const mountElement = document.getElementById('fork-groups-mount-element');
@@ -17,9 +19,14 @@ const {
restrictedVisibilityLevels,
} = mountElement.dataset;
+Vue.use(VueApollo);
+
// eslint-disable-next-line no-new
new Vue({
el: mountElement,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
newGroupPath,
visibilityHelpPath,
diff --git a/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql
new file mode 100644
index 00000000000..089b57815bd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql
@@ -0,0 +1,13 @@
+query searchForkableNamespaces($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ id
+ forkTargets(search: $search) {
+ nodes {
+ id
+ fullPath
+ name
+ visibility
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 032e2410233..ccabaad5b2e 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -141,7 +141,7 @@ export default class Project {
if (doesPathContainRef) {
// We are ignoring the url containing the ref portion
// and plucking the thereafter portion to reconstructure the url that is correct
- const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0];
+ const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
selectedUrl.searchParams.set('path', targetPath);
selectedUrl.hash = window.location.hash;
}
diff --git a/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
new file mode 100644
index 00000000000..37099edc7d2
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RunnerName from '../runner_name.vue';
+import RunnerTags from '../runner_tags.vue';
+import RunnerTypeBadge from '../runner_type_badge.vue';
+
+import { formatJobCount } from '../../utils';
+import {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_VERSION_LABEL,
+ I18N_LAST_CONTACT_LABEL,
+ I18N_CREATED_AT_LABEL,
+} from '../../constants';
+import RunnerSummaryField from './runner_summary_field.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlSprintf,
+ TimeAgo,
+ RunnerSummaryField,
+ RunnerName,
+ RunnerTags,
+ RunnerTypeBadge,
+ RunnerUpgradeStatusIcon: () =>
+ import('ee_component/runner/components/runner_upgrade_status_icon.vue'),
+ TooltipOnTruncate,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ jobCount() {
+ return formatJobCount(this.runner.jobCount);
+ },
+ },
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_VERSION_LABEL,
+ I18N_LAST_CONTACT_LABEL,
+ I18N_CREATED_AT_LABEL,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div>
+ <slot :runner="runner" name="runner-name">
+ <runner-name :runner="runner" />
+ </slot>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip
+ :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ />
+ <runner-type-badge :type="runner.runnerType" size="sm" />
+ </div>
+
+ <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2">
+ <div class="gl-flex-shrink-0">
+ <runner-upgrade-status-icon :runner="runner" />
+ <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL">
+ <template #version>{{ runner.version }}</template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description">
+ {{ runner.description }}
+ </tooltip-on-truncate>
+ </div>
+
+ <div>
+ <runner-summary-field icon="clock">
+ <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ <template v-else>{{ __('Never') }}</template>
+ </template>
+ </gl-sprintf>
+ </runner-summary-field>
+
+ <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')">
+ {{ runner.ipAddress }}
+ </runner-summary-field>
+
+ <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')">
+ {{ jobCount }}
+ </runner-summary-field>
+
+ <runner-summary-field icon="calendar">
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </runner-summary-field>
+ </div>
+
+ <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
new file mode 100644
index 00000000000..1bbbd55089a
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
+ <gl-icon v-if="icon" :name="icon" />
+ <!-- display tooltip as a label for screen readers -->
+ <span class="gl-sr-only">{{ tooltip }}</span>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 708375b0426..534317d6a57 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -2,11 +2,13 @@
import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
+import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue';
import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
@@ -19,6 +21,12 @@ const defaultFields = [
tableField({ key: 'actions', label: '' }),
];
+const stackedLayoutFields = [
+ tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }),
+ tableField({ key: 'summary', label: s__('Runners|Runner') }),
+ tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }),
+];
+
export default {
components: {
GlFormCheckbox,
@@ -28,11 +36,13 @@ export default {
TimeAgo,
RunnerStatusPopover,
RunnerSummaryCell,
+ RunnerStackedSummaryCell,
RunnerStatusCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
apollo: {
checkedRunnerIds: {
query: checkedRunnerIdsQuery,
@@ -62,6 +72,11 @@ export default {
return { checkedRunnerIds: [] };
},
computed: {
+ stackedLayout() {
+ // runner_list_stacked_layout_admin or runner_list_stacked_layout
+ const { runnerListStackedLayoutAdmin, runnerListStackedLayout } = this.glFeatures || {};
+ return runnerListStackedLayoutAdmin || runnerListStackedLayout;
+ },
tableClass() {
// <gl-table-lite> does not provide a busy state, add
// simple support for it.
@@ -71,6 +86,8 @@ export default {
};
},
fields() {
+ const fields = this.stackedLayout ? stackedLayoutFields : defaultFields;
+
if (this.checkable) {
const checkboxField = tableField({
key: 'checkbox',
@@ -78,9 +95,9 @@ export default {
thClasses: ['gl-w-9'],
tdClass: ['gl-text-center'],
});
- return [checkboxField, ...defaultFields];
+ return [checkboxField, ...fields];
}
- return defaultFields;
+ return fields;
},
},
methods: {
@@ -138,24 +155,30 @@ export default {
</template>
<template #cell(summary)="{ item, index }">
- <runner-summary-cell :runner="item">
+ <runner-stacked-summary-cell v-if="stackedLayout" :runner="item">
+ <template #runner-name="{ runner }">
+ <slot name="runner-name" :runner="runner" :index="index"></slot>
+ </template>
+ </runner-stacked-summary-cell>
+
+ <runner-summary-cell v-else :runner="item">
<template #runner-name="{ runner }">
<slot name="runner-name" :runner="runner" :index="index"></slot>
</template>
</runner-summary-cell>
</template>
- <template #cell(version)="{ item: { version } }">
+ <template v-if="!stackedLayout" #cell(version)="{ item: { version } }">
<tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version">
{{ version }}
</tooltip-on-truncate>
</template>
- <template #cell(jobCount)="{ item: { jobCount } }">
- {{ formatJobCount(jobCount) }}
+ <template v-if="!stackedLayout" #cell(jobCount)="{ item: { jobCount } }">
+ <span data-testid="job-count">{{ formatJobCount(jobCount) }}</span>
</template>
- <template #cell(contactedAt)="{ item: { contactedAt } }">
+ <template v-if="!stackedLayout" #cell(contactedAt)="{ item: { contactedAt } }">
<time-ago v-if="contactedAt" :time="contactedAt" />
<template v-else>{{ __('Never') }}</template>
</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index ed1afcbf691..5485ea35e81 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -77,9 +77,13 @@ export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__(
);
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
+// List
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.',
);
+export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
+export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
+export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
// Runner details
diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
index ce23bddb898..a12ba7a751a 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
@@ -9,6 +9,7 @@ fragment ListItemShared on CiRunner {
locked
jobCount
tagList
+ createdAt
contactedAt
status(legacyMode: null)
userPermissions {
diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue
new file mode 100644
index 00000000000..79af9143861
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/set_status_form.vue
@@ -0,0 +1,243 @@
+<script>
+import {
+ GlButton,
+ GlTooltipDirective,
+ GlIcon,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import $ from 'jquery';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import * as Emoji from '~/emoji';
+import { __, s__ } from '~/locale';
+import { timeRanges } from '~/vue_shared/constants';
+
+export const AVAILABILITY_STATUS = {
+ BUSY: 'busy',
+ NOT_SET: 'not_set',
+};
+
+const statusTimeRanges = [
+ {
+ label: __('Never'),
+ name: 'never',
+ },
+ ...timeRanges,
+];
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ defaultEmoji: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ emoji: {
+ type: String,
+ required: true,
+ },
+ message: {
+ type: String,
+ required: true,
+ },
+ availability: {
+ type: Boolean,
+ required: true,
+ },
+ clearStatusAfter: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ currentClearStatusAfter: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ defaultEmojiTag: '',
+ emojiTag: '',
+ };
+ },
+ computed: {
+ isCustomEmoji() {
+ return this.emoji !== this.defaultEmoji;
+ },
+ isDirty() {
+ return Boolean(this.message.length || this.isCustomEmoji);
+ },
+ noEmoji() {
+ return this.emojiTag === '';
+ },
+ },
+ mounted() {
+ this.setupEmojiListAndAutocomplete();
+ },
+ methods: {
+ async setupEmojiListAndAutocomplete() {
+ const emojiAutocomplete = new GfmAutoComplete();
+ emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true });
+
+ if (this.emoji) {
+ this.emojiTag = Emoji.glEmojiTag(this.emoji);
+ }
+ this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
+
+ this.setDefaultEmoji();
+ },
+ setDefaultEmoji() {
+ const { emojiTag } = this;
+ const hasStatusMessage = Boolean(this.message.length);
+ if (hasStatusMessage && emojiTag) {
+ return;
+ }
+
+ if (hasStatusMessage) {
+ this.emojiTag = this.defaultEmojiTag;
+ } else if (emojiTag === this.defaultEmojiTag) {
+ this.clearEmoji();
+ }
+ },
+ handleEmojiClick(emoji) {
+ this.$emit('emoji-click', emoji);
+
+ this.emojiTag = Emoji.glEmojiTag(emoji);
+ },
+ clearEmoji() {
+ if (this.emojiTag) {
+ this.emojiTag = '';
+ }
+ },
+ clearStatusInputs() {
+ this.$emit('emoji-click', '');
+ this.$emit('message-input', '');
+ this.clearEmoji();
+ },
+ },
+ statusTimeRanges,
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ i18n: {
+ statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`),
+ clearStatusButtonLabel: s__('SetStatusModal|Clear status'),
+ availabilityCheckboxLabel: s__('SetStatusModal|Busy'),
+ availabilityCheckboxHelpText: s__(
+ 'SetStatusModal|An indicator appears next to your name and avatar',
+ ),
+ clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'),
+ clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input :value="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
+ <gl-form-input-group class="gl-mb-5">
+ <gl-form-input
+ ref="statusMessageField"
+ :value="message"
+ :placeholder="$options.i18n.statusMessagePlaceholder"
+ class="js-status-message-field"
+ name="user[status][message]"
+ @keyup="setDefaultEmoji"
+ @input="$emit('message-input', $event)"
+ @keyup.enter.prevent
+ />
+ <template #prepend>
+ <emoji-picker
+ dropdown-class="gl-h-full"
+ toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ boundary="viewport"
+ :right="false"
+ @click="handleEmojiClick"
+ >
+ <template #button-content>
+ <span
+ v-if="noEmoji"
+ class="no-emoji-placeholder position-relative"
+ data-testid="no-emoji-placeholder"
+ >
+ <gl-icon name="slight-smile" class="award-control-icon-neutral" />
+ <gl-icon name="smiley" class="award-control-icon-positive" />
+ <gl-icon name="smile" class="award-control-icon-super-positive" />
+ </span>
+ <span v-else>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="emojiTag"
+ data-testid="selected-emoji"
+ ></span>
+ </span>
+ </template>
+ </emoji-picker>
+ </template>
+ <template v-if="isDirty" #append>
+ <gl-button
+ v-gl-tooltip.bottom
+ :title="$options.i18n.clearStatusButtonLabel"
+ :aria-label="$options.i18n.clearStatusButtonLabel"
+ icon="close"
+ class="js-clear-user-status-button"
+ @click="clearStatusInputs"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <gl-form-checkbox
+ :checked="availability"
+ class="gl-mb-5"
+ data-testid="user-availability-checkbox"
+ @input="$emit('availability-input', $event)"
+ >
+ {{ $options.i18n.availabilityCheckboxLabel }}
+ <template #help>
+ {{ $options.i18n.availabilityCheckboxHelpText }}
+ </template>
+ </gl-form-checkbox>
+
+ <div class="form-group">
+ <div class="gl-display-flex gl-align-items-baseline">
+ <span class="gl-mr-3">{{ $options.i18n.clearStatusAfterDropdownLabel }}</span>
+ <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
+ <gl-dropdown-item
+ v-for="after in $options.statusTimeRanges"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="$emit('clear-status-after-click', after)"
+ >{{ after.label }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <p
+ v-if="currentClearStatusAfter.length"
+ class="gl-mt-3 gl-text-gray-400 gl-font-sm"
+ data-testid="clear-status-at-message"
+ >
+ <gl-sprintf :message="$options.i18n.clearStatusAfterMessage">
+ <template #date>{{ currentClearStatusAfter }}</template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 2cdec8fc481..3d0c9d76435 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,28 +1,14 @@
<script>
-import {
- GlButton,
- GlToast,
- GlModal,
- GlTooltipDirective,
- GlIcon,
- GlFormCheckbox,
- GlFormInput,
- GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
- GlSafeHtmlDirective,
-} from '@gitlab/ui';
-import $ from 'jquery';
+import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui';
import Vue from 'vue';
-import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import * as Emoji from '~/emoji';
import createFlash from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
import { timeRanges } from '~/vue_shared/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isUserBusy } from './utils';
+import SetStatusForm from './set_status_form.vue';
export const AVAILABILITY_STATUS = {
BUSY: 'busy',
@@ -41,15 +27,8 @@ const statusTimeRanges = [
export default {
components: {
- GlButton,
- GlIcon,
GlModal,
- GlFormCheckbox,
- GlFormInput,
- GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
- EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ SetStatusForm,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -85,26 +64,12 @@ export default {
return {
defaultEmojiTag: '',
emoji: this.currentEmoji,
- emojiMenu: null,
- emojiTag: '',
message: this.currentMessage,
modalId: 'set-user-status-modal',
- noEmoji: true,
availability: isUserBusy(this.currentAvailability),
clearStatusAfter: statusTimeRanges[0],
- clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
- date: this.currentClearStatusAfter,
- }),
};
},
- computed: {
- isCustomEmoji() {
- return this.emoji !== this.defaultEmoji;
- },
- isDirty() {
- return Boolean(this.message.length || this.isCustomEmoji);
- },
- },
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -112,62 +77,10 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- setupEmojiListAndAutocomplete() {
- const emojiAutocomplete = new GfmAutoComplete();
- emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
-
- Emoji.initEmojiMap()
- .then(() => {
- if (this.emoji) {
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- }
- this.noEmoji = this.emoji === '';
- this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
-
- this.setDefaultEmoji();
- })
- .catch(() =>
- createFlash({
- message: __('Failed to load emoji list.'),
- }),
- );
- },
- setDefaultEmoji() {
- const { emojiTag } = this;
- const hasStatusMessage = Boolean(this.message.length);
- if (hasStatusMessage && emojiTag) {
- return;
- }
-
- if (hasStatusMessage) {
- this.noEmoji = false;
- this.emojiTag = this.defaultEmojiTag;
- } else if (emojiTag === this.defaultEmojiTag) {
- this.noEmoji = true;
- this.clearEmoji();
- }
- },
- setEmoji(emoji) {
- this.emoji = emoji;
- this.noEmoji = false;
- this.clearEmoji();
-
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- },
- clearEmoji() {
- if (this.emojiTag) {
- this.emojiTag = '';
- }
- },
- clearStatusInputs() {
- this.emoji = '';
- this.message = '';
- this.noEmoji = true;
- this.clearEmoji();
- },
removeStatus() {
this.availability = false;
- this.clearStatusInputs();
+ this.emoji = '';
+ this.message = '';
this.setStatus();
},
setStatus() {
@@ -197,9 +110,18 @@ export default {
this.closeModal();
},
- setClearStatusAfter(after) {
+ handleMessageInput(value) {
+ this.message = value;
+ },
+ handleEmojiClick(emoji) {
+ this.emoji = emoji;
+ },
+ handleClearStatusAfterClick(after) {
this.clearStatusAfter = after;
},
+ handleAvailabilityInput(value) {
+ this.availability = value;
+ },
},
statusTimeRanges,
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@@ -215,85 +137,20 @@ export default {
:action-primary="$options.actionPrimary"
:action-secondary="$options.actionSecondary"
modal-class="set-user-status-modal"
- @shown="setupEmojiListAndAutocomplete"
@primary="setStatus"
@secondary="removeStatus"
>
- <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
- <gl-form-input-group class="gl-mb-5">
- <gl-form-input
- ref="statusMessageField"
- v-model="message"
- :placeholder="s__(`SetStatusModal|What's your status?`)"
- class="js-status-message-field"
- name="user[status][message]"
- @keyup="setDefaultEmoji"
- @keyup.enter.prevent
- />
- <template #prepend>
- <emoji-picker
- dropdown-class="gl-h-full"
- toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
- boundary="viewport"
- :right="false"
- @click="setEmoji"
- >
- <template #button-content>
- <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
- <span
- v-show="noEmoji"
- class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
- >
- <gl-icon name="slight-smile" class="award-control-icon-neutral" />
- <gl-icon name="smiley" class="award-control-icon-positive" />
- <gl-icon name="smile" class="award-control-icon-super-positive" />
- </span>
- </template>
- </emoji-picker>
- </template>
- <template v-if="isDirty" #append>
- <gl-button
- v-gl-tooltip.bottom
- :title="s__('SetStatusModal|Clear status')"
- :aria-label="s__('SetStatusModal|Clear status')"
- icon="close"
- class="js-clear-user-status-button"
- @click="clearStatusInputs"
- />
- </template>
- </gl-form-input-group>
-
- <gl-form-checkbox
- v-model="availability"
- class="gl-mb-5"
- data-testid="user-availability-checkbox"
- >
- {{ s__('SetStatusModal|Busy') }}
- <template #help>
- {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
- </template>
- </gl-form-checkbox>
-
- <div class="form-group">
- <div class="gl-display-flex gl-align-items-baseline">
- <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
- <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
- <gl-dropdown-item
- v-for="after in $options.statusTimeRanges"
- :key="after.name"
- :data-testid="after.name"
- @click="setClearStatusAfter(after)"
- >{{ after.label }}</gl-dropdown-item
- >
- </gl-dropdown>
- </div>
- <div
- v-if="currentClearStatusAfter.length"
- class="gl-mt-3 gl-text-gray-400 gl-font-sm"
- data-testid="clear-status-at-message"
- >
- {{ clearStatusAfterMessage }}
- </div>
- </div>
+ <set-status-form
+ :default-emoji="defaultEmoji"
+ :emoji="emoji"
+ :message="message"
+ :availability="availability"
+ :clear-status-after="clearStatusAfter"
+ :current-clear-status-after="currentClearStatusAfter"
+ @message-input="handleMessageInput"
+ @emoji-click="handleEmojiClick"
+ @clear-status-after-click="handleClearStatusAfterClick"
+ @availability-input="handleAvailabilityInput"
+ />
</gl-modal>
</template>
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index d48bdf7d4ed..df114c27908 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -19,6 +19,8 @@ const steps = [
},
];
+const MR_RENDER_LS_KEY = 'mr_survey_rendered';
+
export default {
name: 'MergeRequestExperienceSurveyApp',
components: {
@@ -68,9 +70,20 @@ export default {
onQueryLoaded({ shouldShowCallout }) {
this.visible = shouldShowCallout;
if (!this.visible) this.$emit('close');
+ else if (!localStorage?.getItem(MR_RENDER_LS_KEY)) {
+ this.track('survey:mr_experience', {
+ label: 'render',
+ extra: {
+ accountAge: this.accountAge,
+ },
+ });
+ localStorage?.setItem(MR_RENDER_LS_KEY, '1');
+ }
},
onRate(event) {
+ this.$refs.dismisser?.dismiss();
this.$emit('rate');
+ localStorage?.removeItem(MR_RENDER_LS_KEY);
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
@@ -87,21 +100,18 @@ export default {
},
handleKeyup(e) {
if (e.key !== 'Escape') return;
- this.$emit('close');
- this.$refs.dismisser?.dismiss();
- this.trackDismissal();
+ this.dismiss();
},
- close() {
- this.trackDismissal();
+ dismiss() {
+ this.$refs.dismisser?.dismiss();
this.$emit('close');
- },
- trackDismissal() {
this.track('survey:mr_experience', {
label: 'dismiss',
extra: {
accountAge: this.accountAge,
},
});
+ localStorage?.removeItem(MR_RENDER_LS_KEY);
},
},
};
@@ -113,79 +123,71 @@ export default {
feature-name="mr_experience_survey"
@queryResult.once="onQueryLoaded"
>
- <template #default="{ dismiss }">
- <aside
- class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
- :aria-label="$options.i18n.survey"
- >
- <transition name="survey-slide-up">
+ <aside
+ class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
+ :aria-label="$options.i18n.survey"
+ >
+ <transition name="survey-slide-up">
+ <div
+ v-if="visible"
+ class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ >
+ <gl-button
+ v-tooltip="$options.i18n.close"
+ :aria-label="$options.i18n.close"
+ variant="default"
+ category="tertiary"
+ class="gl-top-4 gl-right-3 gl-absolute"
+ icon="close"
+ @click="dismiss"
+ />
<div
- v-if="visible"
- class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ v-if="stepIndex === 0"
+ class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
+ role="note"
>
- <gl-button
- v-tooltip="$options.i18n.close"
- :aria-label="$options.i18n.close"
- variant="default"
- category="tertiary"
- class="gl-top-4 gl-right-3 gl-absolute"
- icon="close"
- @click="
- dismiss();
- close();
- "
- />
- <div
- v-if="stepIndex === 0"
- class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
- role="note"
- >
- <p class="gl-m-0">
- <gl-sprintf :message="$options.i18n.legal">
- <template #link="{ content }">
- <a
- class="gl-text-decoration-underline gl-text-gray-500"
- href="https://about.gitlab.com/privacy/"
- target="_blank"
- rel="noreferrer nofollow"
- v-text="content"
- ></a>
- </template>
- </gl-sprintf>
- </p>
- </div>
- <div class="gl-relative">
- <div class="gl-absolute">
- <div
- v-safe-html="$options.gitlabLogo"
- aria-hidden="true"
- class="mr-experience-survey-logo"
- ></div>
- </div>
+ <p class="gl-m-0">
+ <gl-sprintf :message="$options.i18n.legal">
+ <template #link="{ content }">
+ <a
+ class="gl-text-decoration-underline gl-text-gray-500"
+ href="https://about.gitlab.com/privacy/"
+ target="_blank"
+ rel="noreferrer nofollow"
+ v-text="content"
+ ></a>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="gl-relative">
+ <div class="gl-absolute">
+ <div
+ v-safe-html="$options.gitlabLogo"
+ aria-hidden="true"
+ class="mr-experience-survey-logo"
+ ></div>
</div>
- <section v-if="step">
- <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
- <gl-sprintf :message="step.question">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <satisfaction-rate
- aria-labelledby="mr_survey_question"
- class="gl-mt-5"
- @rate="
- dismiss();
- onRate($event);
- "
- />
- </section>
- <section v-else class="gl-px-7">
- {{ $options.i18n.thanks }}
- </section>
</div>
- </transition>
- </aside>
- </template>
+ <section v-if="step">
+ <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
+ <gl-sprintf :message="step.question">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <satisfaction-rate
+ aria-labelledby="mr_survey_question"
+ class="gl-mt-5"
+ @rate="onRate"
+ />
+ </section>
+ <section v-else class="gl-px-7">
+ {{ $options.i18n.thanks }}
+ </section>
+ </div>
+ </transition>
+ </aside>
</user-callout-dismisser>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index 254b280bf14..f377a185879 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@@ -9,6 +9,7 @@ const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} m
export default {
components: {
GlSprintf,
+ GlLink,
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -40,6 +41,11 @@ export default {
required: false,
default: '',
},
+ mergeCommitPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
isMerged() {
@@ -124,7 +130,9 @@ export default {
</template>
</template>
<template #mergeCommitSha>
- <span class="label-branch">{{ mergeCommitSha }}</span>
+ <gl-link :href="mergeCommitPath" class="label-branch" data-testid="merge-commit-sha">{{
+ mergeCommitSha
+ }}</gl-link>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index d2c85b14999..78430abcfe9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -680,6 +680,7 @@ export default {
:is-fast-forward-enabled="!shouldShowMergeEdit"
:commits-count="commitsCount"
:target-branch="stateData.targetBranch"
+ :merge-commit-path="mr.mergeCommitPath"
/>
</li>
<li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
diff --git a/app/assets/javascripts/vue_shared/components/code_block.stories.js b/app/assets/javascripts/vue_shared/components/code_block.stories.js
index ad53afe3676..e02a346c1de 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.stories.js
+++ b/app/assets/javascripts/vue_shared/components/code_block.stories.js
@@ -2,7 +2,7 @@ import CodeBlock from './code_block.vue';
export default {
component: CodeBlock,
- title: 'vue_shared/components/code_block',
+ title: 'vue_shared/code_block',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
index 1939575ae40..bf81a811d16 100644
--- a/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
+++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
@@ -2,7 +2,7 @@ import CodeBlockHighlighted from './code_block_highlighted.vue';
export default {
component: CodeBlockHighlighted,
- title: 'vue_shared/components/code_block_highlighted',
+ title: 'vue_shared/code_block_highlighted',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
index 8481280f25f..7ecc309db52 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
@@ -3,7 +3,7 @@ import ConfirmDanger from './confirm_danger.vue';
export default {
component: ConfirmDanger,
- title: 'vue_shared/components/modals/confirm_danger_modal',
+ title: 'vue_shared/modals/confirm_danger_modal',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
index eeed5e9dc3a..8256d953466 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
@@ -5,7 +5,7 @@ import DropdownWidget from './dropdown_widget.vue';
export default {
component: DropdownWidget,
- title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget',
+ title: 'vue_shared/dropdown/dropdown_widget/dropdown_widget',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
index cdd7a074f34..377f1e7c136 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
@@ -2,7 +2,7 @@ import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
export default {
component: InputCopyToggleVisibility,
- title: 'vue_shared/components/form/input_copy_toggle_visibility',
+ title: 'vue_shared/form/input_copy_toggle_visibility',
};
const defaultProps = {
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
index e31446f4bb8..f16afc77164 100644
--- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
@@ -3,7 +3,7 @@ import PaginationBar from './pagination_bar.vue';
export default {
component: PaginationBar,
- title: 'vue_shared/components/pagination_bar/pagination_bar',
+ title: 'vue_shared/pagination_bar/pagination_bar',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
index 0927bf7e13d..bfb30c74cb8 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
@@ -2,7 +2,7 @@ import ProjectAvatar from './project_avatar.vue';
export default {
component: ProjectAvatar,
- title: 'vue_shared/components/project_avatar',
+ title: 'vue_shared/project_avatar',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
index 9700117a3da..4021e23a3f6 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
@@ -2,7 +2,7 @@ import ProjectListItem from './project_list_item.vue';
export default {
component: ProjectListItem,
- title: 'vue_shared/components/project_selector/project_list_item',
+ title: 'vue_shared/project_selector/project_list_item',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
index 5242743ad30..53e4a08e486 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
@@ -2,7 +2,7 @@ import SettingsBlock from './settings_block.vue';
export default {
component: SettingsBlock,
- title: 'vue_shared/components/settings/settings_block',
+ title: 'vue_shared/settings/settings_block',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
index 294e5bd9f90..8a2bab4cb9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
- title: 'vue_shared/components/sidebar/todo_toggle/todo_button',
+ title: 'vue_shared/sidebar/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
index f27901a30a9..e621442e601 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
@@ -5,7 +5,7 @@ const defaultWidth = '250px';
export default {
component: TooltipOnTruncate,
- title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue',
+ title: 'vue_shared/tooltip_on_truncate/tooltip_on_truncate.vue',
};
const createStory = ({ ...options }) => {
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
index d2030c14029..1f0f4cde234 100644
--- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
@@ -5,7 +5,7 @@ import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
export default {
component: UserDeletionObstaclesList,
- title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
+ title: 'vue_shared/user_deletion_obstacles/user_deletion_obstacles_list',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb
index fd0d81a25a9..a283d3abb0b 100644
--- a/app/controllers/admin/hook_logs_controller.rb
+++ b/app/controllers/admin/hook_logs_controller.rb
@@ -1,34 +1,17 @@
# frozen_string_literal: true
-class Admin::HookLogsController < Admin::ApplicationController
- include ::WebHooks::HookExecutionNotice
+module Admin
+ class HookLogsController < Admin::ApplicationController
+ include WebHooks::HookLogActions
- before_action :hook, only: [:show, :retry]
- before_action :hook_log, only: [:show, :retry]
+ private
- respond_to :html
+ def hook
+ @hook ||= SystemHook.find(params[:hook_id])
+ end
- feature_category :integrations
- urgency :low, [:retry]
-
- def show
- end
-
- def retry
- result = hook.execute(hook_log.request_data, hook_log.trigger)
-
- set_hook_execution_notice(result)
-
- redirect_to edit_admin_hook_path(@hook)
- end
-
- private
-
- def hook
- @hook ||= SystemHook.find(params[:hook_id])
- end
-
- def hook_log
- @hook_log ||= hook.web_hook_logs.find(params[:id])
+ def after_retry_redirect_path
+ edit_admin_hook_path(hook)
+ end
end
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 24d7bd9ca7b..e04665e279b 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -6,6 +6,7 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
push_frontend_feature_flag(:admin_runners_bulk_delete)
+ push_frontend_feature_flag(:runner_list_stacked_layout_admin)
end
feature_category :runner
diff --git a/app/controllers/concerns/web_hooks/hook_log_actions.rb b/app/controllers/concerns/web_hooks/hook_log_actions.rb
new file mode 100644
index 00000000000..7c9218ddcd4
--- /dev/null
+++ b/app/controllers/concerns/web_hooks/hook_log_actions.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module WebHooks
+ module HookLogActions
+ extend ActiveSupport::Concern
+ include HookExecutionNotice
+
+ included do
+ before_action :hook, only: [:show, :retry]
+ before_action :hook_log, only: [:show, :retry]
+
+ respond_to :html
+
+ feature_category :integrations
+ urgency :low, [:retry]
+ end
+
+ def show
+ end
+
+ def retry
+ execute_hook
+ redirect_to after_retry_redirect_path
+ end
+
+ private
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def hook_log
+ @hook_log ||= hook.web_hook_logs.find(params[:id])
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def execute_hook
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
+ set_hook_execution_notice(result)
+ end
+ end
+end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index 0ee6ddc2662..25863632849 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -4,6 +4,9 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_read_group_runners!, only: [:index, :show]
before_action :authorize_admin_group_runners!, only: [:edit, :update, :destroy, :pause, :resume]
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
+ before_action only: [:index] do
+ push_frontend_feature_flag(:runner_list_stacked_layout, @group)
+ end
feature_category :runner
urgency :low
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
index 8fbcc840fc5..3ab4c34737d 100644
--- a/app/controllers/projects/hook_logs_controller.rb
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -1,40 +1,19 @@
# frozen_string_literal: true
class Projects::HookLogsController < Projects::ApplicationController
- include ::WebHooks::HookExecutionNotice
-
before_action :authorize_admin_project!
- before_action :hook, only: [:show, :retry]
- before_action :hook_log, only: [:show, :retry]
-
- respond_to :html
+ include WebHooks::HookLogActions
layout 'project_settings'
- feature_category :integrations
- urgency :low, [:retry]
-
- def show
- end
-
- def retry
- execute_hook
- redirect_to edit_project_hook_path(@project, @hook)
- end
-
private
- def execute_hook
- result = hook.execute(hook_log.request_data, hook_log.trigger)
- set_hook_execution_notice(result)
- end
-
def hook
@hook ||= @project.hooks.find(params[:hook_id])
end
- def hook_log
- @hook_log ||= hook.web_hook_logs.find(params[:id])
+ def after_retry_redirect_path
+ edit_project_hook_path(@project, hook)
end
end
diff --git a/app/controllers/projects/settings/integration_hook_logs_controller.rb b/app/controllers/projects/settings/integration_hook_logs_controller.rb
index 1e42fbce4c4..3a921ecad0d 100644
--- a/app/controllers/projects/settings/integration_hook_logs_controller.rb
+++ b/app/controllers/projects/settings/integration_hook_logs_controller.rb
@@ -7,13 +7,13 @@ module Projects
before_action :integration, only: [:show, :retry]
- def retry
- execute_hook
- redirect_to edit_project_settings_integration_path(@project, @integration)
- end
-
private
+ override :after_retry_redirect_path
+ def after_retry_redirect_path
+ edit_project_settings_integration_path(@project, @integration)
+ end
+
def integration
@integration ||= @project.find_or_initialize_integration(params[:integration_id])
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 5843e13c7cd..809eff675bd 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -57,6 +57,13 @@ class SearchController < ApplicationController
@search_highlight = @search_service.search_highlight
end
+ Gitlab::Metrics::GlobalSearchSlis.record_apdex(
+ elapsed: @global_search_duration_s,
+ search_type: @search_type,
+ search_level: @search_level,
+ search_scope: @scope
+ )
+
increment_search_counters
end
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 36347776ec9..a9913fe3d5e 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -3,7 +3,7 @@
#fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'),
endpoint: new_project_fork_path(@project, format: :json),
new_group_path: new_group_path,
- project_full_path: project_path(@project),
+ project_full_path: @project.full_path,
visibility_help_path: help_page_path("user/public_access"),
project_id: @project.id,
project_name: @project.name,
diff --git a/config/feature_flags/development/ci_variables_refactoring_to_variable.yml b/config/feature_flags/development/ci_variables_refactoring_to_variable.yml
new file mode 100644
index 00000000000..131df28d104
--- /dev/null
+++ b/config/feature_flags/development/ci_variables_refactoring_to_variable.yml
@@ -0,0 +1,8 @@
+---
+name: ci_variables_refactoring_to_variable
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95390
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371559
+milestone: '15.4'
+type: development
+group: group::pipeline authoring
+default_enabled: false
diff --git a/config/feature_flags/development/global_search_custom_slis.yml b/config/feature_flags/development/global_search_custom_slis.yml
new file mode 100644
index 00000000000..6dd7cfb12f0
--- /dev/null
+++ b/config/feature_flags/development/global_search_custom_slis.yml
@@ -0,0 +1,8 @@
+---
+name: global_search_custom_slis
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95182
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372107
+milestone: '15.4'
+type: development
+group: group::application performance
+default_enabled: false
diff --git a/config/feature_flags/development/runner_list_stacked_layout.yml b/config/feature_flags/development/runner_list_stacked_layout.yml
new file mode 100644
index 00000000000..bb5f9c8e922
--- /dev/null
+++ b/config/feature_flags/development/runner_list_stacked_layout.yml
@@ -0,0 +1,8 @@
+---
+name: runner_list_stacked_layout
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95617
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371031
+milestone: '15.4'
+type: development
+group: group::runner
+default_enabled: false
diff --git a/config/feature_flags/development/runner_list_stacked_layout_admin.yml b/config/feature_flags/development/runner_list_stacked_layout_admin.yml
new file mode 100644
index 00000000000..4f4688cce89
--- /dev/null
+++ b/config/feature_flags/development/runner_list_stacked_layout_admin.yml
@@ -0,0 +1,8 @@
+---
+name: runner_list_stacked_layout_admin
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95617
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371031
+milestone: '15.4'
+type: development
+group: group::runner
+default_enabled: false
diff --git a/config/initializers/zz_metrics.rb b/config/initializers/zz_metrics.rb
index 5e6c1abdda6..1aeb345badb 100644
--- a/config/initializers/zz_metrics.rb
+++ b/config/initializers/zz_metrics.rb
@@ -40,6 +40,7 @@ if Gitlab::Metrics.enabled? && !Rails.env.test? && !(Rails.env.development? && d
if Gitlab::Runtime.puma?
Gitlab::Metrics::RequestsRackMiddleware.initialize_metrics
+ Gitlab::Metrics::GlobalSearchSlis.initialize_slis!
end
GC::Profiler.enable
diff --git a/doc/architecture/blueprints/ci_data_decay/index.md b/doc/architecture/blueprints/ci_data_decay/index.md
index bf2e7b00e84..23c8e9df1bb 100644
--- a/doc/architecture/blueprints/ci_data_decay/index.md
+++ b/doc/architecture/blueprints/ci_data_decay/index.md
@@ -232,7 +232,7 @@ In progress.
## Timeline
-- 2021-01-21: Parent [CI Scaling](../ci_scale/) blueprint [merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52203) created.
+- 2021-01-21: Parent [CI Scaling](../ci_scale/index.md) blueprint [merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52203) created.
- 2021-04-26: CI Scaling blueprint approved and merged.
- 2021-09-10: CI/CD data time decay blueprint discussions started.
- 2022-01-07: CI/CD data time decay blueprint [merged](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70052).
diff --git a/doc/architecture/blueprints/ci_scale/index.md b/doc/architecture/blueprints/ci_scale/index.md
index bd680714ae5..75c4d05c334 100644
--- a/doc/architecture/blueprints/ci_scale/index.md
+++ b/doc/architecture/blueprints/ci_scale/index.md
@@ -171,7 +171,7 @@ Work required to achieve our next CI/CD scaling target is tracked in the
1. ✓ Migrate primary keys to big integers on GitLab.com.
1. ✓ Implement the new architecture of builds queuing on GitLab.com.
1. [Make the new builds queuing architecture generally available](https://gitlab.com/groups/gitlab-org/-/epics/6954).
-1. [Partition CI/CD data using time-decay pattern](../ci_data_decay/).
+1. [Partition CI/CD data using time-decay pattern](../ci_data_decay/index.md).
## Status
diff --git a/doc/ci/index.md b/doc/ci/index.md
index 68cddaf24e8..be7088ab153 100644
--- a/doc/ci/index.md
+++ b/doc/ci/index.md
@@ -146,43 +146,30 @@ See also the [Why CI/CD?](https://docs.google.com/presentation/d/1OGgk2Tcxbpl7DJ
As GitLab CI/CD has evolved, certain breaking changes have
been necessary.
-#### 15.0 and later
-
-Going forward, all breaking changes are documented on the following pages:
+For GitLab 15.0 and later, all breaking changes are documented on the following pages:
- [Deprecations](../update/deprecations.md)
- [Removals](../update/removals.md)
-#### 14.0
-
-- No breaking changes.
-
-#### 13.0
-
-- [Remove Backported `os.Expand`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4915).
-- [Remove Fedora 29 package support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/16158).
-- [Remove macOS 32-bit support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/25466).
-- [Removed `debug/jobs/list?v=1` endpoint](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6361).
-- [Remove support for array of strings when defining services for Docker executor](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4922).
-- [Remove `--docker-services` flag on register command](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6404).
-- [Remove legacy build directory caching](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4180).
-- [Remove `FF_USE_LEGACY_VOLUMES_MOUNTING_ORDER` feature flag](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6581).
-- [Remove support for Windows Server 1803](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6553).
-
-#### 12.0
-
-- [Use `refspec` to clone/fetch Git repository](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4069).
-- [Old cache configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4070).
-- [Old metrics server configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4072).
-- [Remove `FF_K8S_USE_ENTRYPOINT_OVER_COMMAND`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4073).
-- [Remove Linux distributions that reach EOL](https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/1130).
-- [Update command line API for helper images](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4013).
-- [Remove old `git clean` flow](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4175).
-
-#### 11.0
-
-- No breaking changes.
-
-#### 10.0
-
-- No breaking changes.
+The breaking changes for [GitLab Runner](https://docs.gitlab.com/runner/) in earlier
+major version releases are:
+
+- 14.0: No breaking changes.
+- 13.0:
+ - [Remove Backported `os.Expand`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4915).
+ - [Remove Fedora 29 package support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/16158).
+ - [Remove macOS 32-bit support](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/25466).
+ - [Removed `debug/jobs/list?v=1` endpoint](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6361).
+ - [Remove support for array of strings when defining services for Docker executor](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4922).
+ - [Remove `--docker-services` flag on register command](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6404).
+ - [Remove legacy build directory caching](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4180).
+ - [Remove `FF_USE_LEGACY_VOLUMES_MOUNTING_ORDER` feature flag](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6581).
+ - [Remove support for Windows Server 1803](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/6553).
+- 12.0:
+ - [Use `refspec` to clone/fetch Git repository](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4069).
+ - [Old cache configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4070).
+ - [Old metrics server configuration](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4072).
+ - [Remove `FF_K8S_USE_ENTRYPOINT_OVER_COMMAND`](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4073).
+ - [Remove Linux distributions that reach EOL](https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/1130).
+ - [Update command line API for helper images](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4013).
+ - [Remove old `git clean` flow](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4175).
diff --git a/doc/development/development_processes.md b/doc/development/development_processes.md
index e199aedd3f5..27ebe98bc63 100644
--- a/doc/development/development_processes.md
+++ b/doc/development/development_processes.md
@@ -67,7 +67,7 @@ Some changes affect more than one group. For example:
- Changes to [code review guidelines](code_review.md).
- Changes to [commit message guidelines](contributing/merge_request_workflow.md#commit-messages-guidelines).
-- Changes to guidelines in [feature flags in development of GitLab](feature_flags/).
+- Changes to guidelines in [feature flags in development of GitLab](feature_flags/index.md).
- Changes to [feature flags documentation guidelines](documentation/feature_flags.md).
In these cases, use the following workflow:
diff --git a/doc/development/documentation/restful_api_styleguide.md b/doc/development/documentation/restful_api_styleguide.md
index bf1461a810d..1886b90f317 100644
--- a/doc/development/documentation/restful_api_styleguide.md
+++ b/doc/development/documentation/restful_api_styleguide.md
@@ -129,7 +129,7 @@ To deprecate an attribute:
```
To widely announce a deprecation, or if it's a breaking change,
-[update the deprecations and removals documentation](../deprecation_guidelines/#update-the-deprecations-and-removals-documentation).
+[update the deprecations and removals documentation](../deprecation_guidelines/index.md#update-the-deprecations-and-removals-documentation).
## Method description
diff --git a/doc/development/export_csv.md b/doc/development/export_csv.md
index 42b9f868a30..29e80f676da 100644
--- a/doc/development/export_csv.md
+++ b/doc/development/export_csv.md
@@ -11,7 +11,7 @@ This document lists the different implementations of CSV export in GitLab codeba
| Export type | How it works | Advantages | Disadvantages | Existing examples |
|---|---|---|---|---|
| Streaming | - Query and yield data in batches to a response stream.<br>- Download starts immediately. | - Report available immediately. | - No progress indicator.<br>- Requires a reliable connection. | [Export Audit Event Log](../administration/audit_events.md#export-to-csv) |
-| Downloading | - Query and write data in batches to a temporary file.<br>- Loads the file into memory.<br>- Sends the file to the client. | - Report available immediately. | - Large amount of data might cause request timeout.<br>- Memory intensive.<br>- Request expires when user navigates to a different page. | - [Export Chain of Custody Report](../user/compliance/compliance_report/#chain-of-custody-report)<br>- [Export License Usage File](../subscriptions/self_managed/index.md#export-your-license-usage) |
+| Downloading | - Query and write data in batches to a temporary file.<br>- Loads the file into memory.<br>- Sends the file to the client. | - Report available immediately. | - Large amount of data might cause request timeout.<br>- Memory intensive.<br>- Request expires when user navigates to a different page. | - [Export Chain of Custody Report](../user/compliance/compliance_report/index.md#chain-of-custody-report)<br>- [Export License Usage File](../subscriptions/self_managed/index.md#export-your-license-usage) |
| As email attachment | - Asynchronously process the query with background job.<br>- Email uses the export as an attachment. | - Asynchronous processing. | - Requires users use a different app (email) to download the CSV.<br>- Email providers may limit attachment size. | - [Export issues](../user/project/issues/csv_export.md)<br>- [Export merge requests](../user/project/merge_requests/csv_export.md) |
| As downloadable link in email (*) | - Asynchronously process the query with background job.<br>- Email uses an export link. | - Asynchronous processing.<br>- Bypasses email provider attachment size limit. | - Requires users use a different app (email).<br>- Requires additional storage and cleanup. | [Export User Permissions](https://gitlab.com/gitlab-org/gitlab/-/issues/1772) |
| Polling (non-persistent state) | - Asynchronously processes the query with the background job.<br>- Frontend(FE) polls every few seconds to check if CSV file is ready. | - Asynchronous processing.<br>- Automatically downloads to local machine on completion.<br>- In-app solution. | - Non-persistable request - request expires when user navigates to a different page.<br>- API is processed for each polling request. | [Export Vulnerabilities](../user/application_security/vulnerability_report/index.md#export-vulnerability-details) |
diff --git a/doc/development/fe_guide/storybook.md b/doc/development/fe_guide/storybook.md
index 64bba567123..a3a1fa2160f 100644
--- a/doc/development/fe_guide/storybook.md
+++ b/doc/development/fe_guide/storybook.md
@@ -47,9 +47,9 @@ To add a story:
1. Write the story as per the [official Storybook instructions](https://storybook.js.org/docs/vue/writing-stories/introduction/)
NOTE:
- Specify the `title` field of the story as the component's file path from the `javascripts/` directory.
+ Specify the `title` field of the story as the component's file path from the `javascripts/` directory, without the `/components` part.
For example, if the component is located at `app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue`,
- specify the story `title` as `vue_shared/components/sidebar/todo_toggle/todo_button`.
+ specify the story `title` as `vue_shared/sidebar/todo_toggle/todo_button`.
If the component is located in the `ee/` directory, make sure to prefix the story's title with `ee/` as well.
This will ensure the Storybook navigation maps closely to our internal directory structure.
diff --git a/doc/development/fips_compliance.md b/doc/development/fips_compliance.md
index ccfb8a7b471..1029ed88eac 100644
--- a/doc/development/fips_compliance.md
+++ b/doc/development/fips_compliance.md
@@ -65,7 +65,7 @@ listed here that also do not work properly in FIPS mode:
- [Solutions for vulnerabilities](../user/application_security/vulnerabilities/index.md#resolve-a-vulnerability)
for yarn projects.
- [Static Application Security Testing (SAST)](../user/application_security/sast/index.md)
- supports a reduced set of [analyzers](../user/application_security/sast/#fips-enabled-images)
+ supports a reduced set of [analyzers](../user/application_security/sast/index.md#fips-enabled-images)
when operating in FIPS-compliant mode.
- Advanced Search is currently not included in FIPS mode. It must not be enabled in order to be FIPS-compliant.
- [Gravatar or Libravatar-based profile images](../administration/libravatar.md) are not FIPS-compliant.
diff --git a/doc/development/integrations/secure.md b/doc/development/integrations/secure.md
index 741fa8d89c4..0227dc9147c 100644
--- a/doc/development/integrations/secure.md
+++ b/doc/development/integrations/secure.md
@@ -254,7 +254,7 @@ Following the POSIX exit code standard, the scanner exits with 0 for success and
Success also includes the case when vulnerabilities are found.
When a CI job fails, security report results are not ingested by GitLab, even if the job
-[allows failure](../../ci/yaml/#allow_failure). The report artifacts are still uploaded to GitLab and available
+[allows failure](../../ci/yaml/index.md#allow_failure). The report artifacts are still uploaded to GitLab and available
for [download in the pipeline security tab](../../user/application_security/vulnerability_report/pipeline.md#download-security-scan-outputs).
When executing a scanning job using the [Docker-in-Docker privileged mode](../../user/application_security/sast/index.md#requirements),
diff --git a/doc/development/sec/index.md b/doc/development/sec/index.md
index 0d1952cb7e4..9200311f731 100644
--- a/doc/development/sec/index.md
+++ b/doc/development/sec/index.md
@@ -44,21 +44,21 @@ flowchart LR
### Scanning
The scanning part is responsible for finding vulnerabilities in given resources, and exporting results.
-The scans are executed in CI/CD jobs via several small projects called [Analyzers](../../user/application_security/terminology/#analyzer), which can be found in our [Analyzers sub-group](https://gitlab.com/gitlab-org/security-products/analyzers).
-The Analyzers are wrappers around security tools called [Scanners](../../user/application_security/terminology/#scanner), developed internally or externally, to integrate them into GitLab.
+The scans are executed in CI/CD jobs via several small projects called [Analyzers](../../user/application_security/terminology/index.md#analyzer), which can be found in our [Analyzers sub-group](https://gitlab.com/gitlab-org/security-products/analyzers).
+The Analyzers are wrappers around security tools called [Scanners](../../user/application_security/terminology/index.md#scanner), developed internally or externally, to integrate them into GitLab.
The Analyzers are mainly written in Go.
Some 3rd party integrators also make additional Scanners available by following our [integration documentation](../integrations/secure.md), which leverages the same architecture.
-The results of the scans are exported as JSON reports that must comply with the [Secure report format](../../user/application_security/terminology/#secure-report-format) and are uploaded as [CI/CD Job Report artifacts](../../ci/pipelines/job_artifacts.md) to make them available for processing after the pipelines completes.
+The results of the scans are exported as JSON reports that must comply with the [Secure report format](../../user/application_security/terminology/index.md#secure-report-format) and are uploaded as [CI/CD Job Report artifacts](../../ci/pipelines/job_artifacts.md) to make them available for processing after the pipelines completes.
### Processing, visualization, and management
After the data is available as a Report Artifact it can be processed by the GitLab Rails application to enable our security features, including:
-- [Security Dashboards](../../user/application_security/security_dashboard/), Merge Request widget, Pipeline view, and so on.
-- [Interactions with vulnerabilities](../../user/application_security/#interact-with-findings-and-vulnerabilities).
-- [Approval rules](../../user/application_security/#security-approvals-in-merge-requests).
+- [Security Dashboards](../../user/application_security/security_dashboard/index.md), Merge Request widget, Pipeline view, and so on.
+- [Interactions with vulnerabilities](../../user/application_security/index.md#interact-with-findings-and-vulnerabilities).
+- [Approval rules](../../user/application_security/index.md#security-approvals-in-merge-requests).
Depending on the context, the security reports may be stored either in the database or stay as Report Artifacts for on-demand access.
diff --git a/doc/tutorials/make_your_first_git_commit.md b/doc/tutorials/make_your_first_git_commit.md
index be9023c6ae0..fafc440c6a3 100644
--- a/doc/tutorials/make_your_first_git_commit.md
+++ b/doc/tutorials/make_your_first_git_commit.md
@@ -238,7 +238,7 @@ to the default branch (`main`).
NOTE:
For this tutorial, you merge your branch directly to the default branch for your
-repository. In GitLab, you typically use a [merge request](../user/project/merge_requests/)
+repository. In GitLab, you typically use a [merge request](../user/project/merge_requests/index.md)
to merge your branch.
### View your changes in GitLab
diff --git a/doc/user/admin_area/settings/rate_limit_on_pipelines_creation.md b/doc/user/admin_area/settings/rate_limit_on_pipelines_creation.md
index fce6179f5cf..5202a51de0f 100644
--- a/doc/user/admin_area/settings/rate_limit_on_pipelines_creation.md
+++ b/doc/user/admin_area/settings/rate_limit_on_pipelines_creation.md
@@ -11,7 +11,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
You can set a limit so that users and processes can't request more than a certain number of pipelines each minute. This limit can help save resources and improve stability.
-For example, if you set a limit of `10`, and `11` requests are sent to the [trigger API](../../../ci/triggers/) within one minute,
+For example, if you set a limit of `10`, and `11` requests are sent to the [trigger API](../../../ci/triggers/index.md) within one minute,
the eleventh request is blocked. Access to the endpoint is allowed again after one minute.
This limit is:
diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md
index e7e5fff50bc..059253cc929 100644
--- a/doc/user/application_security/container_scanning/index.md
+++ b/doc/user/application_security/container_scanning/index.md
@@ -68,7 +68,7 @@ information directly in the merge request.
| [Solutions for vulnerabilities (auto-remediation)](#solutions-for-vulnerabilities-auto-remediation) | No | Yes |
| Support for the [vulnerability allow list](#vulnerability-allowlisting) | No | Yes |
| [Access to Security Dashboard page](#security-dashboard) | No | Yes |
-| [Access to Dependency List page](../dependency_list/) | No | Yes |
+| [Access to Dependency List page](../dependency_list/index.md) | No | Yes |
## Requirements
@@ -706,12 +706,12 @@ The results are stored in `gl-container-scanning-report.json`.
## Reports JSON format
The container scanning tool emits JSON reports which the [GitLab Runner](https://docs.gitlab.com/runner/)
-recognizes through the [`artifacts:reports`](../../../ci/yaml/#artifactsreports)
+recognizes through the [`artifacts:reports`](../../../ci/yaml/index.md#artifactsreports)
keyword in the CI configuration file.
Once the CI job finishes, the Runner uploads these reports to GitLab, which are then available in
the CI Job artifacts. In GitLab Ultimate, these reports can be viewed in the corresponding [pipeline](../vulnerability_report/pipeline.md)
-and become part of the [Vulnerability Report](../vulnerability_report/).
+and become part of the [Vulnerability Report](../vulnerability_report/index.md).
These reports must follow a format defined in the
[security report schemas](https://gitlab.com/gitlab-org/security-products/security-report-schemas/). See:
diff --git a/doc/user/group/saml_sso/group_sync.md b/doc/user/group/saml_sso/group_sync.md
index 20dcd6eab27..322b417d466 100644
--- a/doc/user/group/saml_sso/group_sync.md
+++ b/doc/user/group/saml_sso/group_sync.md
@@ -70,9 +70,9 @@ role.
Users granted:
- A higher role with Group Sync are displayed as having
- [direct membership](../../project/members/#display-direct-members) of the group.
+ [direct membership](../../project/members/index.md#display-direct-members) of the group.
- A lower or the same role with Group Sync are displayed as having
- [inherited membership](../../project/members/#display-inherited-members) of the group.
+ [inherited membership](../../project/members/index.md#display-inherited-members) of the group.
### Automatic member removal
diff --git a/doc/user/group/saml_sso/scim_setup.md b/doc/user/group/saml_sso/scim_setup.md
index 7962f171166..c68022f4e3c 100644
--- a/doc/user/group/saml_sso/scim_setup.md
+++ b/doc/user/group/saml_sso/scim_setup.md
@@ -220,7 +220,7 @@ It is important that this SCIM `id` and SCIM `externalId` are configured to the
### How do I verify user's SAML NameId matches the SCIM externalId
-Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/#user-identities).
+Admins can use the Admin Area to [list SCIM identities for a user](../../admin_area/index.md#user-identities).
Group owners can see the list of users and the `externalId` stored for each user in the group SAML SSO Settings page.
diff --git a/doc/user/infrastructure/iac/troubleshooting.md b/doc/user/infrastructure/iac/troubleshooting.md
index 8a7ce3af83a..3286b550507 100644
--- a/doc/user/infrastructure/iac/troubleshooting.md
+++ b/doc/user/infrastructure/iac/troubleshooting.md
@@ -110,8 +110,8 @@ If you don't set `TF_STATE_NAME` or `TF_ADDRESS` in your job, the job fails with
To resolve this, ensure that either `TF_ADDRESS` or `TF_STATE_NAME` is accessible in the
job that returned the error:
-1. Configure the [CI/CD environment scope](../../../ci/variables/#add-a-cicd-variable-to-a-project) for the job.
-1. Set the job's [environment](../../../ci/yaml/#environment), matching the environment scope from the previous step.
+1. Configure the [CI/CD environment scope](../../../ci/variables/index.md#add-a-cicd-variable-to-a-project) for the job.
+1. Set the job's [environment](../../../ci/yaml/index.md#environment), matching the environment scope from the previous step.
### Error refreshing state: HTTP remote state endpoint requires auth
diff --git a/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md b/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md
index 8f90f42ad08..76e3da9538f 100644
--- a/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md
+++ b/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md
@@ -125,7 +125,7 @@ upgrading to [GitLab Premium or Ultimate](https://about.gitlab.com/upgrade/).
## Purchase additional data transfer
-Read more about managing your [data transfer limits](../../../subscriptions/gitlab_com/#purchase-more-storage-and-transfer).
+Read more about managing your [data transfer limits](../../../subscriptions/gitlab_com/index.md#purchase-more-storage-and-transfer).
## Related issues
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 6ff7196ecf5..79d87cddb47 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -119,7 +119,7 @@ The following table lists project permissions available for each role:
| [Merge requests](project/merge_requests/index.md):<br>Add labels | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Lock threads | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Manage or accept | | | ✓ | ✓ | ✓ |
-| [Merge requests](project/merge_requests/index.md):<br>[Resolve a thread](discussions/#resolve-a-thread) | | | ✓ | ✓ | ✓ |
+| [Merge requests](project/merge_requests/index.md):<br>[Resolve a thread](discussions/index.md#resolve-a-thread) | | | ✓ | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Manage [merge approval rules](project/merge_requests/approvals/settings.md) (project settings) | | | | ✓ | ✓ |
| [Merge requests](project/merge_requests/index.md):<br>Delete | | | | | ✓ |
| [Metrics dashboards](../operations/metrics/dashboards/index.md):<br>Manage user-starred metrics dashboards (*6*) | ✓ | ✓ | ✓ | ✓ | ✓ |
diff --git a/doc/user/project/import/index.md b/doc/user/project/import/index.md
index 5b966781312..72d533efd1b 100644
--- a/doc/user/project/import/index.md
+++ b/doc/user/project/import/index.md
@@ -85,7 +85,7 @@ Migrate the assets in this order:
Keep in mind the limitations of the [import/export feature](../settings/import_export.md#items-that-are-exported).
-You must still migrate your [Container Registry](../../packages/container_registry/)
+You must still migrate your [Container Registry](../../packages/container_registry/index.md)
over a series of Docker pulls and pushes. Re-run any CI pipelines to retrieve any build artifacts.
## Migrate from GitLab.com to self-managed GitLab
diff --git a/doc/user/project/integrations/github.md b/doc/user/project/integrations/github.md
index c07142d6edf..d53b892281f 100644
--- a/doc/user/project/integrations/github.md
+++ b/doc/user/project/integrations/github.md
@@ -39,7 +39,7 @@ Complete these steps in GitLab:
1. Optional. Select **Test settings**.
1. Select **Save changes**.
-After configuring the integration, see [Pipelines for external pull requests](../../../ci/ci_cd_for_external_repos/#pipelines-for-external-pull-requests)
+After configuring the integration, see [Pipelines for external pull requests](../../../ci/ci_cd_for_external_repos/index.md#pipelines-for-external-pull-requests)
to configure pipelines to run for open pull requests.
### Static or dynamic status check names
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index aba149bb891..ec408081d68 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -170,7 +170,7 @@ include: # Execute individual project's configuration (if project contains .git
When used to enforce scan execution, this feature has some overlap with [scan execution policies](../../application_security/policies/scan-execution-policies.md),
as we have not [unified the user experience for these two features](https://gitlab.com/groups/gitlab-org/-/epics/7312).
For details on the similarities and differences between these features, see
-[Enforce scan execution](../../application_security/#enforce-scan-execution).
+[Enforce scan execution](../../application_security/index.md#enforce-scan-execution).
### Ensure compliance jobs are always run
diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb
index 2d0e8ae4bfd..40da4a4f8e8 100644
--- a/lib/api/helpers/projects_helpers.rb
+++ b/lib/api/helpers/projects_helpers.rb
@@ -57,7 +57,6 @@ module API
optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all threads are resolved'
optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'Deprecated: Use :topics instead'
optional :topics, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'The list of topics for a project'
- # TODO: remove rubocop disable - https://gitlab.com/gitlab-org/gitlab/issues/14960
optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for project'
optional :printing_merge_request_link_enabled, type: Boolean, desc: 'Show link to create/view merge request when pushing from the command line'
optional :merge_method, type: String, values: %w(ff rebase_merge merge), desc: 'The merge method used when merging merge requests'
diff --git a/lib/api/search.rb b/lib/api/search.rb
index 7aa3cf8a5cb..6724c4e1a28 100644
--- a/lib/api/search.rb
+++ b/lib/api/search.rb
@@ -65,6 +65,13 @@ module API
set_global_search_log_information
+ Gitlab::Metrics::GlobalSearchSlis.record_apdex(
+ elapsed: @search_duration_s,
+ search_type: search_type,
+ search_level: search_service.level,
+ search_scope: search_scope
+ )
+
Gitlab::UsageDataCounters::SearchCounter.count(:all_searches)
paginate(@results)
diff --git a/lib/gitlab/ci/config/entry/current_variables.rb b/lib/gitlab/ci/config/entry/current_variables.rb
new file mode 100644
index 00000000000..3b6721ec92d
--- /dev/null
+++ b/lib/gitlab/ci/config/entry/current_variables.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Config
+ module Entry
+ ##
+ # Entry that represents CI/CD variables.
+ # The class will be renamed to `Variables` when removing the FF `ci_variables_refactoring_to_variable`.
+ #
+ class CurrentVariables < ::Gitlab::Config::Entry::ComposableHash
+ include ::Gitlab::Config::Entry::Validatable
+
+ validations do
+ validates :config, type: Hash
+ end
+
+ # Enable these lines when removing the FF `ci_variables_refactoring_to_variable`
+ # and renaming this class to `Variables`.
+ # def self.default(**)
+ # {}
+ # end
+
+ def value
+ @entries.to_h do |key, entry|
+ [key.to_s, entry.value]
+ end
+ end
+
+ def value_with_data
+ @entries.to_h do |key, entry|
+ [key.to_s, entry.value_with_data]
+ end
+ end
+
+ private
+
+ def composable_class(_name, _config)
+ Entry::Variable
+ end
+
+ def composable_metadata
+ { allowed_value_data: opt(:allowed_value_data) }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config/entry/legacy_variables.rb b/lib/gitlab/ci/config/entry/legacy_variables.rb
new file mode 100644
index 00000000000..279e1ad8609
--- /dev/null
+++ b/lib/gitlab/ci/config/entry/legacy_variables.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Config
+ module Entry
+ ##
+ # Entry that represents environment variables.
+ # This is legacy implementation and will be removed with the FF `ci_variables_refactoring_to_variable`.
+ #
+ class LegacyVariables < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+
+ ALLOWED_VALUE_DATA = %i[value description].freeze
+
+ validations do
+ validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }, if: :use_value_data?
+ validates :config, variables: true, unless: :use_value_data?
+ end
+
+ def value
+ @config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] }
+ end
+
+ def value_with_data
+ @config.to_h { |key, value| [key.to_s, expand_value(value)] }
+ end
+
+ def use_value_data?
+ opt(:use_value_data)
+ end
+
+ private
+
+ def expand_value(value)
+ if value.is_a?(Hash)
+ { value: value[:value].to_s, description: value[:description] }
+ else
+ { value: value.to_s, description: nil }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config/entry/root.rb b/lib/gitlab/ci/config/entry/root.rb
index ff11c757dfa..57e89bd7bc5 100644
--- a/lib/gitlab/ci/config/entry/root.rb
+++ b/lib/gitlab/ci/config/entry/root.rb
@@ -48,9 +48,10 @@ module Gitlab
description: 'Script that will be executed after each job.',
reserved: true
+ # use_value_data will be removed with the FF ci_variables_refactoring_to_variable
entry :variables, Entry::Variables,
description: 'Environment variables that will be used.',
- metadata: { use_value_data: true },
+ metadata: { use_value_data: true, allowed_value_data: %i[value description] },
reserved: true
entry :stages, Entry::Stages,
diff --git a/lib/gitlab/ci/config/entry/variable.rb b/lib/gitlab/ci/config/entry/variable.rb
new file mode 100644
index 00000000000..6c12a8e2c32
--- /dev/null
+++ b/lib/gitlab/ci/config/entry/variable.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Config
+ module Entry
+ ##
+ # Entry that represents a CI/CD variable.
+ #
+ class Variable < ::Gitlab::Config::Entry::Simplifiable
+ strategy :SimpleVariable, if: -> (config) { SimpleVariable.applies_to?(config) }
+ strategy :ComplexVariable, if: -> (config) { ComplexVariable.applies_to?(config) }
+
+ class SimpleVariable < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+
+ class << self
+ def applies_to?(config)
+ Gitlab::Config::Entry::Validators::AlphanumericValidator.validate(config)
+ end
+ end
+
+ validations do
+ validates :key, alphanumeric: true
+ validates :config, alphanumeric: true
+ end
+
+ def value
+ @config.to_s
+ end
+
+ def value_with_data
+ { value: @config.to_s, description: nil }
+ end
+ end
+
+ class ComplexVariable < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+
+ class << self
+ def applies_to?(config)
+ config.is_a?(Hash)
+ end
+ end
+
+ validations do
+ validates :key, alphanumeric: true
+ validates :config_value, alphanumeric: true, allow_nil: false, if: :config_value_defined?
+ validates :config_description, alphanumeric: true, allow_nil: false, if: :config_description_defined?
+
+ validate do
+ allowed_value_data = Array(opt(:allowed_value_data))
+
+ if allowed_value_data.any?
+ extra_keys = config.keys - allowed_value_data
+
+ errors.add(:config, "uses invalid data keys: #{extra_keys.join(', ')}") if extra_keys.present?
+ else
+ errors.add(:config, "must be a string")
+ end
+ end
+ end
+
+ def value
+ config_value.to_s
+ end
+
+ def value_with_data
+ { value: value, description: config_description }
+ end
+
+ def config_value
+ @config[:value]
+ end
+
+ def config_description
+ @config[:description]
+ end
+
+ def config_value_defined?
+ config.key?(:value)
+ end
+
+ def config_description_defined?
+ config.key?(:description)
+ end
+ end
+
+ class UnknownStrategy < ::Gitlab::Config::Entry::Node
+ def errors
+ ["variable definition must be either a string or a hash"]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config/entry/variables.rb b/lib/gitlab/ci/config/entry/variables.rb
index efb469ee32a..0284958d9d4 100644
--- a/lib/gitlab/ci/config/entry/variables.rb
+++ b/lib/gitlab/ci/config/entry/variables.rb
@@ -5,43 +5,21 @@ module Gitlab
class Config
module Entry
##
- # Entry that represents environment variables.
+ # Entry that represents CI/CD variables.
+ # CurrentVariables will be renamed to this class when removing the FF `ci_variables_refactoring_to_variable`.
#
- class Variables < ::Gitlab::Config::Entry::Node
- include ::Gitlab::Config::Entry::Validatable
-
- ALLOWED_VALUE_DATA = %i[value description].freeze
-
- validations do
- validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }, if: :use_value_data?
- validates :config, variables: true, unless: :use_value_data?
- end
-
- def value
- @config.to_h { |key, value| [key.to_s, expand_value(value)[:value]] }
+ class Variables
+ def self.new(...)
+ if YamlProcessor::FeatureFlags.enabled?(:ci_variables_refactoring_to_variable)
+ CurrentVariables.new(...)
+ else
+ LegacyVariables.new(...)
+ end
end
def self.default(**)
{}
end
-
- def value_with_data
- @config.to_h { |key, value| [key.to_s, expand_value(value)] }
- end
-
- def use_value_data?
- opt(:use_value_data)
- end
-
- private
-
- def expand_value(value)
- if value.is_a?(Hash)
- { value: value[:value].to_s, description: value[:description] }
- else
- { value: value.to_s, description: nil }
- end
- end
end
end
end
diff --git a/lib/gitlab/config/entry/composable_hash.rb b/lib/gitlab/config/entry/composable_hash.rb
index 9531b7e56fd..0b892fd4552 100644
--- a/lib/gitlab/config/entry/composable_hash.rb
+++ b/lib/gitlab/config/entry/composable_hash.rb
@@ -25,9 +25,9 @@ module Gitlab
entry_class_name = entry_class.name.demodulize.underscore
factory = ::Gitlab::Config::Entry::Factory.new(entry_class)
- .value(config || {})
+ .value(config.nil? ? {} : config)
.with(key: name, parent: self, description: "#{name} #{entry_class_name} definition") # rubocop:disable CodeReuse/ActiveRecord
- .metadata(name: name)
+ .metadata(composable_metadata.merge(name: name))
@entries[name] = factory.create!
end
@@ -38,9 +38,15 @@ module Gitlab
end
end
+ private
+
def composable_class(name, config)
opt(:composable_class)
end
+
+ def composable_metadata
+ {}
+ end
end
end
end
diff --git a/lib/gitlab/config/entry/validators.rb b/lib/gitlab/config/entry/validators.rb
index cc24ae837f3..337cfbc5287 100644
--- a/lib/gitlab/config/entry/validators.rb
+++ b/lib/gitlab/config/entry/validators.rb
@@ -304,6 +304,7 @@ module Gitlab
end
end
+ # This will be removed with the FF `ci_variables_refactoring_to_variable`.
class VariablesValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
@@ -336,6 +337,18 @@ module Gitlab
end
end
+ class AlphanumericValidator < ActiveModel::EachValidator
+ def self.validate(value)
+ value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)
+ end
+
+ def validate_each(record, attribute, value)
+ unless self.class.validate(value)
+ record.errors.add(attribute, 'must be an alphanumeric string')
+ end
+ end
+ end
+
class ExpressionValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(String) && ::Gitlab::Ci::Pipeline::Expression::Statement.new(value).valid?
diff --git a/lib/gitlab/metrics/global_search_slis.rb b/lib/gitlab/metrics/global_search_slis.rb
new file mode 100644
index 00000000000..c9e1700b253
--- /dev/null
+++ b/lib/gitlab/metrics/global_search_slis.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Metrics
+ module GlobalSearchSlis
+ class << self
+ # The following targets are the 99.95th percentile of code searches
+ # gathered on 24-08-2022
+ # from https://log.gprd.gitlab.net/goto/0c89cd80-23af-11ed-8656-f5f2137823ba (internal only)
+ BASIC_CONTENT_TARGET_S = 7.031
+ BASIC_CODE_TARGET_S = 21.903
+ ADVANCED_CONTENT_TARGET_S = 4.865
+ ADVANCED_CODE_TARGET_S = 13.546
+
+ def initialize_slis!
+ return unless Feature.enabled?(:global_search_custom_slis)
+
+ Gitlab::Metrics::Sli::Apdex.initialize_sli(:global_search, possible_labels)
+ end
+
+ def record_apdex(elapsed:, search_type:, search_level:, search_scope:)
+ return unless Feature.enabled?(:global_search_custom_slis)
+
+ Gitlab::Metrics::Sli::Apdex[:global_search].increment(
+ labels: labels(search_type: search_type, search_level: search_level, search_scope: search_scope),
+ success: elapsed < duration_target(search_type, search_scope)
+ )
+ end
+
+ private
+
+ def duration_target(search_type, search_scope)
+ if search_type == 'basic' && content_search?(search_scope)
+ BASIC_CONTENT_TARGET_S
+ elsif search_type == 'basic' && code_search?(search_scope)
+ BASIC_CODE_TARGET_S
+ elsif search_type == 'advanced' && content_search?(search_scope)
+ ADVANCED_CONTENT_TARGET_S
+ elsif search_type == 'advanced' && code_search?(search_scope)
+ ADVANCED_CODE_TARGET_S
+ end
+ end
+
+ def search_types
+ %w[basic advanced]
+ end
+
+ def search_levels
+ %w[project group global]
+ end
+
+ def search_scopes
+ Gitlab::Search::AbuseDetection::ALLOWED_SCOPES
+ end
+
+ def endpoint_ids
+ ['SearchController#show', 'GET /api/:version/search', 'GET /api/:version/projects/:id/(-/)search',
+ 'GET /api/:version/groups/:id/(-/)search']
+ end
+
+ def possible_labels
+ search_types.flat_map do |search_type|
+ search_levels.flat_map do |search_level|
+ search_scopes.flat_map do |search_scope|
+ endpoint_ids.flat_map do |endpoint_id|
+ {
+ search_type: search_type,
+ search_level: search_level,
+ search_scope: search_scope,
+ endpoint_id: endpoint_id
+ }
+ end
+ end
+ end
+ end
+ end
+
+ def labels(search_type:, search_level:, search_scope:)
+ {
+ search_type: search_type,
+ search_level: search_level,
+ search_scope: search_scope,
+ endpoint_id: endpoint_id
+ }
+ end
+
+ def endpoint_id
+ ::Gitlab::ApplicationContext.current_context_attribute(:caller_id)
+ end
+
+ def code_search?(search_scope)
+ search_scope == 'blobs'
+ end
+
+ def content_search?(search_scope)
+ !code_search?(search_scope)
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9f0e910acc2..995284354fc 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6392,6 +6392,9 @@ msgstr ""
msgid "Blocking"
msgstr ""
+msgid "Blocking epics"
+msgstr ""
+
msgid "Blocking issues"
msgstr ""
@@ -16806,6 +16809,9 @@ msgstr ""
msgid "ForkProject|Select a namespace"
msgstr ""
+msgid "ForkProject|Something went wrong while loading data. Please refresh the page to try again."
+msgstr ""
+
msgid "ForkProject|The project can be accessed by any logged in user."
msgstr ""
@@ -33811,6 +33817,12 @@ msgstr ""
msgid "Runners|An error has occurred fetching instructions"
msgstr ""
+msgid "Runners|An upgrade is available for this runner"
+msgstr ""
+
+msgid "Runners|An upgrade is recommended for this runner"
+msgstr ""
+
msgid "Runners|Architecture"
msgstr ""
@@ -33859,6 +33871,9 @@ msgstr ""
msgid "Runners|Copy registration token"
msgstr ""
+msgid "Runners|Created %{timeAgo}"
+msgstr ""
+
msgid "Runners|Delete %d runner"
msgid_plural "Runners|Delete %d runners"
msgstr[0] ""
@@ -33930,6 +33945,9 @@ msgstr ""
msgid "Runners|Last contact"
msgstr ""
+msgid "Runners|Last contact: %{timeAgo}"
+msgstr ""
+
msgid "Runners|Locked to this project"
msgstr ""
@@ -34213,6 +34231,9 @@ msgstr ""
msgid "Runners|Version"
msgstr ""
+msgid "Runners|Version %{version}"
+msgstr ""
+
msgid "Runners|View installation instructions"
msgstr ""
diff --git a/qa/qa/page/project/fork/new.rb b/qa/qa/page/project/fork/new.rb
index e1b5e47dd0b..b622b341685 100644
--- a/qa/qa/page/project/fork/new.rb
+++ b/qa/qa/page/project/fork/new.rb
@@ -6,19 +6,40 @@ module QA
module Fork
class New < Page::Base
view 'app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue' do
- element :fork_namespace_dropdown
element :fork_project_button
element :fork_privacy_button
end
+ view 'app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue' do
+ element :select_namespace_dropdown
+ element :select_namespace_dropdown_item
+ element :select_namespace_dropdown_search_field
+ element :select_namespace_dropdown_item
+ end
+
def fork_project(namespace = Runtime::Namespace.path)
- select_element(:fork_namespace_dropdown, namespace)
+ choose_namespace(namespace)
click_element(:fork_privacy_button, privacy_level: 'public')
click_element(:fork_project_button)
end
- def fork_namespace_dropdown_values
- find_element(:fork_namespace_dropdown).all(:option).map { |option| option.text.tr("\n", '').strip }
+ def get_list_of_namespaces
+ click_element(:select_namespace_dropdown)
+ wait_until(reload: false) do
+ has_element?(:select_namespace_dropdown_item)
+ end
+ all_elements(:select_namespace_dropdown_item, minimum: 1).map(&:text)
+ end
+
+ def choose_namespace(namespace)
+ retry_on_exception do
+ click_element(:select_namespace_dropdown)
+ fill_element(:select_namespace_dropdown_search_field, namespace)
+ wait_until(reload: false) do
+ has_element?(:select_namespace_dropdown_item, text: namespace)
+ end
+ click_button(namespace)
+ end
end
end
end
diff --git a/qa/qa/resource/fork.rb b/qa/qa/resource/fork.rb
index 0e6dd626312..2016b1d948d 100644
--- a/qa/qa/resource/fork.rb
+++ b/qa/qa/resource/fork.rb
@@ -36,7 +36,7 @@ module QA
def fabricate!
populate(:upstream, :user)
- namespace_path ||= user.name
+ namespace_path ||= user.username
# Sign out as admin and sign is as the fork user
Flow::Login.sign_in(as: user)
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 14b198dbefe..0b8e6a6b6a2 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -270,6 +270,17 @@ RSpec.describe SearchController do
get(:show, params: { search: 'foo@bar.com', scope: 'users' })
end
end
+
+ it 'increments the custom search sli apdex' do
+ expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with(
+ elapsed: a_kind_of(Numeric),
+ search_scope: 'issues',
+ search_type: 'basic',
+ search_level: 'global'
+ )
+
+ get :show, params: { scope: 'issues', search: 'hello world' }
+ end
end
describe 'GET #count', :aggregate_failures do
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 91971406bd6..e50674228f6 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within_runner_row(runner.id) do
- expect(find("[data-label='Jobs']")).to have_content '2'
+ expect(find("[data-testid='job-count']")).to have_content '2'
end
end
diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb
index fb27f0961b6..b8c127f0078 100644
--- a/spec/features/projects/fork_spec.rb
+++ b/spec/features/projects/fork_spec.rb
@@ -126,7 +126,10 @@ RSpec.describe 'Project fork' do
let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
def submit_form
- select(group.name)
+ find('[data-testid="select_namespace_dropdown"]').click
+ find('[data-testid="select_namespace_dropdown_search_field"]').fill_in(with: group.name)
+ click_button group.name
+
click_button 'Fork project'
end
diff --git a/spec/frontend/boards/components/board_blocked_icon_spec.js b/spec/frontend/boards/components/board_blocked_icon_spec.js
index cf4ba07da16..ffdc0a7cecc 100644
--- a/spec/frontend/boards/components/board_blocked_icon_spec.js
+++ b/spec/frontend/boards/components/board_blocked_icon_spec.js
@@ -10,13 +10,17 @@ import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
import { truncate } from '~/lib/utils/text_utility';
import {
mockIssue,
+ mockEpic,
mockBlockingIssue1,
mockBlockingIssue2,
+ mockBlockingEpic1,
mockBlockingIssuablesResponse1,
mockBlockingIssuablesResponse2,
mockBlockingIssuablesResponse3,
mockBlockedIssue1,
mockBlockedIssue2,
+ mockBlockedEpic1,
+ mockBlockingEpicIssuablesResponse1,
} from '../mock_data';
describe('BoardBlockedIcon', () => {
@@ -51,9 +55,11 @@ describe('BoardBlockedIcon', () => {
const createWrapperWithApollo = ({
item = mockBlockedIssue1,
blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1),
+ issuableItem = mockIssue,
+ issuableType = issuableTypes.issue,
} = {}) => {
mockApollo = createMockApollo([
- [blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy],
+ [blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy],
]);
Vue.use(VueApollo);
@@ -62,27 +68,34 @@ describe('BoardBlockedIcon', () => {
apolloProvider: mockApollo,
propsData: {
item: {
- ...mockIssue,
+ ...issuableItem,
...item,
},
uniqueId: 'uniqueId',
- issuableType: issuableTypes.issue,
+ issuableType,
},
attachTo: document.body,
}),
);
};
- const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => {
+ const createWrapper = ({
+ item = {},
+ queries = {},
+ data = {},
+ loading = false,
+ mockIssuable = mockIssue,
+ issuableType = issuableTypes.issue,
+ } = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardBlockedIcon, {
propsData: {
item: {
- ...mockIssue,
+ ...mockIssuable,
...item,
},
uniqueId: 'uniqueid',
- issuableType: issuableTypes.issue,
+ issuableType,
},
data() {
return {
@@ -105,11 +118,24 @@ describe('BoardBlockedIcon', () => {
);
};
- it('should render blocked icon', () => {
- createWrapper();
+ it.each`
+ mockIssuable | issuableType | expectedIcon
+ ${mockIssue} | ${issuableTypes.issue} | ${'issue-block'}
+ ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'}
+ `(
+ 'should render blocked icon for $issuableType',
+ ({ mockIssuable, issuableType, expectedIcon }) => {
+ createWrapper({
+ mockIssuable,
+ issuableType,
+ });
- expect(findGlIcon().exists()).toBe(true);
- });
+ expect(findGlIcon().exists()).toBe(true);
+ const icon = findGlIcon();
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe(expectedIcon);
+ },
+ );
it('should display a loading spinner while loading', () => {
createWrapper({ loading: true });
@@ -124,17 +150,29 @@ describe('BoardBlockedIcon', () => {
});
describe('on mouseenter on blocked icon', () => {
- it('should query for blocking issuables and render the result', async () => {
- createWrapperWithApollo();
+ it.each`
+ item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy
+ ${mockBlockedIssue1} | ${issuableTypes.issue} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)}
+ ${mockBlockedEpic1} | ${issuableTypes.epic} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)}
+ `(
+ 'should query for blocking issuables and render the result for $issuableType',
+ async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => {
+ createWrapperWithApollo({
+ item,
+ issuableType,
+ issuableItem,
+ blockingIssuablesSpy,
+ });
- expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
+ expect(findGlPopover().text()).not.toContain(mockBlockingIssuable.title);
- await mouseenter();
+ await mouseenter();
- expect(findGlPopover().exists()).toBe(true);
- expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title);
- expect(wrapper.vm.skip).toBe(true);
- });
+ expect(findGlPopover().exists()).toBe(true);
+ expect(findIssuableTitle().text()).toContain(mockBlockingIssuable.title);
+ expect(wrapper.vm.skip).toBe(true);
+ },
+ );
it('should emit "blocking-issuables-error" event on query error', async () => {
const mockError = new Error('mayday');
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 0e739f03f31..dc1f3246be0 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -266,6 +266,7 @@ export const rawIssue = {
};
export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test';
+export const mockEpicFullPath = 'gitlab-org/test-subgroup';
export const mockIssue = {
id: 'gid://gitlab/Issue/436',
@@ -291,6 +292,47 @@ export const mockIssue = {
type: 'ISSUE',
};
+export const mockEpic = {
+ id: 'gid://gitlab/Epic/26',
+ iid: '1',
+ group: {
+ id: 'gid://gitlab/Group/33',
+ fullPath: 'twitter',
+ __typename: 'Group',
+ },
+ title: 'Eum animi debitis occaecati ad non odio repellat voluptatem similique.',
+ state: 'opened',
+ reference: '&1',
+ referencePath: `${mockEpicFullPath}&1`,
+ webPath: `/groups/${mockEpicFullPath}/-/epics/1`,
+ webUrl: `${mockEpicFullPath}/-/epics/1`,
+ createdAt: '2022-01-18T05:15:15Z',
+ closedAt: null,
+ __typename: 'Epic',
+ relativePosition: null,
+ confidential: false,
+ subscribed: true,
+ blocked: true,
+ blockedByCount: 1,
+ labels: {
+ nodes: [],
+ __typename: 'LabelConnection',
+ },
+ hasIssues: true,
+ descendantCounts: {
+ closedEpics: 0,
+ closedIssues: 0,
+ openedEpics: 0,
+ openedIssues: 2,
+ __typename: 'EpicDescendantCount',
+ },
+ descendantWeightSum: {
+ closedIssues: 0,
+ openedIssues: 0,
+ __typename: 'EpicDescendantWeights',
+ },
+};
+
export const mockActiveIssue = {
...mockIssue,
id: 'gid://gitlab/Issue/436',
@@ -523,6 +565,15 @@ export const mockBlockingIssue1 = {
__typename: 'Issue',
};
+export const mockBlockingEpic1 = {
+ id: 'gid://gitlab/Epic/29',
+ iid: '4',
+ title: 'Sint nihil exercitationem aspernatur unde molestiae rem accusantium.',
+ reference: 'twitter&4',
+ webUrl: 'http://gdk.test:3000/groups/gitlab-org/test-subgroup/-/epics/4',
+ __typename: 'Epic',
+};
+
export const mockBlockingIssue2 = {
id: 'gid://gitlab/Issue/524',
iid: '5',
@@ -564,6 +615,23 @@ export const mockBlockingIssuablesResponse1 = {
},
};
+export const mockBlockingEpicIssuablesResponse1 = {
+ data: {
+ group: {
+ __typename: 'Group',
+ id: 'gid://gitlab/Group/33',
+ issuable: {
+ __typename: 'Epic',
+ id: 'gid://gitlab/Epic/26',
+ blockingIssuables: {
+ __typename: 'EpicConnection',
+ nodes: [mockBlockingEpic1],
+ },
+ },
+ },
+ },
+};
+
export const mockBlockingIssuablesResponse2 = {
data: {
issuable: {
@@ -601,6 +669,12 @@ export const mockBlockedIssue2 = {
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0',
};
+export const mockBlockedEpic1 = {
+ id: '26',
+ blockedByCount: 1,
+ webUrl: 'http://gdk.test:3000/gitlab-org/test-subgroup/-/epics/1',
+};
+
export const mockMoveIssueParams = {
itemId: 1,
fromListId: 'gid://gitlab/List/1',
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
index 2a0fde45384..f676f2db08e 100644
--- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
@@ -4,11 +4,14 @@ import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import { kebabCase } from 'lodash';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
-import httpStatus from '~/lib/utils/http_status';
import * as urlUtility from '~/lib/utils/url_utility';
import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
+import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
jest.mock('~/flash');
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
@@ -16,6 +19,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('ForkForm component', () => {
let wrapper;
let axiosMock;
+ let mockQueryResponse;
const PROJECT_VISIBILITY_TYPE = {
private:
@@ -24,26 +28,11 @@ describe('ForkForm component', () => {
public: 'Public The project can be accessed without any authentication.',
};
- const GON_GITLAB_URL = 'https://gitlab.com';
const GON_API_VERSION = 'v7';
- const MOCK_NAMESPACES_RESPONSE = [
- {
- name: 'one',
- full_name: 'one-group/one',
- id: 1,
- },
- {
- name: 'two',
- full_name: 'two-group/two',
- id: 2,
- },
- ];
-
const DEFAULT_PROVIDE = {
newGroupPath: 'some/groups/path',
visibilityHelpPath: 'some/visibility/help/path',
- endpoint: '/some/project-full-path/-/forks/new.json',
projectFullPath: '/some/project-full-path',
projectId: '10',
projectName: 'Project Name',
@@ -53,12 +42,44 @@ describe('ForkForm component', () => {
restrictedVisibilityLevels: [],
};
- const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => {
- axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data);
- };
+ Vue.use(VueApollo);
const createComponentFactory = (mountFn) => (provide = {}, data = {}) => {
+ const queryResponse = {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ forkTargets: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Group/21',
+ fullPath: 'flightjs',
+ name: 'Flight JS',
+ visibility: 'public',
+ },
+ {
+ id: 'gid://gitlab/Namespace/4',
+ fullPath: 'root',
+ name: 'Administrator',
+ visibility: 'public',
+ },
+ ],
+ },
+ },
+ };
+
+ mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse });
+ const requestHandlers = [[searchQuery, mockQueryResponse]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: searchQuery,
+ data: {
+ ...queryResponse,
+ },
+ });
+
wrapper = mountFn(ForkForm, {
+ apolloProvider,
provide: {
...DEFAULT_PROVIDE,
...provide,
@@ -83,7 +104,6 @@ describe('ForkForm component', () => {
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
window.gon = {
- gitlab_url: GON_GITLAB_URL,
api_version: GON_API_VERSION,
};
});
@@ -93,12 +113,11 @@ describe('ForkForm component', () => {
axiosMock.restore();
});
- const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option');
const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]');
const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]');
const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]');
const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]');
- const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]');
+ const findForkUrlInput = () => wrapper.findComponent(ProjectNamespace);
const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]');
const findForkDescriptionTextarea = () =>
wrapper.find('[data-testid="fork-description-textarea"]');
@@ -106,7 +125,6 @@ describe('ForkForm component', () => {
wrapper.find('[data-testid="fork-visibility-radio-group"]');
it('will go to projectFullPath when click cancel button', () => {
- mockGetRequest();
createComponent();
const { projectFullPath } = DEFAULT_PROVIDE;
@@ -115,8 +133,13 @@ describe('ForkForm component', () => {
expect(cancelButton.attributes('href')).toBe(projectFullPath);
});
+ const selectedMockNamespace = { name: 'two', full_name: 'two-group/two', id: 2 };
+
+ const fillForm = () => {
+ findForkUrlInput().vm.$emit('select', selectedMockNamespace);
+ };
+
it('has input with csrf token', () => {
- mockGetRequest();
createComponent();
expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe(
@@ -125,7 +148,6 @@ describe('ForkForm component', () => {
});
it('pre-populate form from project props', () => {
- mockGetRequest();
createComponent();
expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName);
@@ -135,75 +157,19 @@ describe('ForkForm component', () => {
);
});
- it('sets project URL prepend text with gon.gitlab_url', () => {
- mockGetRequest();
- createComponent();
-
- expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`);
- });
-
it('will have required attribute for required fields', () => {
- mockGetRequest();
createComponent();
expect(findForkNameInput().attributes('required')).not.toBeUndefined();
- expect(findForkUrlInput().attributes('required')).not.toBeUndefined();
expect(findForkSlugInput().attributes('required')).not.toBeUndefined();
expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined();
expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined();
});
- describe('forks namespaces', () => {
- beforeEach(() => {
- mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE });
- createFullComponent();
- });
-
- it('make GET request from endpoint', async () => {
- await axios.waitForAll();
-
- expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint);
- });
-
- it('generate default option', async () => {
- await axios.waitForAll();
-
- const optionsArray = findForkUrlInput().findAll('option');
-
- expect(optionsArray.at(0).text()).toBe('Select a namespace');
- });
-
- it('populate project url namespace options', async () => {
- await axios.waitForAll();
-
- const optionsArray = findForkUrlInput().findAll('option');
-
- expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1);
- expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].full_name);
- expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].full_name);
- });
-
- it('set namespaces in alphabetical order', async () => {
- const namespace = {
- name: 'three',
- full_name: 'aaa/three',
- id: 3,
- };
- mockGetRequest({
- namespaces: [...MOCK_NAMESPACES_RESPONSE, namespace],
- });
- createComponent();
- await axios.waitForAll();
-
- expect(wrapper.vm.namespaces).toEqual([namespace, ...MOCK_NAMESPACES_RESPONSE]);
- });
- });
-
describe('project slug', () => {
const projectPath = 'some other project slug';
beforeEach(() => {
- mockGetRequest();
createComponent({
projectPath,
});
@@ -232,7 +198,6 @@ describe('ForkForm component', () => {
describe('visibility level', () => {
it('displays the correct description', () => {
- mockGetRequest();
createComponent();
const formRadios = wrapper.findAll(GlFormRadio);
@@ -243,7 +208,6 @@ describe('ForkForm component', () => {
});
it('displays all 3 visibility levels', () => {
- mockGetRequest();
createComponent();
expect(wrapper.findAll(GlFormRadio)).toHaveLength(3);
@@ -262,16 +226,12 @@ describe('ForkForm component', () => {
},
];
- beforeEach(() => {
- mockGetRequest();
- });
-
it('resets the visibility to default "private"', async () => {
createFullComponent({ projectVisibility: 'public' }, { namespaces });
expect(wrapper.vm.form.fields.visibility.value).toBe('public');
- await findFormSelectOptions().at(1).setSelected();
+ fillForm();
await nextTick();
expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true);
@@ -280,8 +240,7 @@ describe('ForkForm component', () => {
it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => {
createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces });
- await findFormSelectOptions().at(1).setSelected();
-
+ fillForm();
await nextTick();
const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i });
@@ -315,8 +274,7 @@ describe('ForkForm component', () => {
${'public'} | ${[0, 20]}
${'public'} | ${[10, 20]}
${'public'} | ${[0, 10, 20]}
- `('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => {
- mockGetRequest();
+ `('checks the correct radio button', ({ project, restrictedVisibilityLevels }) => {
createFullComponent({
projectVisibility: project,
restrictedVisibilityLevels,
@@ -357,7 +315,7 @@ describe('ForkForm component', () => {
${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]}
`(
'sets appropriate radio button disabled state',
- async ({
+ ({
project,
namespace,
privateIsDisabled,
@@ -365,7 +323,6 @@ describe('ForkForm component', () => {
publicIsDisabled,
restrictedVisibilityLevels,
}) => {
- mockGetRequest();
createComponent(
{
projectVisibility: project,
@@ -387,11 +344,9 @@ describe('ForkForm component', () => {
const setupComponent = (fields = {}) => {
jest.spyOn(urlUtility, 'redirectTo').mockImplementation();
- mockGetRequest();
createFullComponent(
{},
{
- namespaces: MOCK_NAMESPACES_RESPONSE,
form: {
state: true,
...fields,
@@ -400,17 +355,13 @@ describe('ForkForm component', () => {
);
};
- const selectedMockNamespaceIndex = 1;
- const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id;
-
- const fillForm = async () => {
- const namespaceOptions = findForkUrlInput().findAll('option');
-
- await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected();
- };
+ beforeEach(() => {
+ setupComponent();
+ });
const submitForm = async () => {
- await fillForm();
+ fillForm();
+ await nextTick();
const form = wrapper.find(GlForm);
await form.trigger('submit');
@@ -418,7 +369,7 @@ describe('ForkForm component', () => {
};
describe('with invalid form', () => {
- it('does not make POST request', async () => {
+ it('does not make POST request', () => {
jest.spyOn(axios, 'post');
setupComponent();
@@ -471,7 +422,7 @@ describe('ForkForm component', () => {
description: projectDescription,
id: projectId,
name: projectName,
- namespace_id: namespaceId,
+ namespace_id: selectedMockNamespace.id,
path: projectPath,
visibility: projectVisibility,
};
diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
new file mode 100644
index 00000000000..1a88aebae32
--- /dev/null
+++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
@@ -0,0 +1,177 @@
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+} from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import createFlash from '~/flash';
+import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
+import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
+
+jest.mock('~/flash');
+
+describe('ProjectNamespace component', () => {
+ let wrapper;
+ let originalGon;
+
+ const data = {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/1',
+ forkTargets: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Group/21',
+ fullPath: 'flightjs',
+ name: 'Flight JS',
+ visibility: 'public',
+ },
+ {
+ id: 'gid://gitlab/Namespace/4',
+ fullPath: 'root',
+ name: 'Administrator',
+ visibility: 'public',
+ },
+ ],
+ },
+ },
+ };
+
+ const mockQueryResponse = jest.fn().mockResolvedValue({ data });
+
+ const emptyQueryResponse = {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/1',
+ forkTargets: {
+ nodes: [],
+ },
+ },
+ };
+
+ const mockQueryError = jest.fn().mockRejectedValue(new Error('Network error'));
+
+ Vue.use(VueApollo);
+
+ const gitlabUrl = 'https://gitlab.com';
+
+ const defaultProvide = {
+ projectFullPath: 'gitlab-org/project',
+ };
+
+ const mountComponent = ({
+ provide = defaultProvide,
+ queryHandler = mockQueryResponse,
+ mountFn = shallowMount,
+ } = {}) => {
+ const requestHandlers = [[searchQuery, queryHandler]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = mountFn(ProjectNamespace, {
+ apolloProvider,
+ provide,
+ });
+ };
+
+ const findButtonLabel = () => wrapper.findComponent(GlButton);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownText = () => wrapper.findComponent(GlTruncate);
+ const findInput = () => wrapper.findComponent(GlSearchBoxByType);
+
+ const clickDropdownItem = async () => {
+ wrapper.findComponent(GlDropdownItem).vm.$emit('click');
+ await nextTick();
+ };
+
+ const showDropdown = () => {
+ findDropdown().vm.$emit('shown');
+ };
+
+ beforeAll(() => {
+ originalGon = window.gon;
+ window.gon = { gitlab_url: gitlabUrl };
+ });
+
+ afterAll(() => {
+ window.gon = originalGon;
+ wrapper.destroy();
+ });
+
+ describe('Initial state', () => {
+ beforeEach(() => {
+ mountComponent({ mountFn: mount });
+ jest.runOnlyPendingTimers();
+ });
+
+ it('renders the root url as a label', () => {
+ expect(findButtonLabel().text()).toBe(`${gitlabUrl}/`);
+ expect(findButtonLabel().props('label')).toBe(true);
+ });
+
+ it('renders placeholder text', () => {
+ expect(findDropdownText().props('text')).toBe('Select a namespace');
+ });
+ });
+
+ describe('After user interactions', () => {
+ beforeEach(async () => {
+ mountComponent({ mountFn: mount });
+ jest.runOnlyPendingTimers();
+ await nextTick();
+ showDropdown();
+ });
+
+ it('focuses on the input when the dropdown is opened', () => {
+ const spy = jest.spyOn(findInput().vm, 'focusInput');
+ showDropdown();
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('displays fetched namespaces', () => {
+ const listItems = wrapper.findAll('li');
+ expect(listItems).toHaveLength(3);
+ expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces');
+ expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath);
+ expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath);
+ });
+
+ it('sets the selected namespace', async () => {
+ const { fullPath } = data.project.forkTargets.nodes[0];
+ await clickDropdownItem();
+ expect(findDropdownText().props('text')).toBe(fullPath);
+ });
+ });
+
+ describe('With empty query response', () => {
+ beforeEach(() => {
+ mountComponent({ queryHandler: emptyQueryResponse, mountFn: mount });
+ jest.runOnlyPendingTimers();
+ });
+
+ it('renders `No matches found`', () => {
+ expect(wrapper.find('li').text()).toBe('No matches found');
+ });
+ });
+
+ describe('With error while fetching data', () => {
+ beforeEach(async () => {
+ mountComponent({ queryHandler: mockQueryError });
+ jest.runOnlyPendingTimers();
+ await nextTick();
+ });
+
+ it('creates a flash message and captures the error', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Something went wrong while loading data. Please refresh the page to try again.',
+ captureError: true,
+ error: expect.any(Error),
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js
new file mode 100644
index 00000000000..65550524baa
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js
@@ -0,0 +1,164 @@
+import { __ } from '~/locale';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerStackedSummaryCell from '~/runner/components/cells/runner_stacked_summary_cell.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RunnerTags from '~/runner/components/runner_tags.vue';
+import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants';
+
+import { allRunnersData } from '../../mock_data';
+
+const mockRunner = allRunnersData.data.runners.nodes[0];
+
+describe('RunnerTypeCell', () => {
+ let wrapper;
+
+ const findLockIcon = () => wrapper.findByTestId('lock-icon');
+ const findRunnerTags = () => wrapper.findComponent(RunnerTags);
+ const findRunnerSummaryField = (icon) =>
+ wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon)
+ .wrappers[0];
+
+ const createComponent = (runner, options) => {
+ wrapper = mountExtended(RunnerStackedSummaryCell, {
+ propsData: {
+ runner: {
+ ...mockRunner,
+ ...runner,
+ },
+ },
+ stubs: {
+ RunnerSummaryField,
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays the runner name as id and short token', () => {
+ expect(wrapper.text()).toContain(
+ `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
+ );
+ });
+
+ it('Does not display the locked icon', () => {
+ expect(findLockIcon().exists()).toBe(false);
+ });
+
+ it('Displays the locked icon for locked runners', () => {
+ createComponent({
+ runnerType: PROJECT_TYPE,
+ locked: true,
+ });
+
+ expect(findLockIcon().exists()).toBe(true);
+ });
+
+ it('Displays the runner type', () => {
+ createComponent({
+ runnerType: INSTANCE_TYPE,
+ locked: true,
+ });
+
+ expect(wrapper.text()).toContain('shared');
+ });
+
+ it('Displays the runner version', () => {
+ expect(wrapper.text()).toContain(mockRunner.version);
+ });
+
+ it('Displays the runner description', () => {
+ expect(wrapper.text()).toContain(mockRunner.description);
+ });
+
+ it('Displays last contact', () => {
+ createComponent({
+ contactedAt: '2022-01-02',
+ });
+
+ expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02');
+ });
+
+ it('Displays empty last contact', () => {
+ createComponent({
+ contactedAt: null,
+ });
+
+ expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false);
+ expect(findRunnerSummaryField('clock').text()).toContain(__('Never'));
+ });
+
+ it('Displays ip address', () => {
+ createComponent({
+ ipAddress: '127.0.0.1',
+ });
+
+ expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1');
+ });
+
+ it('Displays no ip address', () => {
+ createComponent({
+ ipAddress: null,
+ });
+
+ expect(findRunnerSummaryField('disk')).toBeUndefined();
+ });
+
+ it('Displays job count', () => {
+ expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`);
+ });
+
+ it('Formats large job counts ', () => {
+ createComponent({
+ jobCount: 1000,
+ });
+
+ expect(findRunnerSummaryField('pipeline').text()).toContain('1,000');
+ });
+
+ it('Formats large job counts with a plus symbol', () => {
+ createComponent({
+ jobCount: 1001,
+ });
+
+ expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+');
+ });
+
+ it('Displays created at', () => {
+ expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe(
+ mockRunner.createdAt,
+ );
+ });
+
+ it('Displays tag list', () => {
+ createComponent({
+ tagList: ['shell', 'linux'],
+ });
+
+ expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']);
+ });
+
+ it('Displays a custom slot', () => {
+ const slotContent = 'My custom runner name';
+
+ createComponent(
+ {},
+ {
+ slots: {
+ 'runner-name': slotContent,
+ },
+ },
+ );
+
+ expect(wrapper.text()).toContain(slotContent);
+ });
+});
diff --git a/spec/frontend/runner/components/cells/runner_summary_field_spec.js b/spec/frontend/runner/components/cells/runner_summary_field_spec.js
new file mode 100644
index 00000000000..b49addf112f
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_summary_field_spec.js
@@ -0,0 +1,49 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+describe('RunnerSummaryField', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value;
+
+ const createComponent = ({ props, ...options } = {}) => {
+ wrapper = shallowMount(RunnerSummaryField, {
+ propsData: {
+ icon: '',
+ tooltip: '',
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows content in slot', () => {
+ createComponent({
+ slots: { default: 'content' },
+ });
+
+ expect(wrapper.text()).toBe('content');
+ });
+
+ it('shows icon', () => {
+ createComponent({ props: { icon: 'git' } });
+
+ expect(findIcon().props('name')).toBe('git');
+ });
+
+ it('shows tooltip', () => {
+ createComponent({ props: { tooltip: 'tooltip' } });
+
+ expect(getTooltipValue()).toBe('tooltip');
+ });
+});
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 95673b3932b..c3c7616f338 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -22,7 +22,10 @@ describe('RunnerList', () => {
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
- const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
+ const createComponent = (
+ { props = {}, provide = {}, ...options } = {},
+ mountFn = shallowMountExtended,
+ ) => {
wrapper = mountFn(RunnerList, {
propsData: {
runners: mockRunners,
@@ -32,6 +35,7 @@ describe('RunnerList', () => {
provide: {
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ ...provide,
},
...options,
});
@@ -221,4 +225,60 @@ describe('RunnerList', () => {
expect(findSkeletonLoader().exists()).toBe(false);
});
});
+
+ describe.each`
+ glFeatures
+ ${{ runnerListStackedLayoutAdmin: true }}
+ ${{ runnerListStackedLayout: true }}
+ `('When glFeatures = $glFeatures', ({ glFeatures }) => {
+ beforeEach(() => {
+ createComponent(
+ {
+ stubs: {
+ RunnerStatusPopover: {
+ template: '<div/>',
+ },
+ },
+ provide: {
+ glFeatures,
+ },
+ },
+ mountExtended,
+ );
+ });
+
+ it('Displays stacked list headers', () => {
+ const headerLabels = findHeaders().wrappers.map((w) => w.text());
+
+ expect(headerLabels).toEqual([
+ 'Status',
+ 'Runner',
+ '', // actions has no label
+ ]);
+ });
+
+ it('Displays stacked details of a runner', () => {
+ const { id, description, version, shortSha } = mockRunners[0];
+ const numericId = getIdFromGraphQLId(id);
+
+ // Badges
+ expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('never contacted');
+
+ // Runner summary
+ const summary = findCell({ fieldKey: 'summary' }).text();
+
+ expect(summary).toContain(`#${numericId} (${shortSha})`);
+ expect(summary).toContain('specific');
+
+ expect(summary).toContain(version);
+ expect(summary).toContain(description);
+
+ expect(summary).toContain('Last contact');
+ expect(summary).toContain('0'); // job count
+ expect(summary).toContain('Created');
+
+ // Actions
+ expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js
new file mode 100644
index 00000000000..8e1623eedf5
--- /dev/null
+++ b/spec/frontend/set_status_modal/set_status_form_spec.js
@@ -0,0 +1,167 @@
+import $ from 'jquery';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import SetStatusForm from '~/set_status_modal/set_status_form.vue';
+import EmojiPicker from '~/emoji/components/picker.vue';
+import { timeRanges } from '~/vue_shared/constants';
+import { sprintf } from '~/locale';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+
+describe('SetStatusForm', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ defaultEmoji: 'speech_balloon',
+ emoji: 'thumbsup',
+ message: 'Foo bar',
+ availability: false,
+ };
+
+ const createComponent = async ({ propsData = {} } = {}) => {
+ wrapper = mountExtended(SetStatusForm, {
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ const findMessageInput = () =>
+ wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder);
+ const findSelectedEmoji = (emoji) =>
+ wrapper.findByTestId('selected-emoji').find(`gl-emoji[data-name="${emoji}"]`);
+
+ it('sets up emoji autocomplete for the message input', async () => {
+ const gfmAutoCompleteSetupSpy = jest.spyOn(GfmAutoComplete.prototype, 'setup');
+
+ await createComponent();
+
+ expect(gfmAutoCompleteSetupSpy).toHaveBeenCalledWith($(findMessageInput().element), {
+ emojis: true,
+ });
+ });
+
+ describe('when emoji is set', () => {
+ it('displays emoji', async () => {
+ await createComponent();
+
+ expect(findSelectedEmoji(defaultPropsData.emoji).exists()).toBe(true);
+ });
+ });
+
+ describe('when emoji is not set and message is changed', () => {
+ it('displays default emoji', async () => {
+ await createComponent({
+ propsData: {
+ emoji: '',
+ },
+ });
+
+ await findMessageInput().trigger('keyup');
+
+ expect(findSelectedEmoji(defaultPropsData.defaultEmoji).exists()).toBe(true);
+ });
+ });
+
+ describe('when message is set', () => {
+ it('displays filled in message input', async () => {
+ await createComponent();
+
+ expect(findMessageInput().element.value).toBe(defaultPropsData.message);
+ });
+ });
+
+ describe('when clear status after is set', () => {
+ it('displays value in dropdown toggle button', async () => {
+ const clearStatusAfter = timeRanges[0];
+
+ await createComponent({
+ propsData: {
+ clearStatusAfter,
+ },
+ });
+
+ expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true);
+ });
+ });
+
+ describe('when emoji is changed', () => {
+ beforeEach(async () => {
+ await createComponent();
+
+ wrapper.findComponent(EmojiPicker).vm.$emit('click', defaultPropsData.emoji);
+ });
+
+ it('emits `emoji-click` event', () => {
+ expect(wrapper.emitted('emoji-click')).toEqual([[defaultPropsData.emoji]]);
+ });
+ });
+
+ describe('when message is changed', () => {
+ it('emits `message-input` event', async () => {
+ await createComponent();
+
+ const newMessage = 'Foo bar baz';
+
+ await findMessageInput().setValue(newMessage);
+
+ expect(wrapper.emitted('message-input')).toEqual([[newMessage]]);
+ });
+ });
+
+ describe('when availability checkbox is changed', () => {
+ it('emits `availability-input` event', async () => {
+ await createComponent();
+
+ await wrapper
+ .findByLabelText(
+ `${SetStatusForm.i18n.availabilityCheckboxLabel} ${SetStatusForm.i18n.availabilityCheckboxHelpText}`,
+ )
+ .setChecked();
+
+ expect(wrapper.emitted('availability-input')).toEqual([[true]]);
+ });
+ });
+
+ describe('when `Clear status after` dropdown is changed', () => {
+ it('emits `clear-status-after-click`', async () => {
+ await wrapper.findByTestId('thirtyMinutes').trigger('click');
+
+ expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]);
+ });
+ });
+
+ describe('when clear status button is clicked', () => {
+ beforeEach(async () => {
+ await createComponent();
+
+ await wrapper
+ .findByRole('button', { name: SetStatusForm.i18n.clearStatusButtonLabel })
+ .trigger('click');
+ });
+
+ it('clears emoji and message', () => {
+ expect(wrapper.emitted('emoji-click')).toEqual([['']]);
+ expect(wrapper.emitted('message-input')).toEqual([['']]);
+ expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true);
+ });
+ });
+
+ describe('when `currentClearStatusAfter` prop is set', () => {
+ it('displays clear status message', async () => {
+ const date = '2022-08-25 21:14:48 UTC';
+
+ await createComponent({
+ propsData: {
+ currentClearStatusAfter: date,
+ },
+ });
+
+ expect(
+ wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(),
+ ).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
index e3b5478290a..4191c44bb99 100644
--- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -9,6 +9,7 @@ import stubChildren from 'helpers/stub_children';
import SetStatusModalWrapper, {
AVAILABILITY_STATUS,
} from '~/set_status_modal/set_status_modal_wrapper.vue';
+import SetStatusForm from '~/set_status_modal/set_status_form.vue';
jest.mock('~/flash');
@@ -42,6 +43,7 @@ describe('SetStatusModalWrapper', () => {
...stubChildren(SetStatusModalWrapper),
GlFormInput: false,
GlFormInputGroup: false,
+ SetStatusForm: false,
EmojiPicker: EmojiPickerStub,
},
mocks: {
@@ -118,10 +120,10 @@ describe('SetStatusModalWrapper', () => {
});
});
- it('sets emojiTag when clicking in emoji picker', async () => {
+ it('passes emoji to `SetStatusForm`', async () => {
await getEmojiPicker().vm.$emit('click', 'thumbsup');
- expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"');
+ expect(wrapper.findComponent(SetStatusForm).props('emoji')).toBe('thumbsup');
});
});
@@ -194,7 +196,7 @@ describe('SetStatusModalWrapper', () => {
findAvailabilityCheckbox().vm.$emit('input', true);
// set the currentClearStatusAfter to 30 minutes
- wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click');
+ wrapper.find('[data-testid="thirtyMinutes"]').trigger('click');
findModal().vm.$emit('primary');
await nextTick();
diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js
index 1d84685bf88..af91d8aeb6b 100644
--- a/spec/frontend/surveys/merge_request_performance/app_spec.js
+++ b/spec/frontend/surveys/merge_request_performance/app_spec.js
@@ -6,6 +6,17 @@ import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisse
import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
+const createRenderTrackedArguments = () => [
+ undefined,
+ 'survey:mr_experience',
+ {
+ label: 'render',
+ extra: {
+ accountAge: 0,
+ },
+ },
+];
+
describe('MergeRequestExperienceSurveyApp', () => {
let trackingSpy;
let wrapper;
@@ -24,6 +35,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
dismiss,
shouldShowCallout,
});
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, {
propsData: {
accountAge: 0,
@@ -33,9 +45,12 @@ describe('MergeRequestExperienceSurveyApp', () => {
GlSprintf,
},
});
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
describe('when user callout is visible', () => {
beforeEach(() => {
createWrapper();
@@ -47,6 +62,16 @@ describe('MergeRequestExperienceSurveyApp', () => {
expect(wrapper.emitted().close).toBe(undefined);
});
+ it('tracks render once', async () => {
+ expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
+ });
+
+ it("doesn't track subsequent renders", async () => {
+ createWrapper();
+ expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ });
+
describe('when close button clicked', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
@@ -68,6 +93,15 @@ describe('MergeRequestExperienceSurveyApp', () => {
},
});
});
+
+ it('tracks subsequent renders', async () => {
+ createWrapper();
+ expect(trackingSpy.mock.calls).toEqual([
+ createRenderTrackedArguments(),
+ expect.anything(),
+ createRenderTrackedArguments(),
+ ]);
+ });
});
it('applies correct feature name for user callout', () => {
@@ -148,6 +182,10 @@ describe('MergeRequestExperienceSurveyApp', () => {
it('emits close event', async () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
+
+ it("doesn't track anything", async () => {
+ expect(trackingSpy).toHaveBeenCalledTimes(0);
+ });
});
describe('when Escape key is pressed', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
index cb53dc1fb61..063425454d7 100644
--- a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
@@ -1,10 +1,10 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue';
let wrapper;
function factory(propsData) {
- wrapper = shallowMount(AddedCommentMessage, {
+ wrapper = mount(AddedCommentMessage, {
propsData: {
isFastForwardEnabled: false,
targetBranch: 'main',
@@ -23,4 +23,13 @@ describe('Widget added commit message', () => {
expect(wrapper.element.outerHTML).toContain('The changes were not merged');
});
+
+ it('renders merge commit as a link', () => {
+ factory({ state: 'merged', mergeCommitPath: 'https://test.host/merge-commit-link' });
+
+ expect(wrapper.find('[data-testid="merge-commit-sha"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="merge-commit-sha"]').attributes('href')).toBe(
+ 'https://test.host/merge-commit-link',
+ );
+ });
});
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index ca336c3ecaa..75ac2ca87ab 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -605,8 +605,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
let(:deps) do
double('deps',
'default_entry' => default,
- 'workflow_entry' => workflow,
- 'variables_value' => nil)
+ 'workflow_entry' => workflow)
end
context 'when job config overrides default config' do
diff --git a/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb b/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb
new file mode 100644
index 00000000000..252a2b4eacb
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::LegacyVariables do
+ let(:config) { {} }
+ let(:metadata) { {} }
+
+ subject(:entry) { described_class.new(config, **metadata) }
+
+ before do
+ entry.compose!
+ end
+
+ shared_examples 'valid config' do
+ describe '#value' do
+ it 'returns hash with key value strings' do
+ expect(entry.value).to eq result
+ end
+ end
+
+ describe '#errors' do
+ it 'does not append errors' do
+ expect(entry.errors).to be_empty
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+ end
+
+ shared_examples 'invalid config' do |error_message|
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
+ end
+ end
+
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors)
+ .to include(error_message)
+ end
+ end
+ end
+
+ context 'when entry config value has key-value pairs' do
+ let(:config) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
+
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: nil },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
+ end
+
+ context 'with numeric keys and values in the config' do
+ let(:config) { { 10 => 20 } }
+ let(:result) do
+ { '10' => '20' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+
+ context 'when key is an array' do
+ let(:config) { { ['VAR1'] => 'val1' } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+ end
+
+ context 'when value is a symbol' do
+ let(:config) { { 'VAR1' => :val1 } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+
+ context 'when value is a boolean' do
+ let(:config) { { 'VAR1' => true } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+ end
+
+ context 'when entry config value has key-value pair and hash' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
+ 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+
+ context 'when metadata has use_value_data: true' do
+ let(:metadata) { { use_value_data: true } }
+
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
+ end
+ end
+
+ context 'when entry value is an array' do
+ let(:config) { [:VAR, 'test'] }
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+ end
+
+ context 'when metadata has use_value_data: true' do
+ let(:metadata) { { use_value_data: true } }
+
+ context 'when entry value has hash with other key-pairs' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' },
+ 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/
+ end
+
+ context 'when entry config value has hash with nil description' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1', description: nil } }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/
+ end
+
+ context 'when entry config value has hash without description' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1' } }
+ end
+
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb
index 714b0a3b6aa..5f42a8c49a7 100644
--- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb
@@ -197,6 +197,34 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do
end
end
end
+
+ context 'when a variable has an invalid data attribute' do
+ let(:config) do
+ {
+ script: 'echo',
+ variables: { 'VAR1' => 'val 1', 'VAR2' => { value: 'val 2', description: 'hello var 2' } }
+ }
+ end
+
+ it 'reports error about variable' do
+ expect(entry.errors)
+ .to include 'variables:var2 config must be a string'
+ end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:entry_without_ff) { node_class.new(config, name: :rspec) }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ entry_without_ff.compose!
+ end
+
+ it 'reports error about variable' do
+ expect(entry_without_ff.errors)
+ .to include /config should be a hash of key value pairs/
+ end
+ end
+ end
end
end
@@ -212,13 +240,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do
let(:unspecified) { double('unspecified', 'specified?' => false) }
let(:default) { double('default', '[]' => unspecified) }
let(:workflow) { double('workflow', 'has_rules?' => false) }
- let(:variables) {}
let(:deps) do
double('deps',
default_entry: default,
- workflow_entry: workflow,
- variables_value: variables)
+ workflow_entry: workflow)
end
context 'with workflow rules' do
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index 55ad119ea21..5efc65f2117 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -350,6 +350,33 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
end
end
end
+
+ context 'when a variable has an invalid data key' do
+ let(:hash) do
+ { variables: { VAR1: { invalid: 'hello' } }, rspec: { script: 'hello' } }
+ end
+
+ describe '#errors' do
+ it 'reports errors about the invalid variable' do
+ expect(root.errors)
+ .to include /var1 config uses invalid data keys: invalid/
+ end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:root_without_ff) { described_class.new(hash, user: user, project: project) }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ root_without_ff.compose!
+ end
+
+ it 'reports errors about the invalid variable' do
+ expect(root_without_ff.errors)
+ .to include /variables config should be a hash of key value pairs, value can be a hash/
+ end
+ end
+ end
+ end
end
context 'when value is not a hash' do
diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
index c85fe366da6..303d825c591 100644
--- a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
require 'gitlab_chronic_duration'
-require_dependency 'active_model'
RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
let(:factory) do
@@ -363,7 +362,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
it { is_expected.not_to be_valid }
it 'returns an error about invalid variables:' do
- expect(subject.errors).to include(/variables config should be a hash of key value pairs/)
+ expect(subject.errors).to include(/variables config should be a hash/)
+ end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:entry_without_ff) { factory.create! }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ entry_without_ff.compose!
+ end
+
+ it 'returns an error about invalid variables:' do
+ expect(subject.errors).to include(/variables config should be a hash/)
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/variable_spec.rb b/spec/lib/gitlab/ci/config/entry/variable_spec.rb
new file mode 100644
index 00000000000..ebc4e8f9984
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/variable_spec.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::Variable do
+ let(:config) { {} }
+ let(:metadata) { {} }
+
+ subject(:entry) do
+ described_class.new(config, **metadata).tap do |entry|
+ entry.key = 'VAR1' # composable_hash requires key to be set
+ end
+ end
+
+ before do
+ entry.compose!
+ end
+
+ describe 'SimpleVariable' do
+ context 'when config is a string' do
+ let(:config) { 'value' }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+ end
+
+ context 'when config is an integer' do
+ let(:config) { 1 }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('1') }
+ end
+ end
+
+ context 'when config is an array' do
+ let(:config) { [] }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'variable definition must be either a string or a hash' }
+ end
+ end
+ end
+
+ describe 'ComplexVariable' do
+ context 'when config is a hash with description' do
+ let(:config) { { value: 'value', description: 'description' } }
+
+ context 'when metadata allowed_value_data is not provided' do
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config must be a string' }
+ end
+ end
+
+ context 'when metadata allowed_value_data is (value, description)' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: 'description') }
+ end
+
+ context 'when config value is a symbol' do
+ let(:config) { { value: :value, description: 'description' } }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: 'description') }
+ end
+ end
+
+ context 'when config value is an integer' do
+ let(:config) { { value: 123, description: 'description' } }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('123') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: '123', description: 'description') }
+ end
+ end
+
+ context 'when config value is an array' do
+ let(:config) { { value: ['value'], description: 'description' } }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config value must be an alphanumeric string' }
+ end
+ end
+
+ context 'when config description is a symbol' do
+ let(:config) { { value: 'value', description: :description } }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: :description) }
+ end
+ end
+ end
+
+ context 'when metadata allowed_value_data is (value, xyz)' do
+ let(:metadata) { { allowed_value_data: %i[value xyz] } }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config uses invalid data keys: description' }
+ end
+ end
+ end
+
+ context 'when config is a hash without description' do
+ let(:config) { { value: 'value' } }
+
+ context 'when metadata allowed_value_data is not provided' do
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config must be a string' }
+ end
+ end
+
+ context 'when metadata allowed_value_data is (value, description)' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: nil) }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb
index 78d37e228df..055975e4311 100644
--- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb
@@ -3,41 +3,46 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Variables do
+ let(:config) { {} }
let(:metadata) { {} }
- subject { described_class.new(config, **metadata) }
+ subject(:entry) { described_class.new(config, **metadata) }
+
+ before do
+ entry.compose!
+ end
shared_examples 'valid config' do
describe '#value' do
it 'returns hash with key value strings' do
- expect(subject.value).to eq result
+ expect(entry.value).to eq result
end
end
describe '#errors' do
it 'does not append errors' do
- expect(subject.errors).to be_empty
+ expect(entry.errors).to be_empty
end
end
describe '#valid?' do
it 'is valid' do
- expect(subject).to be_valid
+ expect(entry).to be_valid
end
end
end
- shared_examples 'invalid config' do
+ shared_examples 'invalid config' do |error_message|
describe '#valid?' do
it 'is not valid' do
- expect(subject).not_to be_valid
+ expect(entry).not_to be_valid
end
end
describe '#errors' do
it 'saves errors' do
- expect(subject.errors)
- .to include /should be a hash of key value pairs/
+ expect(entry.errors)
+ .to include(error_message)
end
end
end
@@ -52,6 +57,15 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
end
it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: nil },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
end
context 'with numeric keys and values in the config' do
@@ -63,33 +77,63 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
it_behaves_like 'valid config'
end
+ context 'when key is an array' do
+ let(:config) { { ['VAR1'] => 'val1' } }
+
+ it_behaves_like 'invalid config', /must be an alphanumeric string/
+ end
+
+ context 'when value is a symbol' do
+ let(:config) { { 'VAR1' => :val1 } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+
+ context 'when value is a boolean' do
+ let(:config) { { 'VAR1' => true } }
+
+ it_behaves_like 'invalid config', /must be either a string or a hash/
+ end
+
context 'when entry config value has key-value pair and hash' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
'VARIABLE_2' => 'value 2' }
end
- let(:result) do
- { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
- end
+ it_behaves_like 'invalid config', /variable_1 config must be a string/
- it_behaves_like 'invalid config'
+ context 'when metadata has allowed_value_data' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
- context 'when metadata has use_value_data' do
- let(:metadata) { { use_value_data: true } }
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
end
end
context 'when entry value is an array' do
let(:config) { [:VAR, 'test'] }
- it_behaves_like 'invalid config'
+ it_behaves_like 'invalid config', /variables config should be a hash/
end
- context 'when metadata has use_value_data' do
- let(:metadata) { { use_value_data: true } }
+ context 'when metadata has allowed_value_data' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
context 'when entry value has hash with other key-pairs' do
let(:config) do
@@ -97,7 +141,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
'VARIABLE_2' => 'value 2' }
end
- it_behaves_like 'invalid config'
+ it_behaves_like 'invalid config', /variable_1 config uses invalid data keys: hello/
end
context 'when entry config value has hash with nil description' do
@@ -105,7 +149,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
{ 'VARIABLE_1' => { value: 'value 1', description: nil } }
end
- it_behaves_like 'invalid config'
+ it_behaves_like 'invalid config', /variable_1 config description must be an alphanumeric string/
end
context 'when entry config value has hash without description' do
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index eafa8f8fb25..bffdd370179 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -1033,24 +1033,26 @@ module Gitlab
end
end
- describe 'Variables' do
- subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
+ # Change this to a `describe` block when removing the FF ci_variables_refactoring_to_variable
+ shared_examples 'Variables' do
+ subject(:execute) { described_class.new(config).execute }
- let(:build) { subject.builds.first }
+ let(:build) { execute.builds.first }
let(:job_variables) { build[:job_variables] }
let(:root_variables_inheritance) { build[:root_variables_inheritance] }
context 'when global variables are defined' do
- let(:variables) do
- { 'VAR1' => 'value1', 'VAR2' => 'value2' }
- end
-
let(:config) do
- {
- variables: variables,
- before_script: ['pwd'],
- rspec: { script: 'rspec' }
- }
+ <<~YAML
+ variables:
+ VAR1: value1
+ VAR2: value2
+
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ YAML
end
it 'returns global variables' do
@@ -1060,16 +1062,17 @@ module Gitlab
end
context 'when job variables are defined' do
- let(:config) do
- {
- before_script: ['pwd'],
- rspec: { script: 'rspec', variables: variables }
- }
- end
-
context 'when syntax is correct' do
- let(:variables) do
- { 'VAR1' => 'value1', 'VAR2' => 'value2' }
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ variables:
+ VAR1: value1
+ VAR2: value2
+ YAML
end
it 'returns job variables' do
@@ -1083,16 +1086,28 @@ module Gitlab
context 'when syntax is incorrect' do
context 'when variables defined but invalid' do
- let(:variables) do
- %w(VAR1 value1 VAR2 value2)
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ variables: [VAR1 value1 VAR2 value2]
+ YAML
end
- it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash of key value pairs/
+ it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash/
end
context 'when variables key defined but value not specified' do
- let(:variables) do
- nil
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ variables: null
+ YAML
end
it 'returns empty array' do
@@ -1109,10 +1124,12 @@ module Gitlab
context 'when job variables are not defined' do
let(:config) do
- {
- before_script: ['pwd'],
- rspec: { script: 'rspec' }
- }
+ <<~YAML
+ before_script: ['pwd']
+
+ rspec:
+ script: rspec
+ YAML
end
it 'returns empty array' do
@@ -1120,6 +1137,42 @@ module Gitlab
expect(root_variables_inheritance).to eq(true)
end
end
+
+ context 'when variables have different type of values' do
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ variables:
+ VAR1: value1
+ VAR2: :value2
+ VAR3: 123
+ script: rspec
+ YAML
+ end
+
+ it 'returns job variables' do
+ expect(job_variables).to contain_exactly(
+ { key: 'VAR1', value: 'value1', public: true },
+ { key: 'VAR2', value: 'value2', public: true },
+ { key: 'VAR3', value: '123', public: true }
+ )
+ expect(root_variables_inheritance).to eq(true)
+ end
+ end
+ end
+
+ context 'when ci_variables_refactoring_to_variable is enabled' do
+ it_behaves_like 'Variables'
+ end
+
+ context 'when ci_variables_refactoring_to_variable is disabled' do
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ end
+
+ it_behaves_like 'Variables'
end
context 'when using `extends`' do
@@ -2705,13 +2758,13 @@ module Gitlab
context 'returns errors if variables is not a map' do
let(:config) { YAML.dump({ variables: "test", rspec: { script: "test" } }) }
- it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash'
+ it_behaves_like 'returns errors', 'variables config should be a hash'
end
context 'returns errors if variables is not a map of key-value strings' do
let(:config) { YAML.dump({ variables: { test: false }, rspec: { script: "test" } }) }
- it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash'
+ it_behaves_like 'returns errors', 'variable definition must be either a string or a hash'
end
context 'returns errors if job when is not on_success, on_failure or always' do
diff --git a/spec/lib/gitlab/config/entry/composable_hash_spec.rb b/spec/lib/gitlab/config/entry/composable_hash_spec.rb
index f64b39231a3..331c9efc741 100644
--- a/spec/lib/gitlab/config/entry/composable_hash_spec.rb
+++ b/spec/lib/gitlab/config/entry/composable_hash_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe Gitlab::Config::Entry::ComposableHash, :aggregate_failures do
let(:valid_config) do
{
DATABASE_SECRET: 'passw0rd',
- API_TOKEN: 'passw0rd2'
+ API_TOKEN: 'passw0rd2',
+ ACCEPT_PASSWORD: false
}
end
@@ -55,6 +56,12 @@ RSpec.describe Gitlab::Config::Entry::ComposableHash, :aggregate_failures do
expect(entry[:API_TOKEN].metadata).to eq(name: :API_TOKEN)
expect(entry[:API_TOKEN].parent.class).to eq(Gitlab::Config::Entry::ComposableHash)
expect(entry[:API_TOKEN].value).to eq('passw0rd2')
+ expect(entry[:ACCEPT_PASSWORD]).to be_a(Gitlab::Config::Entry::Node)
+ expect(entry[:ACCEPT_PASSWORD].description).to eq('ACCEPT_PASSWORD node definition')
+ expect(entry[:ACCEPT_PASSWORD].key).to eq(:ACCEPT_PASSWORD)
+ expect(entry[:ACCEPT_PASSWORD].metadata).to eq(name: :ACCEPT_PASSWORD)
+ expect(entry[:ACCEPT_PASSWORD].parent.class).to eq(Gitlab::Config::Entry::ComposableHash)
+ expect(entry[:ACCEPT_PASSWORD].value).to eq(false)
end
describe '#descendants' do
diff --git a/spec/lib/gitlab/metrics/global_search_slis_spec.rb b/spec/lib/gitlab/metrics/global_search_slis_spec.rb
new file mode 100644
index 00000000000..23f79abc27e
--- /dev/null
+++ b/spec/lib/gitlab/metrics/global_search_slis_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::GlobalSearchSlis do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ stub_feature_flags(global_search_custom_slis: feature_flag_enabled)
+ end
+
+ describe '#initialize_slis!' do
+ context 'when global_search_custom_slis feature flag is enabled' do
+ let(:feature_flag_enabled) { true }
+
+ it 'initializes Apdex SLI for global_search' do
+ expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(
+ :global_search,
+ a_kind_of(Array)
+ )
+
+ described_class.initialize_slis!
+ end
+ end
+
+ context 'when global_search_custom_slis feature flag is disabled' do
+ let(:feature_flag_enabled) { false }
+
+ it 'does not initialzie the Apdex SLI for global_search' do
+ expect(Gitlab::Metrics::Sli::Apdex).not_to receive(:initialize_sli)
+
+ described_class.initialize_slis!
+ end
+ end
+ end
+
+ describe '#record_apdex' do
+ context 'when global_search_custom_slis feature flag is enabled' do
+ let(:feature_flag_enabled) { true }
+
+ where(:search_type, :code_search, :duration_target) do
+ 'basic' | false | 7.031
+ 'basic' | true | 21.903
+ 'advanced' | false | 4.865
+ 'advanced' | true | 13.546
+ end
+
+ with_them do
+ before do
+ allow(::Gitlab::ApplicationContext).to receive(:current_context_attribute).with(:caller_id).and_return('end')
+ end
+
+ let(:search_scope) { code_search ? 'blobs' : 'issues' }
+
+ it 'increments the global_search SLI as a success if the elapsed time is within the target' do
+ duration = duration_target - 0.1
+
+ expect(Gitlab::Metrics::Sli::Apdex[:global_search]).to receive(:increment).with(
+ labels: {
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope,
+ endpoint_id: 'end'
+ },
+ success: true
+ )
+
+ described_class.record_apdex(
+ elapsed: duration,
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope
+ )
+ end
+
+ it 'increments the global_search SLI as a failure if the elapsed time is not within the target' do
+ duration = duration_target + 0.1
+
+ expect(Gitlab::Metrics::Sli::Apdex[:global_search]).to receive(:increment).with(
+ labels: {
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope,
+ endpoint_id: 'end'
+ },
+ success: false
+ )
+
+ described_class.record_apdex(
+ elapsed: duration,
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope
+ )
+ end
+ end
+ end
+
+ context 'when global_search_custom_slis feature flag is disabled' do
+ let(:feature_flag_enabled) { false }
+
+ it 'does not call increment on the apdex SLI' do
+ expect(Gitlab::Metrics::Sli::Apdex[:global_search]).not_to receive(:increment)
+
+ described_class.record_apdex(
+ elapsed: 1,
+ search_type: 'basic',
+ search_level: 'global',
+ search_scope: 'issues'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
index 7d96adf95e8..8d4629bf48b 100644
--- a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
+++ b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
@@ -150,6 +150,29 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do
}
)
end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:entry_without_ff) { described_class.new(config, with_image_ports: true) }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ entry_without_ff.compose!
+ end
+
+ it 'returns correct value' do
+ expect(entry_without_ff.value)
+ .to eq(
+ tag_list: ['webide'],
+ job_variables: [{ key: 'KEY', value: 'value', public: true }],
+ options: {
+ image: { name: "image:1.0" },
+ services: [{ name: "mysql" }],
+ before_script: %w[ls pwd],
+ script: ['sleep 100']
+ }
+ )
+ end
+ end
end
end
end
diff --git a/spec/requests/admin/hook_logs_controller_spec.rb b/spec/requests/admin/hook_logs_controller_spec.rb
new file mode 100644
index 00000000000..f8d3381c052
--- /dev/null
+++ b/spec/requests/admin/hook_logs_controller_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::HookLogsController, :enable_admin_mode do
+ let_it_be(:user) { create(:admin) }
+ let_it_be_with_refind(:web_hook) { create(:system_hook) }
+ let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) }
+
+ it_behaves_like WebHooks::HookLogActions do
+ let!(:show_path) { admin_hook_hook_log_path(web_hook, web_hook_log) }
+ let!(:retry_path) { retry_admin_hook_hook_log_path(web_hook, web_hook_log) }
+ let(:edit_hook_path) { edit_admin_hook_path(web_hook) }
+ end
+end
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 6034d26f1d2..bc410f657b9 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -351,6 +351,17 @@ RSpec.describe API::Search do
end
end
+ it 'increments the custom search sli apdex' do
+ expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with(
+ elapsed: a_kind_of(Numeric),
+ search_scope: 'issues',
+ search_type: 'basic',
+ search_level: 'global'
+ )
+
+ get api(endpoint, user), params: { scope: 'issues', search: 'john doe' }
+ end
+
it 'sets global search information for logging' do
expect(Gitlab::Instrumentation::GlobalSearchApi).to receive(:set_information).with(
type: 'basic',
diff --git a/spec/requests/projects/hook_logs_controller_spec.rb b/spec/requests/projects/hook_logs_controller_spec.rb
new file mode 100644
index 00000000000..8b3ec307e53
--- /dev/null
+++ b/spec/requests/projects/hook_logs_controller_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::HookLogsController do
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_refind(:web_hook) { create(:project_hook) }
+ let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) }
+
+ let(:project) { web_hook.project }
+
+ it_behaves_like WebHooks::HookLogActions do
+ let(:edit_hook_path) { edit_project_hook_url(project, web_hook) }
+
+ before do
+ project.add_owner(user)
+ end
+ end
+end
diff --git a/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb b/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb
new file mode 100644
index 00000000000..77daff901a1
--- /dev/null
+++ b/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Settings::IntegrationHookLogsController do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:integration) { create(:datadog_integration) }
+ let_it_be_with_refind(:web_hook) { integration.service_hook }
+ let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) }
+
+ let(:project) { integration.project }
+
+ it_behaves_like WebHooks::HookLogActions do
+ let(:edit_hook_path) { edit_project_settings_integration_url(project, integration) }
+
+ before do
+ project.add_owner(user)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb
new file mode 100644
index 00000000000..62c9c3508a8
--- /dev/null
+++ b/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples WebHooks::HookLogActions do
+ let!(:show_path) { web_hook_log.present.details_path }
+ let!(:retry_path) { web_hook_log.present.retry_path }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ it 'renders a 200 if the hook exists' do
+ get show_path
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('hook_logs/show')
+ end
+
+ it 'renders a 404 if the hook does not exist' do
+ web_hook.destroy!
+ get show_path
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'POST #retry' do
+ it 'executes the hook and redirects to the service form' do
+ stub_request(:post, web_hook.url)
+
+ expect_next_found_instance_of(web_hook.class) do |hook|
+ expect(hook).to receive(:execute).and_call_original
+ end
+
+ post retry_path
+
+ expect(response).to redirect_to(edit_hook_path)
+ end
+
+ it 'renders a 404 if the hook does not exist' do
+ web_hook.destroy!
+ post retry_path
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
index 2f52c0fd36c..4cc5df385a5 100644
--- a/spec/tooling/danger/project_helper_spec.rb
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -31,6 +31,8 @@ RSpec.describe Tooling::Danger::ProjectHelper do
end
where(:path, :expected_categories) do
+ 'glfm_specification/example_snapshots/prosemirror_json.yml' | [:frontend]
+ 'glfm_specification/input/glfm_anything.yml' | [:frontend, :backend]
'usage_data.rb' | [:database, :backend, :product_intelligence]
'doc/foo.md' | [:docs]
'CONTRIBUTING.md' | [:docs]
diff --git a/tooling/danger/project_helper.rb b/tooling/danger/project_helper.rb
index d8c7d617927..1d052bf6bbd 100644
--- a/tooling/danger/project_helper.rb
+++ b/tooling/danger/project_helper.rb
@@ -17,6 +17,10 @@ module Tooling
# First-match win, so be sure to put more specific regex at the top...
CATEGORIES = {
+ # GitLab Flavored Markdown Specification files. See more context at: https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#specification-files
+ %r{\Aglfm_specification/.+prosemirror_json\.yml} => [:frontend],
+ %r{\Aglfm_specification/.+\.yml} => [:frontend, :backend],
+
[%r{usage_data\.rb}, %r{^(\+|-).*\s+(count|distinct_count|estimate_batch_distinct_count)\(.*\)(.*)$}] => [:database, :backend, :product_intelligence],
%r{\A((ee|jh)/)?config/feature_flags/} => :feature_flag,