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--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue18
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue26
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue31
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js9
-rw-r--r--app/assets/javascripts/alerts_settings/index.js18
-rw-r--r--app/assets/javascripts/alerts_settings/utils/error_messages.js8
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js2
-rw-r--r--app/assets/javascripts/boards/components/board_assignee_dropdown.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue10
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js3
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue2
-rw-r--r--app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql5
-rw-r--r--app/assets/javascripts/boards/stores/actions.js27
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js7
-rw-r--r--app/assets/javascripts/boards/stores/getters.js6
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js13
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue70
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue10
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue7
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue16
-rw-r--r--app/assets/javascripts/issue_show/issue.js2
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue8
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue108
-rw-r--r--app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js36
-rw-r--r--app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js42
-rw-r--r--app/assets/javascripts/search/dropdown_filter/index.js38
-rw-r--r--app/assets/javascripts/search/index.js8
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue41
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue1
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue1
-rw-r--r--app/assets/javascripts/search/sidebar/index.js23
-rw-r--r--app/assets/javascripts/search/store/actions.js9
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue10
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/index.js3
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue2
-rw-r--r--app/assets/javascripts/static_site_editor/services/renderers/render_image.js73
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js4
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_dark.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss66
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss57
-rw-r--r--app/assets/stylesheets/pages/issuable.scss6
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb8
-rw-r--r--app/controllers/search_controller.rb4
-rw-r--r--app/helpers/operations_helper.rb3
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/serializers/note_entity.rb4
-rw-r--r--app/services/personal_access_tokens/create_service.rb27
-rw-r--r--app/services/personal_access_tokens/revoke_service.rb11
-rw-r--r--app/services/resource_access_tokens/create_service.rb4
-rw-r--r--app/views/search/_results.html.haml39
-rw-r--r--app/views/search/results/_filters.html.haml6
-rw-r--r--app/workers/remove_expired_members_worker.rb10
-rw-r--r--changelogs/unreleased/218531-determine-image-relative-paths.yml5
-rw-r--r--changelogs/unreleased/221035-ide-solarized-light.yml5
-rw-r--r--changelogs/unreleased/235385-ide-monokai-theme.yml5
-rw-r--r--changelogs/unreleased/241691-left-side-facets.yml5
-rw-r--r--changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml5
-rw-r--r--changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml5
-rw-r--r--changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml5
-rw-r--r--config/feature_flags/development/search_facets.yml7
-rw-r--r--config/feature_flags/licensed/minimal_access_role.yml7
-rw-r--r--config/initializers/0_inject_feature_flags.rb1
-rw-r--r--doc/README.md2
-rw-r--r--doc/administration/audit_events.md2
-rw-r--r--lib/api/internal/base.rb2
-rw-r--r--lib/api/personal_access_tokens.rb2
-rw-r--r--locale/gitlab.pot21
-rwxr-xr-xscripts/lint-doc.sh8
-rwxr-xr-xscripts/vale.rb23
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb8
-rw-r--r--spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap4
-rw-r--r--spec/frontend/alerts_settings/alerts_integrations_list_spec.js19
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_form_new_spec.js13
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js42
-rw-r--r--spec/frontend/alerts_settings/util.js2
-rw-r--r--spec/frontend/boards/components/board_assignee_dropdown_spec.js2
-rw-r--r--spec/frontend/boards/mock_data.js3
-rw-r--r--spec/frontend/boards/stores/actions_spec.js88
-rw-r--r--spec/frontend/boards/stores/getters_spec.js14
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js41
-rw-r--r--spec/frontend/graphql_shared/utils_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js44
-rw-r--r--spec/frontend/ide/components/ide_spec.js10
-rw-r--r--spec/frontend/issue_show/components/app_spec.js50
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js6
-rw-r--r--spec/frontend/notes/components/note_form_spec.js22
-rw-r--r--spec/frontend/notes/mock_data.js3
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js1
-rw-r--r--spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js198
-rw-r--r--spec/frontend/search/sidebar/components/app_spec.js99
-rw-r--r--spec/frontend/search/store/actions_spec.js42
-rw-r--r--spec/frontend/static_site_editor/components/edit_area_spec.js4
-rw-r--r--spec/frontend/static_site_editor/mock_data.js10
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js4
-rw-r--r--spec/frontend/static_site_editor/services/renderers/render_image_spec.js37
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js26
-rw-r--r--spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap22
-rw-r--r--spec/helpers/operations_helper_spec.rb3
-rw-r--r--spec/policies/user_policy_spec.rb40
-rw-r--r--spec/services/personal_access_tokens/create_service_spec.rb60
-rw-r--r--spec/services/personal_access_tokens/revoke_service_spec.rb5
-rw-r--r--spec/support/helpers/cop_helper.rb74
-rw-r--r--spec/support/rspec.rb8
-rw-r--r--spec/support/rubocop_patch.rb21
-rw-r--r--spec/support/shared_examples/serializers/note_entity_shared_examples.rb33
-rw-r--r--spec/views/search/_results.html.haml_spec.rb24
-rw-r--r--spec/workers/remove_expired_members_worker_spec.rb44
121 files changed, 1349 insertions, 887 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index f24c52f61da..9420480e35a 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -58,6 +58,11 @@ export default {
required: false,
default: false,
},
+ currentIntegration: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
fields: [
{
@@ -82,17 +87,16 @@ export default {
integrationToDelete: integrationToDeleteDefault,
};
},
- computed: {
- tbodyTrClass() {
- return {
- [bodyTrClass]: this.integrations.length,
- };
- },
- },
mounted() {
this.trackPageViews();
},
methods: {
+ tbodyTrClass(item) {
+ return {
+ [bodyTrClass]: this.integrations.length,
+ 'gl-bg-blue-50': item?.id === this.currentIntegration?.id,
+ };
+ },
trackPageViews() {
const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action);
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
index 946da8ef34c..a08100f3938 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
@@ -33,6 +33,9 @@ export default {
step1: {
label: s__('AlertSettings|1. Select integration type'),
help: s__('AlertSettings|Learn more about our upcoming %{linkStart}integrations%{linkEnd}'),
+ enterprise: s__(
+ 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ ),
},
step2: {
label: s__('AlertSettings|2. Name integration'),
@@ -107,6 +110,10 @@ export default {
required: false,
default: null,
},
+ canAddIntegration: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -236,15 +243,24 @@ export default {
>
<gl-form-select
v-model="selectedIntegration"
- :disabled="currentIntegration !== null"
+ :disabled="currentIntegration !== null || !canAddIntegration"
:options="options"
@change="integrationTypeSelect"
/>
- <alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.step1.help"
- link="https://gitlab.com/groups/gitlab-org/-/epics/4390"
- />
+ <div class="gl-my-4">
+ <alert-settings-form-help-block
+ :message="$options.i18n.integrationFormSteps.step1.help"
+ link="https://gitlab.com/groups/gitlab-org/-/epics/4390"
+ />
+ </div>
+
+ <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported">
+ <alert-settings-form-help-block
+ :message="$options.i18n.integrationFormSteps.step1.enterprise"
+ link="https://about.gitlab.com/pricing"
+ />
+ </div>
</gl-form-group>
<gl-collapse v-model="formVisible" class="gl-mt-3">
<gl-form-group
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index e9e7b1407bc..57fc1984990 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -19,6 +19,12 @@ import {
updateStoreAfterIntegrationDelete,
updateStoreAfterIntegrationAdd,
} from '../utils/cache_updates';
+import {
+ DELETE_INTEGRATION_ERROR,
+ ADD_INTEGRATION_ERROR,
+ RESET_INTEGRATION_TOKEN_ERROR,
+ UPDATE_INTEGRATION_ERROR,
+} from '../utils/error_messages';
export default {
typeSet,
@@ -44,6 +50,9 @@ export default {
projectPath: {
default: '',
},
+ multiIntegrations: {
+ default: false,
+ },
},
apollo: {
integrations: {
@@ -91,6 +100,9 @@ export default {
},
];
},
+ canAddIntegration() {
+ return this.multiIntegrations || this.integrations?.list?.length < 2;
+ },
},
methods: {
createNewIntegration({ type, variables }) {
@@ -121,8 +133,8 @@ export default {
type: FLASH_TYPES.SUCCESS,
});
})
- .catch(err => {
- createFlash({ message: err });
+ .catch(() => {
+ createFlash({ message: ADD_INTEGRATION_ERROR });
})
.finally(() => {
this.isUpdating = false;
@@ -151,8 +163,8 @@ export default {
type: FLASH_TYPES.SUCCESS,
});
})
- .catch(err => {
- createFlash({ message: err });
+ .catch(() => {
+ createFlash({ message: UPDATE_INTEGRATION_ERROR });
})
.finally(() => {
this.isUpdating = false;
@@ -187,8 +199,8 @@ export default {
});
},
)
- .catch(err => {
- createFlash({ message: err });
+ .catch(() => {
+ createFlash({ message: RESET_INTEGRATION_TOKEN_ERROR });
})
.finally(() => {
this.isUpdating = false;
@@ -222,9 +234,8 @@ export default {
type: FLASH_TYPES.SUCCESS,
});
})
- .catch(err => {
- this.errored = true;
- createFlash({ message: err });
+ .catch(() => {
+ createFlash({ message: DELETE_INTEGRATION_ERROR });
})
.finally(() => {
this.isUpdating = false;
@@ -242,6 +253,7 @@ export default {
<integrations-list
:integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld"
:loading="loading"
+ :current-integration="currentIntegration"
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
/>
@@ -249,6 +261,7 @@ export default {
v-if="glFeatures.httpIntegrationsList"
:loading="isUpdating"
:current-integration="currentIntegration"
+ :can-add-integration="canAddIntegration"
@create-new-integration="createNewIntegration"
@update-integration="updateIntegration"
@reset-token="resetToken"
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index 9cf2f356e0a..19eaccf05fc 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -57,15 +57,6 @@ export const typeSet = {
prometheus: 'PROMETHEUS',
};
-export const defaultFormState = {
- name: '',
- active: false,
- token: '',
- url: '',
- apiUrl: '',
- integrationTestPayload: { json: null, error: null },
-};
-
export const integrationToDeleteDefault = { id: null, name: '' };
export const JSON_VALIDATE_DELAY = 250;
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
index 2ae0dd447a1..a9d109b3f3e 100644
--- a/app/assets/javascripts/alerts_settings/index.js
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -29,19 +29,16 @@ export default el => {
opsgenieMvcEnabled,
opsgenieMvcTargetUrl,
projectPath,
+ multiIntegrations,
} = el.dataset;
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {},
- },
- ),
- });
+ const resolvers = {};
- apolloProvider.clients.defaultClient.cache.writeData({
- data: {},
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers, {
+ cacheConfig: {},
+ assumeImmutableResults: true,
+ }),
});
return new Vue({
@@ -70,6 +67,7 @@ export default el => {
opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable),
},
projectPath,
+ multiIntegrations: parseBoolean(multiIntegrations),
},
apolloProvider,
components: {
diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js
index 2e6058fc81a..7df5d444a53 100644
--- a/app/assets/javascripts/alerts_settings/utils/error_messages.js
+++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js
@@ -7,3 +7,11 @@ export const DELETE_INTEGRATION_ERROR = s__(
export const ADD_INTEGRATION_ERROR = s__(
'AlertsIntegrations|The integration could not be added. Please try again.',
);
+
+export const UPDATE_INTEGRATION_ERROR = s__(
+ 'AlertsIntegrations|The current integration could not be updated. Please try again.',
+);
+
+export const RESET_INTEGRATION_TOKEN_ERROR = s__(
+ 'AlertsIntegrations|The integration token could not be reset. Please try again.',
+);
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index cb0e6345059..233c5f84340 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -25,7 +25,7 @@ function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
let theme = 'neutral';
- const ideDarkThemes = ['dark', 'solarized-dark'];
+ const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
if (
ideDarkThemes.includes(window.gon?.user_color_scheme) &&
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
index a04b1361d4e..b2c3b6aa8ab 100644
--- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
+++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
@@ -26,7 +26,7 @@ export default {
data() {
return {
participants: [],
- selected: this.$store.getters.getActiveIssue.assignees,
+ selected: this.$store.getters.activeIssue.assignees,
};
},
apollo: {
@@ -34,7 +34,7 @@ export default {
query: getIssueParticipants,
variables() {
return {
- id: `gid://gitlab/Issue/${this.getActiveIssue.iid}`,
+ id: `gid://gitlab/Issue/${this.activeIssue.iid}`,
};
},
update(data) {
@@ -43,7 +43,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getActiveIssue']),
+ ...mapGetters(['activeIssue']),
assigneeText() {
return n__('Assignee', '%d Assignees', this.selected.length);
},
@@ -88,7 +88,7 @@ export default {
<template>
<board-editable-item :title="assigneeText" @close="saveAssignees">
<template #collapsed>
- <issuable-assignees :users="getActiveIssue.assignees" />
+ <issuable-assignees :users="activeIssue.assignees" />
</template>
<template #default>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 7a468abddf1..c8b713b0c5a 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -46,7 +46,7 @@ export default {
};
},
computed: {
- ...mapGetters(['getIssues']),
+ ...mapGetters(['getIssuesByList']),
showBoardListAndBoardInfo() {
return this.list.type !== ListType.promotion;
},
@@ -58,7 +58,7 @@ export default {
if (!this.glFeatures.graphqlBoardLists) {
return this.list.issues;
}
- return this.getIssues(this.list.id);
+ return this.getIssuesByList(this.list.id);
},
shouldFetchIssues() {
return this.glFeatures.graphqlBoardLists && this.list.type !== ListType.blank;
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 06319df6ea5..80070b25bd0 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -69,14 +69,18 @@ export default {
eventHub.$off('sidebar.closeAll', this.unsetActiveId);
},
methods: {
- ...mapActions(['unsetActiveId']),
+ ...mapActions(['unsetActiveId', 'removeList']),
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
deleteBoard() {
// eslint-disable-next-line no-alert
- if (window.confirm(__('Are you sure you want to delete this list?'))) {
- this.activeList.destroy();
+ if (window.confirm(__('Are you sure you want to remove this list?'))) {
+ if (this.shouldUseGraphQL) {
+ this.removeList(this.activeId);
+ } else {
+ this.activeList.destroy();
+ }
this.unsetActiveId();
}
},
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index c8926c5ef2a..47eee5306da 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -7,6 +7,7 @@ import { deprecatedCreateFlash as flash } from '~/flash';
import CreateLabelDropdown from '../../create_label';
import boardsStore from '../stores/boards_store';
import { fullLabelId } from '../boards_util';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import store from '~/boards/stores';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
@@ -61,7 +62,7 @@ export default function initNewListDropdown() {
const active = boardsStore.findListByLabelId(label.id);
const $li = $('<li />');
const $a = $('<a />', {
- class: active ? `is-active js-board-list-${active.id}` : '',
+ class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '',
text: label.title,
href: '#',
});
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
index 97fe7572493..19e6f8a2269 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
@@ -18,7 +18,7 @@ export default {
};
},
computed: {
- ...mapGetters({ issue: 'getActiveIssue' }),
+ ...mapGetters({ issue: 'activeIssue' }),
hasDueDate() {
return this.issue.dueDate != null;
},
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
index 0f063c7582e..31094939733 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
@@ -21,7 +21,7 @@ export default {
},
inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
computed: {
- ...mapGetters({ issue: 'getActiveIssue' }),
+ ...mapGetters({ issue: 'activeIssue' }),
selectedLabels() {
const { labels = [] } = this.issue;
diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql
new file mode 100644
index 00000000000..ef3fd36e980
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql
@@ -0,0 +1,5 @@
+mutation DestroyBoardList($listId: ID!) {
+ destroyBoardList(input: { listId: $listId }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index df5b84a974a..bbc7559cd86 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -18,6 +18,7 @@ import boardLabelsQuery from '../queries/board_labels.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
+import destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
@@ -212,8 +213,26 @@ export default {
});
},
- deleteList: () => {
- notImplemented();
+ removeList: ({ state, commit }, listId) => {
+ const listsBackup = { ...state.boardLists };
+
+ commit(types.REMOVE_LIST, listId);
+
+ return gqlClient
+ .mutate({
+ mutation: destroyBoardListMutation,
+ variables: {
+ listId,
+ },
+ })
+ .then(({ data: { destroyBoardList: { errors } } }) => {
+ if (errors.length > 0) {
+ commit(types.REMOVE_LIST_FAILURE, listsBackup);
+ }
+ })
+ .catch(() => {
+ commit(types.REMOVE_LIST_FAILURE, listsBackup);
+ });
},
fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
@@ -324,7 +343,7 @@ export default {
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
- const activeIssue = getters.getActiveIssue;
+ const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabels,
variables: {
@@ -349,7 +368,7 @@ export default {
},
setActiveIssueDueDate: async ({ commit, getters }, input) => {
- const activeIssue = getters.getActiveIssue;
+ const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetDueDate,
variables: {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index d1a5db1bcc5..337b2897fe9 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,7 +1,6 @@
/* eslint-disable no-shadow, no-param-reassign,consistent-return */
/* global List */
/* global ListIssue */
-import $ from 'jquery';
import { sortBy, pick } from 'lodash';
import Vue from 'vue';
import Cookies from 'js-cookie';
@@ -119,8 +118,12 @@ const boardsStore = {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
},
+
updateNewListDropdown(listId) {
- $(`.js-board-list-${listId}`).removeClass('is-active');
+ // eslint-disable-next-line no-unused-expressions
+ document
+ .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`)
+ ?.classList.remove('is-active');
},
shouldAddBlankState() {
// Decide whether to add the blank state
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index 89a3b14b262..f717b4101ab 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -2,7 +2,7 @@ import { find } from 'lodash';
import { inactiveId } from '../constants';
export default {
- getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
+ labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
isSidebarOpen: state => state.activeId !== inactiveId,
isSwimlanesOn: state => {
if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) {
@@ -15,12 +15,12 @@ export default {
return state.issues[id] || {};
},
- getIssues: (state, getters) => listId => {
+ getIssuesByList: (state, getters) => listId => {
const listIssueIds = state.issuesByListId[listId] || [];
return listIssueIds.map(id => getters.getIssueById(id));
},
- getActiveIssue: state => {
+ activeIssue: state => {
return state.issues[state.activeId] || {};
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index d5ddba710a9..29468105b5c 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -12,9 +12,8 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST';
export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
-export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
-export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
-export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
+export const REMOVE_LIST = 'REMOVE_LIST';
+export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 361bb94abe0..eb2003a6ed3 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -93,16 +93,13 @@ export default {
Vue.set(state, 'boardLists', backupList);
},
- [mutationTypes.REQUEST_REMOVE_LIST]: () => {
- notImplemented();
+ [mutationTypes.REMOVE_LIST]: (state, listId) => {
+ Vue.delete(state.boardLists, listId);
},
- [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => {
- notImplemented();
- },
-
- [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => {
- notImplemented();
+ [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) {
+ state.error = s__('Boards|An error occurred while removing the list. Please try again.');
+ state.boardLists = listsBackup;
},
[mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => {
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 7a81d5963f2..5487aeb9391 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -7,7 +7,7 @@
* @returns {Number}
*/
export const getIdFromGraphQLId = (gid = '') =>
- parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
+ parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
export const MutationOperationMode = {
Append: 'APPEND',
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 54365df2119..e1d2895831a 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,5 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import {
WEBIDE_MARK_APP_START,
@@ -14,15 +13,8 @@ import {
import { performanceMarkAndMeasure } from '~/performance/utils';
import { modalTypes } from '../constants';
import eventHub from '../eventhub';
-import FindFile from '~/vue_shared/components/file_finder/index.vue';
-import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
-import RepoTabs from './repo_tabs.vue';
-import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
-import RightPane from './panes/right.vue';
-import ErrorMessage from './error_message.vue';
-import CommitEditorHeader from './commit_sidebar/editor_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { measurePerformance } from '../utils';
@@ -43,19 +35,24 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
export default {
components: {
- NewModal,
IdeSidebar,
- RepoTabs,
- IdeStatusBar,
RepoEditor,
- FindFile,
- ErrorMessage,
- CommitEditorHeader,
- GlButton,
- GlLoadingIcon,
- RightPane,
+ 'error-message': () => import('./error_message.vue'),
+ 'gl-button': () => import('@gitlab/ui/src/components/base/button/button.vue'),
+ 'gl-loading-icon': () => import('@gitlab/ui/src/components/base/loading_icon/loading_icon.vue'),
+ 'commit-editor-header': () => import('./commit_sidebar/editor_header.vue'),
+ 'repo-tabs': () => import('./repo_tabs.vue'),
+ 'ide-status-bar': () => import('./ide_status_bar.vue'),
+ 'find-file': () => import('~/vue_shared/components/file_finder/index.vue'),
+ 'right-pane': () => import('./panes/right.vue'),
+ 'new-modal': () => import('./new_dropdown/modal.vue'),
},
mixins: [glFeatureFlagsMixin()],
+ data() {
+ return {
+ loadDeferred: false,
+ };
+ },
computed: {
...mapState([
'openFiles',
@@ -107,6 +104,9 @@ export default {
createNewFile() {
this.$refs.newModal.open(modalTypes.blob);
},
+ loadDeferredComponents() {
+ this.loadDeferred = true;
+ },
},
};
</script>
@@ -118,19 +118,23 @@ export default {
>
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
- <find-file
- v-show="fileFindVisible"
- :files="allBlobs"
- :visible="fileFindVisible"
- :loading="loading"
- @toggle="toggleFileFinder"
- @click="openFile"
- />
- <ide-sidebar />
+ <template v-if="loadDeferred">
+ <find-file
+ v-show="fileFindVisible"
+ :files="allBlobs"
+ :visible="fileFindVisible"
+ :loading="loading"
+ @toggle="toggleFileFinder"
+ @click="openFile"
+ />
+ </template>
+ <ide-sidebar @tree-ready="loadDeferredComponents" />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
- <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" />
- <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" />
+ <template v-if="loadDeferred">
+ <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" />
+ <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" />
+ </template>
<repo-editor :file="activeFile" class="multi-file-edit-pane-content" />
</template>
<template v-else>
@@ -177,9 +181,13 @@ export default {
</div>
</template>
</div>
- <right-pane v-if="currentProjectId" />
+ <template v-if="loadDeferred">
+ <right-pane v-if="currentProjectId" />
+ </template>
</div>
- <ide-status-bar />
- <new-modal ref="newModal" />
+ <template v-if="loadDeferred">
+ <ide-status-bar />
+ <new-modal ref="newModal" />
+ </template>
</article>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 53dfc133fc8..99215d6c3f1 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -4,21 +4,19 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue';
import ActivityBar from './activity_bar.vue';
-import RepoCommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue';
-import IdeReview from './ide_review.vue';
import IdeProjectHeader from './ide_project_header.vue';
-import { SIDEBAR_INIT_WIDTH } from '../constants';
+import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants';
export default {
components: {
GlSkeletonLoading,
ResizablePanel,
ActivityBar,
- RepoCommitSection,
IdeTree,
+ [leftSidebarViews.review.name]: () => import('./ide_review.vue'),
+ [leftSidebarViews.commit.name]: () => import('./repo_commit_section.vue'),
CommitForm,
- IdeReview,
IdeProjectHeader,
},
computed: {
@@ -49,7 +47,7 @@ export default {
<div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div class="multi-file-commit-panel-inner-content">
<keep-alive>
- <component :is="currentActivityView" />
+ <component :is="currentActivityView" @tree-ready="$emit('tree-ready')" />
</keep-alive>
</div>
<commit-form />
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 56fcb6c2600..e563de6659a 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -51,7 +51,7 @@ export default {
</script>
<template>
- <ide-tree-list>
+ <ide-tree-list @tree-ready="$emit('tree-ready')">
<template #header>
{{ __('Edit') }}
<div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions">
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index d7ff1b8316f..e7e94f5b5da 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -32,6 +32,13 @@ export default {
return !this.currentTree || this.currentTree.loading;
},
},
+ watch: {
+ showLoading(newVal) {
+ if (!newVal) {
+ this.$emit('tree-ready');
+ }
+ },
+ },
beforeCreate() {
performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START });
},
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 96cb024c768..61e5db0970a 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -136,6 +136,16 @@ export default {
type: String,
required: true,
},
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
issuableType: {
type: String,
required: false,
@@ -453,6 +463,12 @@ export default {
<gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
<span class="gl-display-none d-sm-block">{{ statusText }}</span>
</p>
+ <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon">
+ <gl-icon name="lock" :aria-label="__('Locked')" />
+ </span>
+ <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon">
+ <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
+ </span>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="state.titleText"
diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js
index fc9e8e051bb..b5cd466596e 100644
--- a/app/assets/javascripts/issue_show/issue.js
+++ b/app/assets/javascripts/issue_show/issue.js
@@ -17,6 +17,8 @@ export function initIssuableApp(issuableData, store) {
return createElement(IssuableApp, {
props: {
...issuableData,
+ isConfidential: this.getNoteableData?.confidential,
+ isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
},
});
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 878a748e99a..0272790a75d 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -45,7 +45,7 @@ export default {
return this.discussion.notes.filter(x => x.resolvable);
},
userCanResolveDiscussion() {
- return this.resolvableNotes.every(note => note.current_user && note.current_user.can_resolve);
+ return this.resolvableNotes.every(note => note.current_user?.can_resolve_discussion);
},
},
};
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 4b3f23e742d..43f17c5d65c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -121,7 +121,13 @@ export default {
return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
},
showResolveDiscussionToggle() {
- return (this.discussion?.id && this.discussion.resolvable) || this.isDraft;
+ if (!this.discussion?.notes) return false;
+
+ return (
+ this.discussion?.notes
+ .filter(n => n.resolvable)
+ .some(n => n.current_user?.can_resolve_discussion) || this.isDraft
+ );
},
noteHash() {
if (this.noteId) {
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 1775e83978e..9be53fe60f2 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -141,6 +141,8 @@ export default {
canResolve() {
if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false;
+ if (this.glFeatures.removeResolveNote) return this.note.current_user.can_resolve_discussion;
+
return (
this.note.current_user.can_resolve ||
(this.note.isDraft && this.note.discussion_id !== null)
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 09f40601fbf..4ed0aae0d1e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -132,24 +132,26 @@ export default {
<div class="ci-job-component" data-qa-selector="job_item_container">
<gl-link
v-if="status.has_details"
- v-gl-tooltip="{ boundary, placement: 'bottom' }"
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:href="status.details_path"
:title="tooltipText"
:class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item"
data-testid="job-with-link"
@click.stop="hideTooltips"
+ @mouseout="hideTooltips"
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
<div
v-else
- v-gl-tooltip="{ boundary, placement: 'bottom' }"
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:title="tooltipText"
:class="jobClasses"
class="js-job-component-tooltip non-details-job-component"
data-testid="job-without-link"
+ @mouseout="hideTooltips"
>
<job-name-component :name="job.name" :status="job.status" />
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 00ccf59e37c..9ee427d01fd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -337,7 +337,7 @@ export default {
:message="emptyTabMessage"
/>
- <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder">
+ <div v-else-if="stateToRender === $options.stateMap.tableList">
<pipelines-table-component
:pipelines="state.pipelines"
:pipeline-schedule-url="pipelineScheduleUrl"
diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue
deleted file mode 100644
index 08619fa2066..00000000000
--- a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue
+++ /dev/null
@@ -1,108 +0,0 @@
-<script>
-import { mapState } from 'vuex';
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
-import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
-import { sprintf, s__ } from '~/locale';
-
-export default {
- name: 'DropdownFilter',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- },
- props: {
- filterData: {
- type: Object,
- required: true,
- },
- },
- computed: {
- ...mapState(['query']),
- scope() {
- return this.query.scope;
- },
- supportedScopes() {
- return Object.values(this.filterData.scopes);
- },
- initialFilter() {
- return this.query[this.filterData.filterParam];
- },
- filter() {
- return this.initialFilter || this.filterData.filters.ANY.value;
- },
- filtersArray() {
- return this.filterData.filterByScope[this.scope];
- },
- selectedFilter: {
- get() {
- if (this.filtersArray.some(({ value }) => value === this.filter)) {
- return this.filter;
- }
-
- return this.filterData.filters.ANY.value;
- },
- set(filter) {
- // we need to remove the pagination cursor to ensure the
- // relevancy of the new results
-
- visitUrl(
- setUrlParams({
- page: null,
- [this.filterData.filterParam]: filter,
- }),
- );
- },
- },
- selectedFilterText() {
- const f = this.filtersArray.find(({ value }) => value === this.selectedFilter);
- if (!f || f === this.filterData.filters.ANY) {
- return sprintf(s__('Any %{header}'), { header: this.filterData.header });
- }
-
- return f.label;
- },
- showDropdown() {
- return this.supportedScopes.includes(this.scope);
- },
- },
- methods: {
- dropDownItemClass(filter) {
- return {
- 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
- filter === this.filterData.filters.ANY,
- };
- },
- isFilterSelected(filter) {
- return filter === this.selectedFilter;
- },
- handleFilterChange(filter) {
- this.selectedFilter = filter;
- },
- },
-};
-</script>
-
-<template>
- <gl-dropdown
- v-if="showDropdown"
- :text="selectedFilterText"
- class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4"
- menu-class="gl-w-full! gl-pl-0"
- >
- <header class="gl-text-center gl-font-weight-bold gl-font-lg">
- {{ filterData.header }}
- </header>
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-for="f in filtersArray"
- :key="f.value"
- :is-check-item="true"
- :is-checked="isFilterSelected(f.value)"
- :class="dropDownItemClass(f)"
- @click="handleFilterChange(f.value)"
- >
- {{ f.label }}
- </gl-dropdown-item>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js
deleted file mode 100644
index b29daca89cb..00000000000
--- a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { __ } from '~/locale';
-
-const header = __('Confidentiality');
-
-const filters = {
- ANY: {
- label: __('Any'),
- value: null,
- },
- CONFIDENTIAL: {
- label: __('Confidential'),
- value: 'yes',
- },
- NOT_CONFIDENTIAL: {
- label: __('Not confidential'),
- value: 'no',
- },
-};
-
-const scopes = {
- ISSUES: 'issues',
-};
-
-const filterByScope = {
- [scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL],
-};
-
-const filterParam = 'confidential';
-
-export default {
- header,
- filters,
- scopes,
- filterByScope,
- filterParam,
-};
diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js
deleted file mode 100644
index 0b93aa0be29..00000000000
--- a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { __ } from '~/locale';
-
-const header = __('Status');
-
-const filters = {
- ANY: {
- label: __('Any'),
- value: 'all',
- },
- OPEN: {
- label: __('Open'),
- value: 'opened',
- },
- CLOSED: {
- label: __('Closed'),
- value: 'closed',
- },
- MERGED: {
- label: __('Merged'),
- value: 'merged',
- },
-};
-
-const scopes = {
- ISSUES: 'issues',
- MERGE_REQUESTS: 'merge_requests',
-};
-
-const filterByScope = {
- [scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
- [scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
-};
-
-const filterParam = 'state';
-
-export default {
- header,
- filters,
- scopes,
- filterByScope,
- filterParam,
-};
diff --git a/app/assets/javascripts/search/dropdown_filter/index.js b/app/assets/javascripts/search/dropdown_filter/index.js
deleted file mode 100644
index e5e0745d990..00000000000
--- a/app/assets/javascripts/search/dropdown_filter/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import DropdownFilter from './components/dropdown_filter.vue';
-import stateFilterData from './constants/state_filter_data';
-import confidentialFilterData from './constants/confidential_filter_data';
-
-Vue.use(Translate);
-
-const mountDropdownFilter = (store, { id, filterData }) => {
- const el = document.getElementById(id);
-
- if (!el) return false;
-
- return new Vue({
- el,
- store,
- render(createElement) {
- return createElement(DropdownFilter, {
- props: {
- filterData,
- },
- });
- },
- });
-};
-
-const dropdownFilters = [
- {
- id: 'js-search-filter-by-state',
- filterData: stateFilterData,
- },
- {
- id: 'js-search-filter-by-confidential',
- filterData: confidentialFilterData,
- },
-];
-
-export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter));
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index 275d6351adc..7508b3c9a55 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,17 +1,11 @@
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
-import initDropdownFilters from './dropdown_filter';
import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
- if (gon.features.searchFacets) {
- initSidebar(store);
- } else {
- initDropdownFilters(store);
- }
-
+ initSidebar(store);
initGroupFilter(store);
};
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
new file mode 100644
index 00000000000..0c50f93d381
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -0,0 +1,41 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlButton, GlLink } from '@gitlab/ui';
+import StatusFilter from './status_filter.vue';
+import ConfidentialityFilter from './confidentiality_filter.vue';
+
+export default {
+ name: 'GlobalSearchSidebar',
+ components: {
+ GlButton,
+ GlLink,
+ StatusFilter,
+ ConfidentialityFilter,
+ },
+ computed: {
+ ...mapState(['query']),
+ showReset() {
+ return this.query.state || this.query.confidential;
+ },
+ },
+ methods: {
+ ...mapActions(['applyQuery', 'resetQuery']),
+ },
+};
+</script>
+
+<template>
+ <form
+ class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mb gl-mt-5"
+ @submit.prevent="applyQuery"
+ >
+ <status-filter />
+ <confidentiality-filter />
+ <div class="gl-display-flex gl-align-items-center gl-mt-3">
+ <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button>
+ <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
+ __('Reset filters')
+ }}</gl-link>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index f8938e799aa..38dccb9675d 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -21,5 +21,6 @@ export default {
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.confidentialFilterData" />
+ <hr class="gl-my-5 gl-border-gray-100" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index 876123ccc52..5cec2090906 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -21,5 +21,6 @@ export default {
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.stateFilterData" />
+ <hr class="gl-my-5 gl-border-gray-100" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js
index b19016edf3d..6419e8ac2c6 100644
--- a/app/assets/javascripts/search/sidebar/index.js
+++ b/app/assets/javascripts/search/sidebar/index.js
@@ -1,12 +1,11 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import StatusFilter from './components/status_filter.vue';
-import ConfidentialityFilter from './components/confidentiality_filter.vue';
+import GlobalSearchSidebar from './components/app.vue';
Vue.use(Translate);
-const mountRadioFilters = (store, { id, component }) => {
- const el = document.getElementById(id);
+export const initSidebar = store => {
+ const el = document.getElementById('js-search-sidebar');
if (!el) return false;
@@ -14,21 +13,7 @@ const mountRadioFilters = (store, { id, component }) => {
el,
store,
render(createElement) {
- return createElement(component);
+ return createElement(GlobalSearchSidebar);
},
});
};
-
-const radioFilters = [
- {
- id: 'js-search-filter-by-state',
- component: StatusFilter,
- },
- {
- id: 'js-search-filter-by-confidential',
- component: ConfidentialityFilter,
- },
-];
-
-export const initSidebar = store =>
- [...radioFilters].map(filter => mountRadioFilters(store, filter));
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 722ed2eec26..447278aa223 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -1,6 +1,7 @@
import Api from '~/api';
import createFlash from '~/flash';
import { __ } from '~/locale';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
export const fetchGroups = ({ commit }, search) => {
@@ -18,3 +19,11 @@ export const fetchGroups = ({ commit }, search) => {
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
+
+export const applyQuery = ({ state }) => {
+ visitUrl(setUrlParams({ ...state.query, page: null }));
+};
+
+export const resetQuery = ({ state }) => {
+ visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
+};
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index 034c8dea012..09bc176c399 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -37,6 +37,14 @@ export default {
required: false,
default: '',
},
+ branch: {
+ type: String,
+ required: true,
+ },
+ baseUrl: {
+ type: String,
+ required: true,
+ },
mounts: {
type: Array,
required: true,
@@ -75,7 +83,7 @@ export default {
return this.editorMode === EDITOR_TYPES.wysiwyg;
},
customRenderers() {
- const imageRenderer = renderImage.build(this.mounts, this.project);
+ const imageRenderer = renderImage.build(this.mounts, this.project, this.branch, this.baseUrl);
return {
image: [imageRenderer],
};
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
index 4842dfcda49..e422a4b6036 100644
--- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
@@ -6,6 +6,8 @@ query appData {
sourcePath
username
returnUrl
+ branch
+ baseUrl
mounts {
source
target
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
index fdac87cd91c..00af6c10359 100644
--- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -26,6 +26,8 @@ type AppData {
returnUrl: String
sourcePath: String!
username: String!
+ branch: String!
+ baseUrl: String!
mounts: [Mount]!
imageUploadPath: String!
}
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index dd684429794..b58564388de 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -9,6 +9,7 @@ const initStaticSiteEditor = el => {
isSupportedContent,
path: sourcePath,
baseUrl,
+ branch,
namespace,
project,
mergeRequestsIllustrationPath,
@@ -27,6 +28,8 @@ const initStaticSiteEditor = el => {
hasSubmittedChanges: false,
project: `${namespace}/${project}`,
mounts: JSON.parse(mounts), // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object.
+ branch,
+ baseUrl,
returnUrl,
sourcePath,
username,
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index 3e2e6e3b3ee..b61e0968d7d 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -139,6 +139,8 @@ export default {
:saving-changes="isSavingChanges"
:return-url="appData.returnUrl"
:mounts="appData.mounts"
+ :branch="appData.branch"
+ :base-url="appData.baseUrl"
:project="appData.project"
:image-root="appData.imageUploadPath"
@submit="onPrepareSubmit"
diff --git a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
index 38304a1c57f..5e5e5d1c860 100644
--- a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
+++ b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
@@ -1,29 +1,80 @@
+import { isAbsolute, getBaseURL, joinPaths } from '~/lib/utils/url_utility';
+
const canRender = ({ type }) => type === 'image';
-// NOTE: the `metadata` is not used yet, but will be used in a follow-up iteration
-// To be removed with the next iteration of https://gitlab.com/gitlab-org/gitlab/-/issues/218531
-// eslint-disable-next-line no-unused-vars
let metadata;
-const render = (node, { skipChildren }) => {
- skipChildren();
+const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/');
+
+const extractSourceDirectory = url => {
+ const sourceDir = /^(.+)\/([^/]+)$/.exec(url); // Extracts the base path and fileName from an image path
+ return sourceDir || [null, null, url]; // If no source directory was extracted it means only a fileName was specified (e.g. url='file.png')
+};
+
+const parseCurrentDirectory = basePath => {
+ const baseUrl = decodeURIComponent(metadata.baseUrl);
+ const sourceDirectory = extractSourceDirectory(baseUrl)[1];
+ const currentDirectory = sourceDirectory.split(`/-/sse/${metadata.branch}`)[1];
+
+ return joinPaths(currentDirectory, basePath);
+};
+
+// For more context around this logic, please see the following comment:
+// https://gitlab.com/gitlab-org/gitlab/-/issues/241166#note_409413500
+const generateSourceDirectory = basePath => {
+ let sourceDir = '';
+ let defaultSourceDir = '';
+
+ if (!basePath || isRelativeToCurrentDirectory(basePath)) {
+ return parseCurrentDirectory(basePath);
+ }
+
+ if (!metadata.mounts.length) {
+ return basePath;
+ }
- // To be removed with the next iteration of https://gitlab.com/gitlab-org/gitlab/-/issues/218531
- // TODO resolve relative paths
+ metadata.mounts.forEach(({ source, target }) => {
+ const hasTarget = target !== '';
+
+ if (hasTarget && basePath.includes(target)) {
+ sourceDir = source;
+ } else if (!hasTarget) {
+ defaultSourceDir = joinPaths(source, basePath);
+ }
+ });
+
+ return sourceDir || defaultSourceDir;
+};
+
+const resolveFullPath = originalSrc => {
+ if (isAbsolute(originalSrc)) {
+ return originalSrc;
+ }
+
+ const sourceDirectory = extractSourceDirectory(originalSrc);
+ const [, basePath, fileName] = sourceDirectory;
+ const sourceDir = generateSourceDirectory(basePath);
+
+ return joinPaths(getBaseURL(), metadata.project, '/-/raw/', metadata.branch, sourceDir, fileName);
+};
+
+const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => {
+ skipChildren();
return {
type: 'openTag',
tagName: 'img',
selfClose: true,
attributes: {
- src: node.destination,
- alt: node.firstChild.literal,
+ 'data-original-src': !isAbsolute(originalSrc) ? originalSrc : '',
+ src: resolveFullPath(originalSrc),
+ alt: firstChild.literal,
},
};
};
-const build = (mounts, project) => {
- metadata = { mounts, project };
+const build = (mounts = [], project, branch, baseUrl) => {
+ metadata = { mounts, project, branch, baseUrl };
return { canRender, render };
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
index 2bce691e793..9744e25a8e1 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -99,6 +99,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
? `\n\n${node.innerText}\n\n`
: baseRenderer.convert(node, subContent);
},
+ IMG(node) {
+ const { originalSrc } = node.dataset;
+ return `![${node.alt}](${originalSrc || node.src})`;
+ },
};
};
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index 14274c0747a..52cc7d3449e 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -151,7 +151,7 @@
border-color: var(--ide-border-color-alt, $gray-100);
code {
- background-color: var(--ide-border-color, inherit);
+ background-color: var(--ide-empty-state-background, inherit);
}
}
@@ -427,7 +427,7 @@
}
.md table:not(.code) tbody {
- background-color: var(--ide-border-color, $white);
+ background-color: var(--ide-empty-state-background, $white);
}
.animation-container {
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 54cd267b993..15cc10d1532 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -5,7 +5,9 @@
@import './ide_theme_overrides';
@import './ide_themes/dark';
+@import './ide_themes/solarized-light';
@import './ide_themes/solarized-dark';
+@import './ide_themes/monokai';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
@@ -176,11 +178,11 @@ $ide-commit-header-height: 48px;
height: 100%;
overflow: auto;
padding: $gl-padding;
- background-color: var(--ide-border-color, transparent);
+ background-color: var(--ide-empty-state-background, transparent);
}
.file-container {
- background-color: var(--ide-border-color, $gray-darker);
+ background-color: var(--ide-empty-state-background, $gray-darker);
display: flex;
height: 100%;
align-items: center;
@@ -491,7 +493,7 @@ $ide-commit-header-height: 48px;
height: 100vh;
align-items: center;
justify-content: center;
- background-color: var(--ide-border-color, transparent);
+ background-color: var(--ide-empty-state-background, transparent);
}
.ide {
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
index 5f31d554c19..c7aae77c412 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
@@ -12,6 +12,7 @@
--ide-highlight-background: #252526;
--ide-link-color: #428fdc;
--ide-footer-background: #060606;
+ --ide-empty-state-background: var(--ide-border-color);
--ide-input-border: #868686;
--ide-input-background: transparent;
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss
new file mode 100644
index 00000000000..f53ace0b6c2
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss
@@ -0,0 +1,66 @@
+// -------
+// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
+// -------
+.ide.theme-monokai {
+ --ide-border-color: #1a1a18;
+ --ide-border-color-alt: #3f4237;
+ --ide-highlight-accent: #fff;
+ --ide-text-color: #ccc;
+ --ide-text-color-secondary: #b7b7b7;
+ --ide-background: #282822;
+ --ide-background-hover: #2d2d2d;
+ --ide-highlight-background: #1f1f1d;
+ --ide-link-color: #428fdc;
+ --ide-footer-background: #404338;
+ --ide-empty-state-background: #1a1a18;
+
+ --ide-input-border: #7d8175;
+ --ide-input-background: transparent;
+ --ide-input-color: #fff;
+
+ --ide-btn-default-background: transparent;
+ --ide-btn-default-border: #7d8175;
+ --ide-btn-default-hover-border: #b5bda5;
+ --ide-btn-default-hover-border-width: 2px;
+ --ide-btn-default-focus-box-shadow: 0 0 0 1px #bfbfbf;
+
+ --ide-btn-primary-background: #1068bf;
+ --ide-btn-primary-border: #428fdc;
+ --ide-btn-primary-hover-border: #63a6e9;
+ --ide-btn-primary-hover-border-width: 2px;
+ --ide-btn-primary-focus-box-shadow: 0 0 0 1px #63a6e9;
+
+ --ide-btn-success-background: #217645;
+ --ide-btn-success-border: #108548;
+ --ide-btn-success-hover-border: #2da160;
+ --ide-btn-success-hover-border-width: 2px;
+ --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160;
+
+ // Danger styles should be the same as default styles in dark theme
+ --ide-btn-danger-secondary-background: var(--ide-btn-default-background);
+ --ide-btn-danger-secondary-border: var(--ide-btn-default-border);
+ --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border);
+ --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width);
+ --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow);
+
+ --ide-btn-disabled-background: transparent;
+ --ide-btn-disabled-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-hover-border-width: 1px;
+ --ide-btn-disabled-focus-box-shadow: 0 0 0 0 transparent;
+ --ide-btn-disabled-color: rgba(145, 145, 145, 0.48);
+
+ --ide-dropdown-background: #36382f;
+ --ide-dropdown-hover-background: #404338;
+
+ --ide-dropdown-btn-hover-border: #b5bda5;
+ --ide-dropdown-btn-hover-background: #3f4237;
+
+ --ide-file-row-btn-hover-background: #404338;
+
+ --ide-diff-insert: rgba(155, 185, 85, 0.2);
+ --ide-diff-remove: rgba(255, 0, 0, 0.2);
+
+ --ide-animation-gradient-1: #404338;
+ --ide-animation-gradient-2: #36382f;
+}
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
index 83c55310063..1906b3ca938 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
@@ -12,6 +12,7 @@
--ide-highlight-background: #003240;
--ide-link-color: #73b9ff;
--ide-footer-background: var(--ide-highlight-background);
+ --ide-empty-state-background: var(--ide-border-color);
--ide-input-border: #d8d8d8;
--ide-input-background: transparent;
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
new file mode 100644
index 00000000000..315a0ae6202
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
@@ -0,0 +1,57 @@
+// -------
+// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
+// -------
+.ide.theme-solarized-light {
+ --ide-border-color: #dfd7bf;
+ --ide-border-color-alt: #dfd7bf;
+ --ide-highlight-accent: #5c4e21;
+ --ide-text-color: #616161;
+ --ide-text-color-secondary: #526f76;
+ --ide-background: #efe8d3;
+ --ide-background-hover: #ded6be;
+ --ide-highlight-background: #fef6e1;
+ --ide-link-color: #955800;
+ --ide-footer-background: #efe8d3;
+ --ide-empty-state-background: #fef6e1;
+
+ --ide-input-border: #c0b9a4;
+ --ide-input-background: transparent;
+
+ --ide-btn-default-background: transparent;
+ --ide-btn-default-border: #c0b9a4;
+ --ide-btn-default-hover-border: #c0b9a4;
+
+ --ide-btn-primary-background: #b16802;
+ --ide-btn-primary-border: #a35f00;
+ --ide-btn-primary-hover-border: #955800;
+ --ide-btn-primary-hover-border-width: 2px;
+ --ide-btn-primary-focus-box-shadow: 0 0 0 1px #dd8101;
+
+ --ide-btn-danger-secondary-background: transparent;
+
+ --ide-btn-disabled-background: transparent;
+ --ide-btn-disabled-border: rgba(192, 185, 64, 0.48);
+ --ide-btn-disabled-hover-border: rgba(192, 185, 64, 0.48);
+ --ide-btn-disabled-hover-border-width: 1px;
+ --ide-btn-disabled-focus-box-shadow: transparent;
+ --ide-btn-disabled-color: rgba(82, 82, 82, 0.48);
+
+ --ide-dropdown-background: #fef6e1;
+ --ide-dropdown-hover-background: #efe8d3;
+
+ --ide-dropdown-btn-hover-border: #dfd7bf;
+ --ide-dropdown-btn-hover-background: #efe8d3;
+
+ --ide-file-row-btn-hover-background: #ded6be;
+
+ --ide-animation-gradient-1: #d3cbb3;
+ --ide-animation-gradient-2: #efe8d3;
+
+ .ide-empty-state,
+ .ide-sidebar,
+ .ide-commit-empty-state {
+ img {
+ filter: sepia(1) brightness(0.7);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 00d32b75628..cc4827f75d4 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -1,16 +1,12 @@
.issuable-warning-icon {
background-color: $orange-50;
border-radius: $border-radius-default;
+ color: $orange-600;
width: $issuable-warning-size;
height: $issuable-warning-size;
text-align: center;
margin-right: $issuable-warning-icon-margin;
line-height: $gl-line-height-24;
-
- .icon {
- fill: $orange-600;
- vertical-align: text-bottom;
- }
}
.limit-container-width {
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index b005347c43a..a45205c5da7 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -9,9 +9,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def create
- @personal_access_token = finder.build(personal_access_token_params)
+ result = ::PersonalAccessTokens::CreateService.new(
+ current_user: current_user, target_user: current_user, params: personal_access_token_params
+ ).execute
- if @personal_access_token.save
+ @personal_access_token = result.payload[:personal_access_token]
+
+ if result.success?
PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.")
else
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 0f149c24a59..4b21edc98d5 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -24,10 +24,6 @@ class SearchController < ApplicationController
search_term_present && !params[:project_id].present?
end
- before_action do
- push_frontend_feature_flag(:search_facets)
- end
-
layout 'search'
feature_category :global_search
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index 3e802156c8f..8105fce10cf 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -30,7 +30,8 @@ module OperationsHelper
'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'),
'alerts_usage_url' => project_alert_management_index_path(@project),
'disabled' => disabled.to_s,
- 'project_path' => @project.full_path
+ 'project_path' => @project.full_path,
+ 'multi_integrations' => 'false'
}
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 61030b6d5e6..70e8fb32064 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -27,6 +27,7 @@ class UserPolicy < BasePolicy
rule { default }.enable :read_user_profile
rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile
rule { user_is_self | admin }.enable :disable_two_factor
+ rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token
end
UserPolicy.prepend_if_ee('EE::UserPolicy')
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index ef305195e22..cf7a71a11d0 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -34,6 +34,10 @@ class NoteEntity < API::Entities::Note
expose :can_resolve do |note|
note.resolvable? && can?(current_user, :resolve_note, note)
end
+
+ expose :can_resolve_discussion do |note|
+ note.discussion.resolvable? && note.discussion.can_resolve?(current_user)
+ end
end
expose :suggestions, using: SuggestionEntity
diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
index ff9bb7d6802..93a0135669f 100644
--- a/app/services/personal_access_tokens/create_service.rb
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -2,23 +2,30 @@
module PersonalAccessTokens
class CreateService < BaseService
- def initialize(current_user, params = {})
+ def initialize(current_user:, target_user:, params: {})
@current_user = current_user
+ @target_user = target_user
@params = params.dup
+ @ip_address = @params.delete(:ip_address)
end
def execute
- personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params))
+ return ServiceResponse.error(message: 'Not permitted to create') unless creation_permitted?
- if personal_access_token.persisted?
- ServiceResponse.success(payload: { personal_access_token: personal_access_token })
+ token = target_user.personal_access_tokens.create(params.slice(*allowed_params))
+
+ if token.persisted?
+ log_event(token)
+ ServiceResponse.success(payload: { personal_access_token: token })
else
- ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence)
+ ServiceResponse.error(message: token.errors.full_messages.to_sentence, payload: { personal_access_token: token })
end
end
private
+ attr_reader :target_user, :ip_address
+
def allowed_params
[
:name,
@@ -27,5 +34,15 @@ module PersonalAccessTokens
:expires_at
]
end
+
+ def creation_permitted?
+ Ability.allowed?(current_user, :create_user_personal_access_token, target_user)
+ end
+
+ def log_event(token)
+ log_info("PAT CREATION: created_by: '#{current_user.username}', created_for: '#{token.user.username}', token_id: '#{token.id}'")
+ end
end
end
+
+PersonalAccessTokens::CreateService.prepend_if_ee('EE::PersonalAccessTokens::CreateService')
diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb
index 17405002d8d..34d542acab1 100644
--- a/app/services/personal_access_tokens/revoke_service.rb
+++ b/app/services/personal_access_tokens/revoke_service.rb
@@ -4,16 +4,17 @@ module PersonalAccessTokens
class RevokeService
attr_reader :token, :current_user, :group
- def initialize(current_user = nil, params = { token: nil, group: nil })
+ def initialize(current_user = nil, token: nil, group: nil )
@current_user = current_user
- @token = params[:token]
- @group = params[:group]
+ @token = token
+ @group = group
end
def execute
return ServiceResponse.error(message: 'Not permitted to revoke') unless revocation_permitted?
if token.revoke!
+ log_event
ServiceResponse.success(message: success_message)
else
ServiceResponse.error(message: error_message)
@@ -33,6 +34,10 @@ module PersonalAccessTokens
def revocation_permitted?
Ability.allowed?(current_user, :revoke_token, token)
end
+
+ def log_event
+ Gitlab::AppLogger.info("PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '#{token.id}'")
+ end
end
end
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index c2b4773a505..70e09be9407 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -83,7 +83,9 @@ module ResourceAccessTokens
end
def create_personal_access_token(user)
- PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute
+ PersonalAccessTokens::CreateService.new(
+ current_user: user, target_user: user, params: personal_access_token_params
+ ).execute
end
def personal_access_token_params
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 607e759928c..3af4437a63a 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,7 +1,10 @@
- if @search_objects.to_a.empty?
- = render partial: "search/results/filters"
- = render partial: "search/results/empty"
- = render_if_exists 'shared/promotions/promote_advanced_search'
+ .gl-display-md-flex
+ - if %w(issues merge_requests).include?(@scope)
+ #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
+ .gl-w-full
+ = render partial: "search/results/empty"
+ = render_if_exists 'shared/promotions/promote_advanced_search'
- else
.search-results-status
.row-content-block.gl-display-flex
@@ -24,19 +27,21 @@
.gl-display-md-flex.gl-flex-direction-column
= render partial: 'search/sort_dropdown'
= render_if_exists 'shared/promotions/promote_advanced_search'
- = render partial: "search/results/filters"
- .results.gl-mt-3
- - if @scope == 'commits'
- %ul.content-list.commit-list
- = render partial: "search/results/commit", collection: @search_objects
- - else
- .search-results
- - if @scope == 'projects'
- .term
- = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- - else
- = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ .results.gl-display-md-flex.gl-mt-3
+ - if %w(issues merge_requests).include?(@scope)
+ #js-search-sidebar{ }
+ .gl-w-full
+ - if @scope == 'commits'
+ %ul.content-list.commit-list
+ = render partial: "search/results/commit", collection: @search_objects
+ - else
+ .search-results
+ - if @scope == 'projects'
+ .term
+ = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
+ - else
+ = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- - if @scope != 'projects'
- = paginate_collection(@search_objects)
+ - if @scope != 'projects'
+ = paginate_collection(@search_objects)
diff --git a/app/views/search/results/_filters.html.haml b/app/views/search/results/_filters.html.haml
deleted file mode 100644
index 2356a6e1f2c..00000000000
--- a/app/views/search/results/_filters.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.d-lg-flex.align-items-end
- #js-search-filter-by-state{ 'v-cloak': true }
- #js-search-filter-by-confidential{ 'v-cloak': true }
-
- - if %w(issues merge_requests).include?(@scope)
- %hr.gl-mt-4.gl-mb-4
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index f56a6cd9fa2..35844fdf297 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -7,11 +7,19 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :authentication_and_authorization
worker_resource_boundary :cpu
+ # rubocop: disable CodeReuse/ActiveRecord
def perform
- Member.expired.find_each do |member|
+ Member.expired.preload(:user).find_each do |member|
Members::DestroyService.new.execute(member, skip_authorization: true)
+
+ expired_user = member.user
+
+ if expired_user.project_bot?
+ Users::DestroyService.new(nil).execute(expired_user, skip_authorization: true)
+ end
rescue => ex
logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
end
end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/changelogs/unreleased/218531-determine-image-relative-paths.yml b/changelogs/unreleased/218531-determine-image-relative-paths.yml
new file mode 100644
index 00000000000..7265f2e6e16
--- /dev/null
+++ b/changelogs/unreleased/218531-determine-image-relative-paths.yml
@@ -0,0 +1,5 @@
+---
+title: Determine image relative paths
+merge_request: 46208
+author:
+type: added
diff --git a/changelogs/unreleased/221035-ide-solarized-light.yml b/changelogs/unreleased/221035-ide-solarized-light.yml
new file mode 100644
index 00000000000..5ae6f4120f2
--- /dev/null
+++ b/changelogs/unreleased/221035-ide-solarized-light.yml
@@ -0,0 +1,5 @@
+---
+title: Add Web IDE Solarized Light theme support
+merge_request: 46999
+author:
+type: added
diff --git a/changelogs/unreleased/235385-ide-monokai-theme.yml b/changelogs/unreleased/235385-ide-monokai-theme.yml
new file mode 100644
index 00000000000..a63c27bdd4c
--- /dev/null
+++ b/changelogs/unreleased/235385-ide-monokai-theme.yml
@@ -0,0 +1,5 @@
+---
+title: Monokai theme for the Web IDE
+merge_request: 46901
+author:
+type: added
diff --git a/changelogs/unreleased/241691-left-side-facets.yml b/changelogs/unreleased/241691-left-side-facets.yml
new file mode 100644
index 00000000000..bafbf36297b
--- /dev/null
+++ b/changelogs/unreleased/241691-left-side-facets.yml
@@ -0,0 +1,5 @@
+---
+title: Global Search - Left Sidebar
+merge_request: 46595
+author:
+type: added
diff --git a/changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml b/changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml
new file mode 100644
index 00000000000..514a7b5b3c0
--- /dev/null
+++ b/changelogs/unreleased/250484-add-locked-and-confidential-badge-to-issue-sticky-header.yml
@@ -0,0 +1,5 @@
+---
+title: Add locked and confidential badge to issue sticky header
+merge_request: 46996
+author:
+type: added
diff --git a/changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml b/changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml
new file mode 100644
index 00000000000..b6cec22bc92
--- /dev/null
+++ b/changelogs/unreleased/267191-project-access-tokens-delete-project-bot-after-it-s-removed-from-p.yml
@@ -0,0 +1,5 @@
+---
+title: Project Access Tokens - Delete project bot after token expires
+merge_request: 45828
+author:
+type: fixed
diff --git a/changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml b/changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml
new file mode 100644
index 00000000000..4fb470d9010
--- /dev/null
+++ b/changelogs/unreleased/273739-pipeline-tooltips-cover-the-entire-element.yml
@@ -0,0 +1,5 @@
+---
+title: Better-behaved tooltips in pipeline dropdown
+merge_request: 46866
+author:
+type: fixed
diff --git a/config/feature_flags/development/search_facets.yml b/config/feature_flags/development/search_facets.yml
deleted file mode 100644
index b100c4a6490..00000000000
--- a/config/feature_flags/development/search_facets.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: search_facets
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46809
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46595
-group: group::global search
-type: development
-default_enabled: false
diff --git a/config/feature_flags/licensed/minimal_access_role.yml b/config/feature_flags/licensed/minimal_access_role.yml
deleted file mode 100644
index ca27b86d35f..00000000000
--- a/config/feature_flags/licensed/minimal_access_role.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: minimal_access_role
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40942
-rollout_issue_url:
-group: group::access
-type: licensed
-default_enabled: true
diff --git a/config/initializers/0_inject_feature_flags.rb b/config/initializers/0_inject_feature_flags.rb
index 78fd67b97df..726336ccd0c 100644
--- a/config/initializers/0_inject_feature_flags.rb
+++ b/config/initializers/0_inject_feature_flags.rb
@@ -16,7 +16,6 @@ if Gitlab.ee? && Gitlab.dev_or_test_env?
IGNORED_FEATURE_FLAGS = %i[
group_wikis
swimlanes
- minimal_access_role
].to_set
# First, we validate a list of overrides to ensure that these overrides
diff --git a/doc/README.md b/doc/README.md
index 1589c73bb1d..df7ad597334 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -25,7 +25,7 @@ No matter how you use GitLab, we have documentation for you.
| Essential documentation | Essential documentation |
|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------|
-| [**User documentation**](user/index.md)<br/>Discover features and concepts for GitLab users. | [**Administrator documentation**](administration/index.md)<br/>Everything GitLab self-managed administrators need to know. |
+| [**User documentation**](user/index.md)<br>Discover features and concepts for GitLab users. | [**Administrator documentation**](administration/index.md)<br/>Everything GitLab self-managed administrators need to know. |
| [**Contributing to GitLab**](#contributing-to-gitlab)<br/>At GitLab, everyone can contribute! | [**New to Git and GitLab?**](#new-to-git-and-gitlab)<br/>We have the resources to get you started. |
| [**Build an integration with GitLab**](#build-an-integration-with-gitlab)<br/>Consult our automation and integration documentation. | [**Coming to GitLab from another platform?**](#coming-to-gitlab-from-another-platform)<br/>Consult our handy guides. |
| [**Install GitLab**](https://about.gitlab.com/install/)<br/>Installation options for different platforms. | [**Customers**](subscriptions/index.md)<br/>Information for new and existing customers. |
diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md
index 825bee93714..4b8aaa8157a 100644
--- a/doc/administration/audit_events.md
+++ b/doc/administration/audit_events.md
@@ -127,6 +127,8 @@ recorded:
- User was blocked via Admin Area ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/251) in GitLab 12.8)
- User was blocked via API ([introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25872) in GitLab 12.9)
- Failed second-factor authentication attempt ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/16826) in GitLab 13.5)
+- A user's personal access token was successfully created or revoked ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276921) in GitLab 13.6)
+- A failed attempt to create or revoke a user's personal access token ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276921) in GitLab 13.6)
It's possible to filter particular actions by choosing an audit data type from
the filter dropdown box. You can further filter by specific group, project, or user
diff --git a/lib/api/internal/base.rb b/lib/api/internal/base.rb
index e09d074d749..410bc1b0974 100644
--- a/lib/api/internal/base.rb
+++ b/lib/api/internal/base.rb
@@ -245,7 +245,7 @@ module API
end
result = ::PersonalAccessTokens::CreateService.new(
- user, name: params[:name], scopes: params[:scopes], expires_at: expires_at
+ current_user: user, target_user: user, params: { name: params[:name], scopes: params[:scopes], expires_at: expires_at }
).execute
unless result.status == :success
diff --git a/lib/api/personal_access_tokens.rb b/lib/api/personal_access_tokens.rb
index 599b3ee034e..2c60938b75a 100644
--- a/lib/api/personal_access_tokens.rb
+++ b/lib/api/personal_access_tokens.rb
@@ -51,7 +51,7 @@ module API
delete ':id' do
service = ::PersonalAccessTokens::RevokeService.new(
current_user,
- { token: find_token(params[:id]) }
+ token: find_token(params[:id])
).execute
service.success? ? no_content! : bad_request!(nil)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2bb773aa13e..8af1f073b0a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2566,6 +2566,9 @@ msgstr ""
msgid "AlertSettings|HTTP endpoint"
msgstr ""
+msgid "AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations."
+msgstr ""
+
msgid "AlertSettings|Integration"
msgstr ""
@@ -2668,6 +2671,9 @@ msgstr ""
msgid "AlertsIntegrations|Prometheus"
msgstr ""
+msgid "AlertsIntegrations|The current integration could not be updated. Please try again."
+msgstr ""
+
msgid "AlertsIntegrations|The integration could not be added. Please try again."
msgstr ""
@@ -2680,6 +2686,9 @@ msgstr ""
msgid "AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list."
msgstr ""
+msgid "AlertsIntegrations|The integration token could not be reset. Please try again."
+msgstr ""
+
msgid "AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone."
msgstr ""
@@ -3582,9 +3591,6 @@ msgstr ""
msgid "Are you sure you want to delete this device? This action cannot be undone."
msgstr ""
-msgid "Are you sure you want to delete this list?"
-msgstr ""
-
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
@@ -3635,6 +3641,9 @@ msgstr ""
msgid "Are you sure you want to remove this identity?"
msgstr ""
+msgid "Are you sure you want to remove this list?"
+msgstr ""
+
msgid "Are you sure you want to reset registration token?"
msgstr ""
@@ -4403,6 +4412,9 @@ msgstr ""
msgid "Boards|An error occurred while moving the issue. Please try again."
msgstr ""
+msgid "Boards|An error occurred while removing the list. Please try again."
+msgstr ""
+
msgid "Boards|An error occurred while updating the list. Please try again."
msgstr ""
@@ -22941,6 +22953,9 @@ msgstr ""
msgid "Reset authorization key?"
msgstr ""
+msgid "Reset filters"
+msgstr ""
+
msgid "Reset health check access token"
msgstr ""
diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh
index 9d6a4dbccee..1b671df84e6 100755
--- a/scripts/lint-doc.sh
+++ b/scripts/lint-doc.sh
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+set -o pipefail
cd "$(dirname "$0")/.." || exit 1
echo "=> Linting documents at path $(pwd) as $(whoami)..."
@@ -71,13 +72,14 @@ else
function run_locally_or_in_docker() {
local cmd=$1
local args=$2
+ local pipe_cmd=$3
if hash ${cmd} 2>/dev/null
then
- $cmd $args
+ $cmd $args | $pipe_cmd
elif hash docker 2>/dev/null
then
- docker run -t -v ${PWD}:/gitlab -w /gitlab --rm registry.gitlab.com/gitlab-org/gitlab-docs/lint:latest ${cmd} ${args}
+ docker run -t -v ${PWD}:/gitlab -w /gitlab --rm registry.gitlab.com/gitlab-org/gitlab-docs/lint:latest ${cmd} ${args} | $pipe_cmd
else
echo
echo " ✖ ERROR: '${cmd}' not found. Install '${cmd}' or Docker to proceed." >&2
@@ -99,7 +101,7 @@ echo
run_locally_or_in_docker 'markdownlint' "--config .markdownlint.json ${MD_DOC_PATH}"
echo '=> Linting prose...'
-run_locally_or_in_docker 'vale' "--minAlertLevel error ${MD_DOC_PATH}"
+run_locally_or_in_docker 'vale' "--minAlertLevel error --output=JSON ${MD_DOC_PATH}" "ruby scripts/vale.rb"
if [ $ERRORCODE -ne 0 ]
then
diff --git a/scripts/vale.rb b/scripts/vale.rb
new file mode 100755
index 00000000000..22c6a474019
--- /dev/null
+++ b/scripts/vale.rb
@@ -0,0 +1,23 @@
+#!/usr/bin/env ruby
+#
+# Get the JSON output from Vale and format it in a nicer way
+# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46725
+#
+# Usage:
+# vale --output=JSON filename.md | ruby vale.rb
+#
+
+require 'json'
+
+input = ARGF.read
+data = JSON.parse(input)
+
+data.each_pair do |source, alerts|
+ alerts.each do |alert|
+ puts "#{source}:"
+ puts " Line #{alert['Line']}, position #{alert['Span'][0]} (rule #{alert['Check']})"
+ puts " #{alert['Severity']}: #{alert['Message']}"
+ puts " More information: #{alert['Link']}"
+ puts
+ end
+end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 4438831fb76..de5a594aca6 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Profile > Personal Access Tokens', :js do
let(:user) { create(:user) }
+ let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) }
def active_personal_access_tokens
find(".table.active-tokens")
@@ -18,7 +19,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
end
def disallow_personal_access_token_saves!
- allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false)
+ allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service)
errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
@@ -100,7 +101,10 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
context "when revocation fails" do
it "displays an error message" do
visit profile_personal_access_tokens_path
- allow_any_instance_of(PersonalAccessTokens::RevokeService).to receive(:revocation_permitted?).and_return(false)
+
+ allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance|
+ allow(instance).to receive(:revocation_permitted?).and_return(false)
+ end
accept_confirm { click_on "Revoke" }
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap
index 68b5eb12b99..629cbd68bd6 100644
--- a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap
+++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap
@@ -9,7 +9,9 @@ exports[`AlertsSettingsFormNew with default values renders the initial template
<option value=\\"HTTP\\">HTTP Endpoint</option>
<option value=\\"PROMETHEUS\\">External Prometheus</option>
<option value=\\"OPSGENIE\\">Opsgenie</option>
- </select> <span class=\\"gl-text-gray-500\\">Learn more about our upcoming <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://gitlab.com/groups/gitlab-org/-/epics/4390\\" class=\\"gl-link gl-display-inline-block\\">integrations</a></span>
+ </select>
+ <div class=\\"gl-my-4\\"><span class=\\"gl-text-gray-500\\">Learn more about our upcoming <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://gitlab.com/groups/gitlab-org/-/epics/4390\\" class=\\"gl-link gl-display-inline-block\\">integrations</a></span></div>
+ <!---->
<!---->
<!---->
<!---->
diff --git a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js
index 5d1feffe84a..c7a9db82bea 100644
--- a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js
+++ b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js
@@ -1,4 +1,4 @@
-import { GlTable, GlIcon } from '@gitlab/ui';
+import { GlTable, GlIcon, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Tracking from '~/tracking';
import AlertIntegrationsList, {
@@ -8,11 +8,13 @@ import { trackAlertIntegrationsViewsOptions } from '~/alerts_settings/constants'
const mockIntegrations = [
{
+ id: '1',
active: true,
name: 'Integration 1',
type: 'HTTP endpoint',
},
{
+ id: '2',
active: false,
name: 'Integration 2',
type: 'HTTP endpoint',
@@ -30,6 +32,7 @@ describe('AlertIntegrationsList', () => {
},
stubs: {
GlIcon: true,
+ GlButton: true,
},
});
}
@@ -46,6 +49,7 @@ describe('AlertIntegrationsList', () => {
});
const findTableComponent = () => wrapper.find(GlTable);
+ const findTableComponentRows = () => wrapper.find(GlTable).findAll('table tbody tr');
const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]');
it('renders a table', () => {
@@ -57,6 +61,19 @@ describe('AlertIntegrationsList', () => {
expect(findTableComponent().text()).toContain(i18n.emptyState);
});
+ it('renders an an edit and delete button for each integration', () => {
+ expect(findTableComponent().findAll(GlButton).length).toBe(4);
+ });
+
+ it('renders an highlighted row when a current integration is selected to edit', () => {
+ mountComponent({ currentIntegration: { id: '1' } });
+ expect(
+ findTableComponentRows()
+ .at(0)
+ .classes(),
+ ).toContain('gl-bg-blue-50');
+ });
+
describe('integration status', () => {
it('enabled', () => {
const cell = finsStatusCell().at(0);
diff --git a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js
index 59238e40686..8ab760dd962 100644
--- a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js
+++ b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js
@@ -9,7 +9,7 @@ describe('AlertsSettingsFormNew', () => {
const createComponent = ({
data = {},
- props = { loading: false },
+ props = {},
multipleHttpIntegrationsCustomMapping = false,
} = {}) => {
wrapper = mount(AlertsSettingsForm, {
@@ -17,6 +17,8 @@ describe('AlertsSettingsFormNew', () => {
return { ...data };
},
propsData: {
+ loading: false,
+ canAddIntegration: true,
...props,
},
provide: {
@@ -33,6 +35,8 @@ describe('AlertsSettingsFormNew', () => {
const findFormToggle = () => wrapper.find(GlToggle);
const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`);
const findSubmitButton = () => wrapper.find(`[type = "submit"]`);
+ const findMultiSupportText = () =>
+ wrapper.find(`[data-testid="multi-integrations-not-supported"]`);
afterEach(() => {
if (wrapper) {
@@ -53,6 +57,7 @@ describe('AlertsSettingsFormNew', () => {
it('render the initial form with only an integration type dropdown', () => {
expect(findForm().exists()).toBe(true);
expect(findSelect().exists()).toBe(true);
+ expect(findMultiSupportText().exists()).toBe(false);
expect(findFormSteps().attributes('visible')).toBeUndefined();
});
@@ -68,6 +73,12 @@ describe('AlertsSettingsFormNew', () => {
.isVisible(),
).toBe(true);
});
+
+ it('disabled the dropdown and shows help text when multi integrations are not supported', async () => {
+ createComponent({ props: { canAddIntegration: false } });
+ expect(findSelect().attributes('disabled')).toBe('disabled');
+ expect(findMultiSupportText().exists()).toBe(true);
+ });
});
describe('submitting integration form', () => {
diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js
index e2e7034940d..dc3a490154b 100644
--- a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js
+++ b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js
@@ -1,6 +1,7 @@
import VueApollo from 'vue-apollo';
import { mount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { GlLoadingIcon } from '@gitlab/ui';
import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue';
import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue';
@@ -15,6 +16,11 @@ import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/
import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql';
import { typeSet } from '~/alerts_settings/constants';
+import {
+ ADD_INTEGRATION_ERROR,
+ RESET_INTEGRATION_TOKEN_ERROR,
+ UPDATE_INTEGRATION_ERROR,
+} from '~/alerts_settings/utils/error_messages';
import createFlash from '~/flash';
import { defaultAlertSettingsConfig } from './util';
import mockIntegrations from './mocks/integrations.json';
@@ -143,16 +149,6 @@ describe('AlertsSettingsWrapper', () => {
expect(findIntegrations()).toHaveLength(mockIntegrations.length);
});
- it('shows an error message when a user cannot create a new integration', () => {
- createComponent({
- data: { integrations: { list: mockIntegrations } },
- provide: { glFeatures: { httpIntegrationsList: true } },
- loading: false,
- });
- expect(findLoader().exists()).toBe(false);
- expect(findIntegrations()).toHaveLength(mockIntegrations.length);
- });
-
it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
@@ -287,38 +283,37 @@ describe('AlertsSettingsWrapper', () => {
});
});
- it('shows error alert when integration creation fails ', async () => {
+ it('shows an error alert when integration creation fails ', async () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR);
wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {});
- setImmediate(() => {
- expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
- });
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR });
});
- it('shows error alert when integration token reset fails ', () => {
+ it('shows an error alert when integration token reset fails ', async () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR);
wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {});
- setImmediate(() => {
- expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
- });
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR });
});
- it('shows error alert when integration update fails ', () => {
+ it('shows an error alert when integration update fails ', async () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
@@ -329,9 +324,8 @@ describe('AlertsSettingsWrapper', () => {
wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {});
- setImmediate(() => {
- expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
- });
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR });
});
});
diff --git a/spec/frontend/alerts_settings/util.js b/spec/frontend/alerts_settings/util.js
index beb6a724f20..f9f9b69791e 100644
--- a/spec/frontend/alerts_settings/util.js
+++ b/spec/frontend/alerts_settings/util.js
@@ -25,4 +25,6 @@ export const defaultAlertSettingsConfig = {
active: ACTIVE,
opsgenieMvcTargetUrl: GENERIC_URL,
},
+ projectPath: '',
+ multiIntegrations: true,
};
diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js
index 4d3129da11a..f81a96c645d 100644
--- a/spec/frontend/boards/components/board_assignee_dropdown_spec.js
+++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js
@@ -18,7 +18,7 @@ describe('BoardCardAssigneeDropdown', () => {
wrapper = mount(BoardAssigneeDropdown, {
data() {
return {
- selected: store.getters.getActiveIssue.assignees,
+ selected: store.getters.activeIssue.assignees,
participants,
};
},
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index d83b39a5594..7ab7836ddcd 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -2,6 +2,7 @@
/* global List */
import Vue from 'vue';
+import { keyBy } from 'lodash';
import '~/boards/models/list';
import '~/boards/models/issue';
import boardsStore from '~/boards/stores/boards_store';
@@ -310,6 +311,8 @@ export const mockLists = [
},
];
+export const mockListsById = keyBy(mockLists, 'id');
+
export const mockListsWithModel = mockLists.map(listMock =>
Vue.observable(new List({ ...listMock, doNotFetchIssues: true })),
);
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 3b204c3cf70..44dd44edb12 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -2,6 +2,7 @@ import testAction from 'helpers/vuex_action_helper';
import {
mockListsWithModel,
mockLists,
+ mockListsById,
mockIssue,
mockIssueWithModel,
mockIssue2WithModel,
@@ -13,6 +14,7 @@ import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId } from '~/boards/constants';
import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql';
+import destroyBoardListMutation from '~/boards/queries/board_list_destroy.mutation.graphql';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util';
@@ -318,8 +320,82 @@ describe('updateList', () => {
});
});
-describe('deleteList', () => {
- expectNotImplemented(actions.deleteList);
+describe('removeList', () => {
+ let state;
+ const list = mockLists[0];
+ const listId = list.id;
+ const mutationVariables = {
+ mutation: destroyBoardListMutation,
+ variables: {
+ listId,
+ },
+ };
+
+ beforeEach(() => {
+ state = {
+ boardLists: mockListsById,
+ };
+ });
+
+ afterEach(() => {
+ state = null;
+ });
+
+ it('optimistically deletes the list', () => {
+ const commit = jest.fn();
+
+ actions.removeList({ commit, state }, listId);
+
+ expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]);
+ });
+
+ it('keeps the updated list if remove succeeds', async () => {
+ const commit = jest.fn();
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ destroyBoardList: {
+ errors: [],
+ },
+ },
+ });
+
+ await actions.removeList({ commit, state }, listId);
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]);
+ });
+
+ it('restores the list if update fails', async () => {
+ const commit = jest.fn();
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject());
+
+ await actions.removeList({ commit, state }, listId);
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(commit.mock.calls).toEqual([
+ [types.REMOVE_LIST, listId],
+ [types.REMOVE_LIST_FAILURE, mockListsById],
+ ]);
+ });
+
+ it('restores the list if update response has errors', async () => {
+ const commit = jest.fn();
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ destroyBoardList: {
+ errors: ['update failed, ID invalid'],
+ },
+ },
+ });
+
+ await actions.removeList({ commit, state }, listId);
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(commit.mock.calls).toEqual([
+ [types.REMOVE_LIST, listId],
+ [types.REMOVE_LIST_FAILURE, mockListsById],
+ ]);
+ });
});
describe('fetchIssuesForList', () => {
@@ -640,7 +716,7 @@ describe('addListIssueFailure', () => {
describe('setActiveIssueLabels', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
- const getters = { getActiveIssue: mockIssue };
+ const getters = { activeIssue: mockIssue };
const testLabelIds = labels.map(label => label.id);
const input = {
addLabelIds: testLabelIds,
@@ -654,7 +730,7 @@ describe('setActiveIssueLabels', () => {
.mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } });
const payload = {
- issueId: getters.getActiveIssue.id,
+ issueId: getters.activeIssue.id,
prop: 'labels',
value: labels,
};
@@ -685,7 +761,7 @@ describe('setActiveIssueLabels', () => {
describe('setActiveIssueDueDate', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
- const getters = { getActiveIssue: mockIssue };
+ const getters = { activeIssue: mockIssue };
const testDueDate = '2020-02-20';
const input = {
dueDate: testDueDate,
@@ -705,7 +781,7 @@ describe('setActiveIssueDueDate', () => {
});
const payload = {
- issueId: getters.getActiveIssue.id,
+ issueId: getters.activeIssue.id,
prop: 'dueDate',
value: testDueDate,
};
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index b987080abab..66c26d087bb 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -10,13 +10,13 @@ import {
} from '../mock_data';
describe('Boards - Getters', () => {
- describe('getLabelToggleState', () => {
+ describe('labelToggleState', () => {
it('should return "on" when isShowingLabels is true', () => {
const state = {
isShowingLabels: true,
};
- expect(getters.getLabelToggleState(state)).toBe('on');
+ expect(getters.labelToggleState(state)).toBe('on');
});
it('should return "off" when isShowingLabels is false', () => {
@@ -24,7 +24,7 @@ describe('Boards - Getters', () => {
isShowingLabels: false,
};
- expect(getters.getLabelToggleState(state)).toBe('off');
+ expect(getters.labelToggleState(state)).toBe('off');
});
});
@@ -112,7 +112,7 @@ describe('Boards - Getters', () => {
});
});
- describe('getActiveIssue', () => {
+ describe('activeIssue', () => {
it.each`
id | expected
${'1'} | ${'issue'}
@@ -120,11 +120,11 @@ describe('Boards - Getters', () => {
`('returns $expected when $id is passed to state', ({ id, expected }) => {
const state = { issues: { '1': 'issue' }, activeId: id };
- expect(getters.getActiveIssue(state)).toEqual(expected);
+ expect(getters.activeIssue(state)).toEqual(expected);
});
});
- describe('getIssues', () => {
+ describe('getIssuesByList', () => {
const boardsState = {
issuesByListId: mockIssuesByListId,
issues,
@@ -132,7 +132,7 @@ describe('Boards - Getters', () => {
it('returns issues for a given listId', () => {
const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId);
- expect(getters.getIssues(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual(
+ expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual(
mockIssues,
);
});
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 86056b922be..0036d1eafe1 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -184,16 +184,43 @@ describe('Board Store Mutations', () => {
});
});
- describe('REQUEST_REMOVE_LIST', () => {
- expectNotImplemented(mutations.REQUEST_REMOVE_LIST);
- });
+ describe('REMOVE_LIST', () => {
+ it('removes list from boardLists', () => {
+ const [list, secondList] = mockListsWithModel;
+ const expected = {
+ [secondList.id]: secondList,
+ };
+ state = {
+ ...state,
+ boardLists: { ...initialBoardListsState },
+ };
- describe('RECEIVE_REMOVE_LIST_SUCCESS', () => {
- expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_SUCCESS);
+ mutations[types.REMOVE_LIST](state, list.id);
+
+ expect(state.boardLists).toEqual(expected);
+ });
});
- describe('RECEIVE_REMOVE_LIST_ERROR', () => {
- expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR);
+ describe('REMOVE_LIST_FAILURE', () => {
+ it('restores lists from backup', () => {
+ const backupLists = { ...initialBoardListsState };
+
+ mutations[types.REMOVE_LIST_FAILURE](state, backupLists);
+
+ expect(state.boardLists).toEqual(backupLists);
+ });
+
+ it('sets error state', () => {
+ const backupLists = { ...initialBoardListsState };
+ state = {
+ ...state,
+ error: undefined,
+ };
+
+ mutations[types.REMOVE_LIST_FAILURE](state, backupLists);
+
+ expect(state.error).toEqual('An error occurred while removing the list. Please try again.');
+ });
});
describe('RESET_ISSUES', () => {
diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js
index 52386bf6ede..6a630195126 100644
--- a/spec/frontend/graphql_shared/utils_spec.js
+++ b/spec/frontend/graphql_shared/utils_spec.js
@@ -11,6 +11,10 @@ describe('getIdFromGraphQLId', () => {
output: null,
},
{
+ input: 2,
+ output: 2,
+ },
+ {
input: 'gid://',
output: null,
},
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
index 86e4e8d8f89..72e9463945b 100644
--- a/spec/frontend/ide/components/ide_side_bar_spec.js
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -1,10 +1,12 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
import { createStore } from '~/ide/stores';
import IdeSidebar from '~/ide/components/ide_side_bar.vue';
import IdeTree from '~/ide/components/ide_tree.vue';
import RepoCommitSection from '~/ide/components/repo_commit_section.vue';
+import IdeReview from '~/ide/components/ide_review.vue';
import { leftSidebarViews } from '~/ide/constants';
import { projectData } from '../mock_data';
@@ -15,11 +17,12 @@ describe('IdeSidebar', () => {
let wrapper;
let store;
- function createComponent() {
+ function createComponent({ view = leftSidebarViews.edit.name } = {}) {
store = createStore();
store.state.currentProjectId = 'abcproject';
store.state.projects.abcproject = projectData;
+ store.state.currentActivityView = view;
return mount(IdeSidebar, {
store,
@@ -48,22 +51,46 @@ describe('IdeSidebar', () => {
expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3);
});
- describe('activityBarComponent', () => {
- it('renders tree component', () => {
+ describe('deferred rendering components', () => {
+ it('fetches components on demand', async () => {
wrapper = createComponent();
expect(wrapper.find(IdeTree).exists()).toBe(true);
- });
+ expect(wrapper.find(IdeReview).exists()).toBe(false);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(false);
- it('renders commit component', async () => {
- wrapper = createComponent();
+ store.state.currentActivityView = leftSidebarViews.review.name;
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
- store.state.currentActivityView = leftSidebarViews.commit.name;
+ expect(wrapper.find(IdeTree).exists()).toBe(false);
+ expect(wrapper.find(IdeReview).exists()).toBe(true);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(false);
+ store.state.currentActivityView = leftSidebarViews.commit.name;
+ await waitForPromises();
await wrapper.vm.$nextTick();
+ expect(wrapper.find(IdeTree).exists()).toBe(false);
+ expect(wrapper.find(IdeReview).exists()).toBe(false);
expect(wrapper.find(RepoCommitSection).exists()).toBe(true);
});
+ it.each`
+ view | tree | review | commit
+ ${leftSidebarViews.edit.name} | ${true} | ${false} | ${false}
+ ${leftSidebarViews.review.name} | ${false} | ${true} | ${false}
+ ${leftSidebarViews.commit.name} | ${false} | ${false} | ${true}
+ `('renders correct panels for $view', async ({ view, tree, review, commit } = {}) => {
+ wrapper = createComponent({
+ view,
+ });
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(IdeTree).exists()).toBe(tree);
+ expect(wrapper.find(IdeReview).exists()).toBe(review);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(commit);
+ });
});
it('keeps the current activity view components alive', async () => {
@@ -72,7 +99,7 @@ describe('IdeSidebar', () => {
const ideTreeComponent = wrapper.find(IdeTree).element;
store.state.currentActivityView = leftSidebarViews.commit.name;
-
+ await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.find(IdeTree).exists()).toBe(false);
@@ -80,6 +107,7 @@ describe('IdeSidebar', () => {
store.state.currentActivityView = leftSidebarViews.edit.name;
+ await waitForPromises();
await wrapper.vm.$nextTick();
// reference to the elements remains the same, meaning the components were kept alive
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
index a7b07a9f0e2..e402b03f782 100644
--- a/spec/frontend/ide/components/ide_spec.js
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { createStore } from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { file } from '../helpers';
@@ -63,18 +64,17 @@ describe('ide component, non-empty repo', () => {
vm.$destroy();
});
- it('shows error message when set', done => {
+ it('shows error message when set', async () => {
expect(vm.$el.querySelector('.gl-alert')).toBe(null);
vm.$store.state.errorMessage = {
text: 'error',
};
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
+ await waitForPromises();
+ await vm.$nextTick();
- done();
- });
+ expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
});
describe('onBeforeUnload', () => {
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index f4095d4de96..dde4e8458d5 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -17,6 +17,7 @@ import {
import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
+import { IssuableStatus, IssuableStatusText } from '~/issue_show/constants';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
@@ -36,6 +37,10 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
+ const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
+
+ const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
+
const mountComponent = (props = {}, options = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...appProps, ...props },
@@ -532,7 +537,7 @@ describe('Issuable output', () => {
describe('sticky header', () => {
describe('when title is in view', () => {
it('is not shown', () => {
- expect(wrapper.find('.issue-sticky-header').exists()).toBe(false);
+ expect(findStickyHeader().exists()).toBe(false);
});
});
@@ -542,24 +547,45 @@ describe('Issuable output', () => {
wrapper.find(GlIntersectionObserver).vm.$emit('disappear');
});
- it('is shown with title', () => {
+ it('shows with title', () => {
expect(findStickyHeader().text()).toContain('Sticky header title');
});
- it('is shown with Open when status is opened', () => {
- wrapper.setProps({ issuableStatus: 'opened' });
+ it.each`
+ title | state
+ ${'shows with Open when status is opened'} | ${IssuableStatus.Open}
+ ${'shows with Closed when status is closed'} | ${IssuableStatus.Closed}
+ ${'shows with Open when status is reopened'} | ${IssuableStatus.Reopened}
+ `('$title', async ({ state }) => {
+ wrapper.setProps({ issuableStatus: state });
- return wrapper.vm.$nextTick(() => {
- expect(findStickyHeader().text()).toContain('Open');
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
});
- it('is shown with Closed when status is closed', () => {
- wrapper.setProps({ issuableStatus: 'closed' });
+ it.each`
+ title | isConfidential
+ ${'does not show confidential badge when issue is not confidential'} | ${true}
+ ${'shows confidential badge when issue is confidential'} | ${false}
+ `('$title', async ({ isConfidential }) => {
+ wrapper.setProps({ isConfidential });
- return wrapper.vm.$nextTick(() => {
- expect(findStickyHeader().text()).toContain('Closed');
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findConfidentialBadge().exists()).toBe(isConfidential);
+ });
+
+ it.each`
+ title | isLocked
+ ${'does not show locked badge when issue is not locked'} | ${true}
+ ${'shows locked badge when issue is locked'} | ${false}
+ `('$title', async ({ isLocked }) => {
+ wrapper.setProps({ isLocked });
+
+ await wrapper.vm.$nextTick();
+
+ expect(findLockedBadge().exists()).toBe(isLocked);
});
});
});
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index 3e1e43d0c6a..b26eb00bfdc 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -13,11 +13,11 @@ const createDiscussionMock = (props = {}) =>
const createNoteMock = (props = {}) =>
Object.assign(JSON.parse(JSON.stringify(discussionMock.notes[0])), props);
const createResolvableNote = () =>
- createNoteMock({ resolvable: true, current_user: { can_resolve: true } });
+ createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: true } });
const createUnresolvableNote = () =>
- createNoteMock({ resolvable: false, current_user: { can_resolve: false } });
+ createNoteMock({ resolvable: false, current_user: { can_resolve_discussion: false } });
const createUnallowedNote = () =>
- createNoteMock({ resolvable: true, current_user: { can_resolve: false } });
+ createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: false } });
describe('DiscussionActions', () => {
let wrapper;
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index a5b5204509e..cc434d6c952 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -272,6 +272,7 @@ describe('issue_note_form component', () => {
wrapper = createComponentWrapper();
wrapper.setProps({
...props,
+ isDraft: true,
noteId: '',
discussion: { ...discussionMock, for_commit: false },
});
@@ -292,6 +293,27 @@ describe('issue_note_form component', () => {
expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(true);
});
+ it('hides resolve checkbox', async () => {
+ wrapper.setProps({
+ isDraft: false,
+ discussion: {
+ ...discussionMock,
+ notes: [
+ ...discussionMock.notes.map(n => ({
+ ...n,
+ resolvable: true,
+ current_user: { ...n.current_user, can_resolve_discussion: false },
+ })),
+ ],
+ for_commit: false,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(false);
+ });
+
it('hides actions for commits', () => {
wrapper.setProps({ discussion: { for_commit: true } });
diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js
index 7661d51aadf..638a4edecd6 100644
--- a/spec/frontend/notes/mock_data.js
+++ b/spec/frontend/notes/mock_data.js
@@ -202,6 +202,7 @@ export const discussionMock = {
can_edit: true,
can_award_emoji: true,
can_resolve: true,
+ can_resolve_discussion: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
@@ -249,6 +250,7 @@ export const discussionMock = {
can_edit: true,
can_award_emoji: true,
can_resolve: true,
+ can_resolve_discussion: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
@@ -296,6 +298,7 @@ export const discussionMock = {
can_edit: true,
can_award_emoji: true,
can_resolve: true,
+ can_resolve_discussion: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 5e04f9a6433..a272803f9b6 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -372,7 +372,6 @@ describe('Pipelines', () => {
});
it('should render table', () => {
- expect(wrapper.find('.table-holder').exists()).toBe(true);
expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
pipelines.pipelines.length + 1,
);
diff --git a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js b/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js
deleted file mode 100644
index f795a23404e..00000000000
--- a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js
+++ /dev/null
@@ -1,198 +0,0 @@
-import Vuex from 'vuex';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { MOCK_QUERY } from 'jest/search/mock_data';
-import * as urlUtils from '~/lib/utils/url_utility';
-import initStore from '~/search/store';
-import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue';
-import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data';
-import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data';
-
-jest.mock('~/lib/utils/url_utility', () => ({
- visitUrl: jest.fn(),
- setUrlParams: jest.fn(),
-}));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('DropdownFilter', () => {
- let wrapper;
- let store;
-
- const createStore = options => {
- store = initStore({ query: MOCK_QUERY, ...options });
- };
-
- const createComponent = (props = { filterData: stateFilterData }) => {
- wrapper = shallowMount(DropdownFilter, {
- localVue,
- store,
- propsData: {
- ...props,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- store = null;
- });
-
- const findGlDropdown = () => wrapper.find(GlDropdown);
- const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
- const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
- const firstDropDownItem = () => findGlDropdownItems().at(0);
-
- describe('StatusFilter', () => {
- describe('template', () => {
- describe.each`
- scope | showDropdown
- ${'issues'} | ${true}
- ${'merge_requests'} | ${true}
- ${'projects'} | ${false}
- ${'milestones'} | ${false}
- ${'users'} | ${false}
- ${'notes'} | ${false}
- ${'wiki_blobs'} | ${false}
- ${'blobs'} | ${false}
- `(`dropdown`, ({ scope, showDropdown }) => {
- beforeEach(() => {
- createStore({ query: { ...MOCK_QUERY, scope } });
- createComponent();
- });
-
- it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
- expect(findGlDropdown().exists()).toBe(showDropdown);
- });
- });
-
- describe.each`
- initialFilter | label
- ${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`}
- ${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label}
- ${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label}
- `(`filter text`, ({ initialFilter, label }) => {
- describe(`when initialFilter is ${initialFilter}`, () => {
- beforeEach(() => {
- createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } });
- createComponent();
- });
-
- it(`sets dropdown label to ${label}`, () => {
- expect(findGlDropdown().attributes('text')).toBe(label);
- });
- });
- });
- });
-
- describe('Filter options', () => {
- beforeEach(() => {
- createStore();
- createComponent();
- });
-
- it('renders a dropdown item for each filterOption', () => {
- expect(findDropdownItemsText()).toStrictEqual(
- stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => {
- return v.label;
- }),
- );
- });
-
- it('clicking a dropdown item calls setUrlParams', () => {
- const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- page: null,
- [stateFilterData.filterParam]: filter,
- });
- });
-
- it('clicking a dropdown item calls visitUrl', () => {
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.visitUrl).toHaveBeenCalled();
- });
- });
- });
-
- describe('ConfidentialFilter', () => {
- describe('template', () => {
- describe.each`
- scope | showDropdown
- ${'issues'} | ${true}
- ${'merge_requests'} | ${false}
- ${'projects'} | ${false}
- ${'milestones'} | ${false}
- ${'users'} | ${false}
- ${'notes'} | ${false}
- ${'wiki_blobs'} | ${false}
- ${'blobs'} | ${false}
- `(`dropdown`, ({ scope, showDropdown }) => {
- beforeEach(() => {
- createStore({ query: { ...MOCK_QUERY, scope } });
- createComponent({ filterData: confidentialFilterData });
- });
-
- it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
- expect(findGlDropdown().exists()).toBe(showDropdown);
- });
- });
-
- describe.each`
- initialFilter | label
- ${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`}
- ${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label}
- ${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label}
- `(`filter text`, ({ initialFilter, label }) => {
- describe(`when initialFilter is ${initialFilter}`, () => {
- beforeEach(() => {
- createStore({
- query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter },
- });
- createComponent({ filterData: confidentialFilterData });
- });
-
- it(`sets dropdown label to ${label}`, () => {
- expect(findGlDropdown().attributes('text')).toBe(label);
- });
- });
- });
- });
- });
-
- describe('Filter options', () => {
- beforeEach(() => {
- createStore();
- createComponent({ filterData: confidentialFilterData });
- });
-
- it('renders a dropdown item for each filterOption', () => {
- expect(findDropdownItemsText()).toStrictEqual(
- confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => {
- return v.label;
- }),
- );
- });
-
- it('clicking a dropdown item calls setUrlParams', () => {
- const filter =
- confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- page: null,
- [confidentialFilterData.filterParam]: filter,
- });
- });
-
- it('clicking a dropdown item calls visitUrl', () => {
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.visitUrl).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js
new file mode 100644
index 00000000000..c68be10f664
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/app_spec.js
@@ -0,0 +1,99 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlButton, GlLink } from '@gitlab/ui';
+import { MOCK_QUERY } from 'jest/search/mock_data';
+import GlobalSearchSidebar from '~/search/sidebar/components/app.vue';
+import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
+import StatusFilter from '~/search/sidebar/components/status_filter.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('GlobalSearchSidebar', () => {
+ let wrapper;
+
+ const actionSpies = {
+ applyQuery: jest.fn(),
+ resetQuery: jest.fn(),
+ };
+
+ const createComponent = initialState => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(GlobalSearchSidebar, {
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findSidebarForm = () => wrapper.find('form');
+ const findStatusFilter = () => wrapper.find(StatusFilter);
+ const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter);
+ const findApplyButton = () => wrapper.find(GlButton);
+ const findResetLinkButton = () => wrapper.find(GlLink);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders StatusFilter always', () => {
+ expect(findStatusFilter().exists()).toBe(true);
+ });
+
+ it('renders ConfidentialityFilter always', () => {
+ expect(findConfidentialityFilter().exists()).toBe(true);
+ });
+
+ it('renders ApplyButton always', () => {
+ expect(findApplyButton().exists()).toBe(true);
+ });
+
+ describe('ResetLinkButton', () => {
+ describe('with no filter selected', () => {
+ beforeEach(() => {
+ createComponent({ query: {} });
+ });
+
+ it('does not render', () => {
+ expect(findResetLinkButton().exists()).toBe(false);
+ });
+ });
+
+ describe('with filter selected', () => {
+ it('does render when a filter selected', () => {
+ expect(findResetLinkButton().exists()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('actions', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('clicking ApplyButton calls applyQuery', () => {
+ findSidebarForm().trigger('submit');
+
+ expect(actionSpies.applyQuery).toHaveBeenCalled();
+ });
+
+ it('clicking ResetLinkButton calls resetQuery', () => {
+ findResetLinkButton().vm.$emit('click');
+
+ expect(actionSpies.resetQuery).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 0bab4ce17a6..35d97c7dcb1 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/search/store/actions';
import * as types from '~/search/store/mutation_types';
+import * as urlUtils from '~/lib/utils/url_utility';
import state from '~/search/store/state';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
@@ -42,6 +43,47 @@ describe('Global Search Store Actions', () => {
});
});
});
+
+ describe('setQuery', () => {
+ const payload = { key: 'key1', value: 'value1' };
+
+ it('calls the SET_QUERY mutation', done => {
+ testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done);
+ });
+ });
+
+ describe('applyQuery', () => {
+ beforeEach(() => {
+ urlUtils.setUrlParams = jest.fn();
+ urlUtils.visitUrl = jest.fn();
+ });
+
+ it('calls visitUrl and setParams with the state.query', () => {
+ testAction(actions.applyQuery, null, state, [], [], () => {
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null });
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('resetQuery', () => {
+ beforeEach(() => {
+ urlUtils.setUrlParams = jest.fn();
+ urlUtils.visitUrl = jest.fn();
+ });
+
+ it('calls visitUrl and setParams with empty values', () => {
+ testAction(actions.resetQuery, null, state, [], [], () => {
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
+ ...state.query,
+ page: null,
+ state: null,
+ confidential: null,
+ });
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
});
describe('setQuery', () => {
diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js
index 35c97d43eb0..247aff57c1a 100644
--- a/spec/frontend/static_site_editor/components/edit_area_spec.js
+++ b/spec/frontend/static_site_editor/components/edit_area_spec.js
@@ -17,6 +17,8 @@ import {
returnUrl,
mounts,
project,
+ branch,
+ baseUrl,
imageRoot,
} from '../mock_data';
@@ -36,6 +38,8 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
returnUrl,
mounts,
project,
+ branch,
+ baseUrl,
imageRoot,
savingChanges,
...propsData,
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index 4b27970023a..8bc65c6ce31 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -75,9 +75,17 @@ export const images = new Map([
export const mounts = [
{
- source: 'some/source/',
+ source: 'default/source/',
target: '',
},
+ {
+ source: 'source/with/target',
+ target: 'target',
+ },
];
+export const branch = 'master';
+
+export const baseUrl = '/user1/project1/-/sse/master%2Ftest.md';
+
export const imageRoot = 'source/images/';
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
index fcd4fa66274..f5daa70714e 100644
--- a/spec/frontend/static_site_editor/pages/home_spec.js
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -24,6 +24,8 @@ import {
trackingCategory,
images,
mounts,
+ branch,
+ baseUrl,
imageRoot,
} from '../mock_data';
@@ -44,6 +46,8 @@ describe('static_site_editor/pages/home', () => {
username,
sourcePath,
mounts,
+ branch,
+ baseUrl,
imageUploadPath: imageRoot,
};
const hasSubmittedChangesMutationPayload = {
diff --git a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js
index ab375d9e970..5ea90b8184f 100644
--- a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js
+++ b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js
@@ -1,11 +1,11 @@
import imageRenderer from '~/static_site_editor/services/renderers/render_image';
-import { mounts, project } from '../../mock_data';
+import { mounts, project, branch, baseUrl } from '../../mock_data';
describe('rich_content_editor/renderers/render_image', () => {
let renderer;
beforeEach(() => {
- renderer = imageRenderer.build(mounts, project);
+ renderer = imageRenderer.build(mounts, project, branch, baseUrl);
});
describe('build', () => {
@@ -27,37 +27,38 @@ describe('rich_content_editor/renderers/render_image', () => {
});
describe('render', () => {
- let context;
- let result;
- const skipChildren = jest.fn();
-
- beforeEach(() => {
+ it.each`
+ destination | isAbsolute | src
+ ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'}
+ ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/default/source/relative/path/to/image.png'}
+ ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/source/with/target/image.png'}
+ ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/relative/to/current/image.png'}
+ ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'}
+ ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'}
+ `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => {
+ const skipChildren = jest.fn();
+ const context = { skipChildren };
const node = {
- destination: '/some/path/image.png',
+ destination,
firstChild: {
type: 'img',
literal: 'Some Image',
},
};
+ const result = renderer.render(node, context);
- context = { skipChildren };
- result = renderer.render(node, context);
- });
-
- it('invokes `skipChildren`', () => {
- expect(skipChildren).toHaveBeenCalled();
- });
-
- it('returns an image', () => {
expect(result).toEqual({
type: 'openTag',
tagName: 'img',
selfClose: true,
attributes: {
- src: '/some/path/image.png',
+ 'data-original-src': !isAbsolute ? destination : '',
+ src,
alt: 'Some Image',
},
});
+
+ expect(skipChildren).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
index fd745c21bb6..85516eae4cf 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
@@ -189,4 +189,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => {
expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult);
});
});
+
+ describe('IMG', () => {
+ const originalSrc = 'path/to/image.png';
+ const alt = 'alt text';
+ let node;
+
+ beforeEach(() => {
+ node = document.createElement('img');
+ node.alt = alt;
+ node.src = originalSrc;
+ });
+
+ it('returns an image with its original src of the `original-src` attribute is preset', () => {
+ node.dataset.originalSrc = originalSrc;
+ node.src = 'modified/path/to/image.png';
+
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+
+ expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`);
+ });
+
+ it('fallback to `src` if no `original-src` is specified on the image', () => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`);
+ });
+ });
});
diff --git a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
index b84f12df4f6..877cc78a111 100644
--- a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
+++ b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
@@ -9,12 +9,6 @@ exports[`WebIDE runs 1`] = `
class="ide-view flex-grow d-flex"
>
<div
- class="file-finder-overlay"
- style="display: none;"
- >
- (jest: contents hidden)
- </div>
- <div
class="gl-relative multi-file-commit-panel flex-column"
style="width: 340px;"
>
@@ -109,28 +103,12 @@ exports[`WebIDE runs 1`] = `
<h4>
Make and review changes in the browser with the Web IDE
</h4>
- <div
- class="gl-spinner-container"
- >
- <span
- aria-label="Loading"
- class="align-text-bottom gl-spinner gl-spinner-dark gl-spinner-md"
- />
- </div>
</div>
</div>
</div>
</div>
</div>
</div>
- <footer
- class="ide-status-bar"
- >
- <div
- class="ide-status-list d-flex ml-auto"
- >
- </div>
- </footer>
</article>
</div>
`;
diff --git a/spec/helpers/operations_helper_spec.rb b/spec/helpers/operations_helper_spec.rb
index 63f821da2bb..09f9bba8f9e 100644
--- a/spec/helpers/operations_helper_spec.rb
+++ b/spec/helpers/operations_helper_spec.rb
@@ -44,7 +44,8 @@ RSpec.describe OperationsHelper do
'prometheus_activated' => 'false',
'prometheus_url' => notify_project_prometheus_alerts_url(project, format: :json),
'disabled' => 'false',
- 'project_path' => project.full_path
+ 'project_path' => project.full_path,
+ 'multi_integrations' => 'false'
)
end
end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index c57d345ef56..17ac7d0e44d 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -40,6 +40,46 @@ RSpec.describe UserPolicy do
end
end
+ describe "creating a different user's Personal Access Tokens" do
+ context 'when current_user is admin' do
+ let(:current_user) { create(:user, :admin) }
+
+ context 'when admin mode is enabled and current_user is not blocked', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_user_personal_access_token) }
+ end
+
+ context 'when admin mode is enabled and current_user is blocked', :enable_admin_mode do
+ let(:current_user) { create(:admin, :blocked) }
+
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+ end
+
+ context 'when current_user is not an admin' do
+ context 'creating their own personal access tokens' do
+ subject { described_class.new(current_user, current_user) }
+
+ context 'when current_user is not blocked' do
+ it { is_expected.to be_allowed(:create_user_personal_access_token) }
+ end
+
+ context 'when current_user is blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+ end
+
+ context "creating a different user's personal access tokens" do
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+ end
+ end
+
shared_examples 'changing a user' do |ability|
context "when a regular user tries to destroy another regular user" do
it { is_expected.not_to be_allowed(ability) }
diff --git a/spec/services/personal_access_tokens/create_service_spec.rb b/spec/services/personal_access_tokens/create_service_spec.rb
index 475ade95948..667ed337d83 100644
--- a/spec/services/personal_access_tokens/create_service_spec.rb
+++ b/spec/services/personal_access_tokens/create_service_spec.rb
@@ -3,21 +3,53 @@
require 'spec_helper'
RSpec.describe PersonalAccessTokens::CreateService do
+ shared_examples_for 'a successfully created token' do
+ it 'creates personal access token record' do
+ expect(subject.success?).to be true
+ expect(token.name).to eq(params[:name])
+ expect(token.impersonation).to eq(params[:impersonation])
+ expect(token.scopes).to eq(params[:scopes])
+ expect(token.expires_at).to eq(params[:expires_at])
+ expect(token.user).to eq(user)
+ end
+
+ it 'logs the event' do
+ expect(Gitlab::AppLogger).to receive(:info).with(/PAT CREATION: created_by: '#{current_user.username}', created_for: '#{user.username}', token_id: '\d+'/)
+
+ subject
+ end
+ end
+
+ shared_examples_for 'an unsuccessfully created token' do
+ it { expect(subject.success?).to be false }
+ it { expect(subject.message).to eq('Not permitted to create') }
+ it { expect(token).to be_nil }
+ end
+
describe '#execute' do
- context 'with valid params' do
- it 'creates personal access token record' do
- user = create(:user)
- params = { name: 'Test token', impersonation: true, scopes: [:api], expires_at: Date.today + 1.month }
-
- response = described_class.new(user, params).execute
- personal_access_token = response.payload[:personal_access_token]
-
- expect(response.success?).to be true
- expect(personal_access_token.name).to eq(params[:name])
- expect(personal_access_token.impersonation).to eq(params[:impersonation])
- expect(personal_access_token.scopes).to eq(params[:scopes])
- expect(personal_access_token.expires_at).to eq(params[:expires_at])
- expect(personal_access_token.user).to eq(user)
+ subject { service.execute }
+
+ let(:current_user) { create(:user) }
+ let(:user) { create(:user) }
+ let(:params) { { name: 'Test token', impersonation: false, scopes: [:api], expires_at: Date.today + 1.month } }
+ let(:service) { described_class.new(current_user: current_user, target_user: user, params: params) }
+ let(:token) { subject.payload[:personal_access_token] }
+
+ context 'when current_user is an administrator' do
+ let(:current_user) { create(:admin) }
+
+ it_behaves_like 'a successfully created token'
+ end
+
+ context 'when current_user is not an administrator' do
+ context 'target_user is not the same as current_user' do
+ it_behaves_like 'an unsuccessfully created token'
+ end
+
+ context 'target_user is same as current_user' do
+ let(:current_user) { user }
+
+ it_behaves_like 'a successfully created token'
end
end
end
diff --git a/spec/services/personal_access_tokens/revoke_service_spec.rb b/spec/services/personal_access_tokens/revoke_service_spec.rb
index 5afa43cef76..b6563a6131b 100644
--- a/spec/services/personal_access_tokens/revoke_service_spec.rb
+++ b/spec/services/personal_access_tokens/revoke_service_spec.rb
@@ -6,6 +6,11 @@ RSpec.describe PersonalAccessTokens::RevokeService do
shared_examples_for 'a successfully revoked token' do
it { expect(subject.success?).to be true }
it { expect(service.token.revoked?).to be true }
+ it 'logs the event' do
+ expect(Gitlab::AppLogger).to receive(:info).with(/PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '\d+'/)
+
+ subject
+ end
end
shared_examples_for 'an unsuccessfully revoked token' do
diff --git a/spec/support/helpers/cop_helper.rb b/spec/support/helpers/cop_helper.rb
deleted file mode 100644
index d4c056eea8a..00000000000
--- a/spec/support/helpers/cop_helper.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-# frozen_string_literal: true
-
-require 'tempfile'
-
-# This module provides methods that make it easier to test Cops.
-module CopHelper
- extend RSpec::SharedContext
-
- let(:ruby_version) { 2.4 }
- let(:rails_version) { false }
-
- def inspect_source_file(source)
- Tempfile.open('tmp') { |f| inspect_source(source, f) }
- end
-
- def inspect_source(source, file = nil)
- RuboCop::Formatter::DisabledConfigFormatter.config_to_allow_offenses = {}
- RuboCop::Formatter::DisabledConfigFormatter.detected_styles = {}
- processed_source = parse_source(source, file)
- raise 'Error parsing example code' unless processed_source.valid_syntax?
-
- _investigate(cop, processed_source)
- end
-
- def parse_source(source, file = nil)
- if file&.respond_to?(:write)
- file.write(source)
- file.rewind
- file = file.path
- end
-
- RuboCop::ProcessedSource.new(source, ruby_version, file)
- end
-
- def autocorrect_source_file(source)
- Tempfile.open('tmp') { |f| autocorrect_source(source, f) }
- end
-
- def autocorrect_source(source, file = nil)
- RuboCop::Formatter::DisabledConfigFormatter.config_to_allow_offenses = {}
- RuboCop::Formatter::DisabledConfigFormatter.detected_styles = {}
- cop.instance_variable_get(:@options)[:auto_correct] = true
- processed_source = parse_source(source, file)
- _investigate(cop, processed_source)
-
- @last_corrector.rewrite
- end
-
- def _investigate(cop, processed_source)
- team = RuboCop::Cop::Team.new([cop], nil, raise_error: true)
- report = team.investigate(processed_source)
- @last_corrector = report.correctors.first || RuboCop::Cop::Corrector.new(processed_source)
- report.offenses
- end
-end
-
-module RuboCop
- module Cop
- # Monkey-patch Cop for tests to provide easy access to messages and
- # highlights.
- # this file is an exact copy of source except for this line
- # where we change to the new Base class defined in rubocop and skirt around our superclass mismatch for class Cop
- # when running a rubocop spec.
- class Base
- def messages
- offenses.sort.map(&:message)
- end
-
- def highlights
- offenses.sort.map { |o| o.location.source }
- end
- end
- end
-end
diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb
index 558a6ee1e86..32f738faa9b 100644
--- a/spec/support/rspec.rb
+++ b/spec/support/rspec.rb
@@ -6,7 +6,13 @@ require_relative "helpers/stub_object_storage"
require_relative "helpers/stub_env"
require_relative "helpers/fast_rails_root"
-require_relative 'rubocop_patch'
+# so we need to load rubocop here due to the rubocop support file loading cop_helper
+# which monkey patches class Cop
+# if cop helper is loaded before rubocop (where class Cop is defined as class Cop < Base)
+# we get a `superclass mismatch for class Cop` error when running a rspec for a locally defined
+# rubocop cop - therefore we need rubocop required first since it had an inheritance added to the Cop class
+require 'rubocop'
+require 'rubocop/rspec/support'
RSpec.configure do |config|
config.mock_with :rspec
diff --git a/spec/support/rubocop_patch.rb b/spec/support/rubocop_patch.rb
deleted file mode 100644
index f485ef32a87..00000000000
--- a/spec/support/rubocop_patch.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-# There is an issue between rubocop versions 0.86 and 0.87 (verified by testing locally)
-# where the monkey patching in cop_helper is referencing class Cop and should really be referencing class Base instead
-# the gem's version of the cop_helper causes this issue when testing a rubocop cop locally and in CI
-# The only difference in this file as compared to gem source file is that we include our own cop_helper instead
-# which is a direct copy with a fix for the monkey patching part.
-# Doing this, resolves the issue.
-# Ideally we should move away from using the cop_helper at all as is the direction of rubocop as seen
-# here - https://github.com/rubocop-hq/rubocop/issues/8003
-# more info https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46477
-
-require_relative 'helpers/cop_helper'
-require 'rubocop/rspec/host_environment_simulation_helper'
-require 'rubocop/rspec/shared_contexts'
-require 'rubocop/rspec/expect_offense'
-
-RSpec.configure do |config|
- config.include CopHelper
- config.include HostEnvironmentSimulatorHelper
-end
diff --git a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
index a90a2dc3667..42cda3059e6 100644
--- a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
+++ b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
@@ -20,6 +20,39 @@ RSpec.shared_examples 'note entity' do
it 'does not expose web_url for author' do
expect(subject[:author]).not_to include(:web_url)
end
+
+ it 'exposes permission fields on current_user' do
+ expect(subject[:current_user]).to include(:can_edit, :can_award_emoji, :can_resolve, :can_resolve_discussion)
+ end
+
+ describe ':can_resolve_discussion' do
+ context 'discussion is resolvable' do
+ before do
+ expect(note.discussion).to receive(:resolvable?).and_return(true)
+ end
+
+ context 'user can resolve' do
+ it 'is true' do
+ expect(note.discussion).to receive(:can_resolve?).with(user).and_return(true)
+ expect(subject[:current_user][:can_resolve_discussion]).to be_truthy
+ end
+ end
+
+ context 'user cannot resolve' do
+ it 'is false' do
+ expect(note.discussion).to receive(:can_resolve?).with(user).and_return(false)
+ expect(subject[:current_user][:can_resolve_discussion]).to be_falsey
+ end
+ end
+ end
+
+ context 'discussion is not resolvable' do
+ it 'is false' do
+ expect(note.discussion).to receive(:resolvable?).and_return(false)
+ expect(subject[:current_user][:can_resolve_discussion]).to be_falsey
+ end
+ end
+ end
end
context 'when note was edited' do
diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb
index 58912eab51e..6299fd0cf36 100644
--- a/spec/views/search/_results.html.haml_spec.rb
+++ b/spec/views/search/_results.html.haml_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe 'search/_results' do
let_it_be(:wiki_blob) { create(:wiki_page, project: project, content: '*') }
let_it_be(:user) { create(:admin) }
- %w[issues blobs notes wiki_blobs merge_requests milestones].each do |search_scope|
+ %w[issues merge_requests].each do |search_scope|
context "when scope is #{search_scope}" do
let(:scope) { search_scope }
let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
@@ -55,16 +55,30 @@ RSpec.describe 'search/_results' do
expect(rendered).to have_selector('[data-track-property=search_result]')
end
- it 'renders the state filter drop down' do
+ it 'does render the sidebar' do
render
- expect(rendered).to have_selector('#js-search-filter-by-state')
+ expect(rendered).to have_selector('#js-search-sidebar')
+ end
+ end
+ end
+
+ %w[blobs notes wiki_blobs milestones].each do |search_scope|
+ context "when scope is #{search_scope}" do
+ let(:scope) { search_scope }
+ let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
+
+ it 'renders the click text event tracking attributes' do
+ render
+
+ expect(rendered).to have_selector('[data-track-event=click_text]')
+ expect(rendered).to have_selector('[data-track-property=search_result]')
end
- it 'renders the confidential drop down' do
+ it 'does not render the sidebar' do
render
- expect(rendered).to have_selector('#js-search-filter-by-confidential')
+ expect(rendered).not_to have_selector('#js-search-sidebar')
end
end
end
diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb
index 8a34b41834b..5642de05731 100644
--- a/spec/workers/remove_expired_members_worker_spec.rb
+++ b/spec/workers/remove_expired_members_worker_spec.rb
@@ -31,6 +31,50 @@ RSpec.describe RemoveExpiredMembersWorker do
end
end
+ context 'project bots' do
+ let(:project) { create(:project) }
+
+ context 'expired project bot', :sidekiq_inline do
+ let_it_be(:expired_project_bot) { create(:user, :project_bot) }
+
+ before do
+ project.add_user(expired_project_bot, :maintainer, expires_at: 1.day.from_now)
+ travel_to(3.days.from_now)
+ end
+
+ it 'removes expired project bot membership' do
+ expect { worker.perform }.to change { Member.count }.by(-1)
+ expect(Member.find_by(user_id: expired_project_bot.id)).to be_nil
+ end
+
+ it 'deletes expired project bot' do
+ worker.perform
+
+ expect(User.exists?(expired_project_bot.id)).to be(false)
+ end
+ end
+
+ context 'non-expired project bot' do
+ let_it_be(:other_project_bot) { create(:user, :project_bot) }
+
+ before do
+ project.add_user(other_project_bot, :maintainer, expires_at: 10.days.from_now)
+ travel_to(3.days.from_now)
+ end
+
+ it 'does not remove expired project bot that expires in the future' do
+ expect { worker.perform }.to change { Member.count }.by(0)
+ expect(other_project_bot.reload).to be_present
+ end
+
+ it 'does not delete project bot expiring in the future' do
+ worker.perform
+
+ expect(User.exists?(other_project_bot.id)).to be(true)
+ end
+ end
+ end
+
context 'group members' do
let_it_be(:expired_group_member) { create(:group_member, expires_at: 1.day.from_now, access_level: GroupMember::DEVELOPER) }
let_it_be(:group_member_expiring_in_future) { create(:group_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) }