diff options
46 files changed, 925 insertions, 419 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index 229067167af..d086bdf7361 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -575,6 +575,9 @@ Rails/SaveBang: - 'ee/spec/**/*.rb' - 'qa/spec/**/*.rb' - 'qa/qa/specs/**/*.rb' + Exclude: + - spec/models/wiki_page/**/* + - spec/models/wiki_page_spec.rb Cop/PutProjectRoutesUnderScope: Include: diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index c6fe01b3ac6..35676e023d8 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -245,8 +245,6 @@ Rails/SaveBang: - 'spec/models/user_preference_spec.rb' - 'spec/models/user_spec.rb' - 'spec/models/user_status_spec.rb' - - 'spec/models/wiki_page/meta_spec.rb' - - 'spec/models/wiki_page_spec.rb' Rails/TimeZone: Enabled: true diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 62eee2ff3ca..d81d04162c1 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -b19cafa222fd7a999167d3f9f8562c2d74b62bfd +5658d720f02d2c84b51feaae484ea68aeeb59773 diff --git a/app/assets/javascripts/actioncable_link.js b/app/assets/javascripts/actioncable_link.js new file mode 100644 index 00000000000..4c642db2f3c --- /dev/null +++ b/app/assets/javascripts/actioncable_link.js @@ -0,0 +1,40 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import { print } from 'graphql'; +import cable from '~/actioncable_consumer'; +import { uuids } from '~/diffs/utils/uuids'; + +export default class ActionCableLink extends ApolloLink { + // eslint-disable-next-line class-methods-use-this + request(operation) { + return new Observable((observer) => { + const subscription = cable.subscriptions.create( + { + channel: 'GraphqlChannel', + query: operation.query ? print(operation.query) : null, + variables: operation.variables, + operationName: operation.operationName, + nonce: uuids()[0], + }, + { + received(data) { + if (data.errors) { + observer.error(data.errors); + } else if (data.result) { + observer.next(data.result); + } + + if (!data.more) { + observer.complete(); + } + }, + }, + ); + + return { + unsubscribe() { + subscription.unsubscribe(); + }, + }; + }); + } +} diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 22f88b1caa7..ee8384f734d 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -238,10 +238,13 @@ class GfmAutoComplete { const MEMBER_COMMAND = { ASSIGN: '/assign', UNASSIGN: '/unassign', + ASSIGN_REVIEWER: '/assign_reviewer', + UNASSIGN_REVIEWER: '/unassign_reviewer', REASSIGN: '/reassign', CC: '/cc', }; let assignees = []; + let reviewers = []; let command = ''; // Team Members @@ -286,9 +289,11 @@ class GfmAutoComplete { return null; }); - // Cache assignees list for easier filtering later + // Cache assignees & reviewers list for easier filtering later assignees = SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || []; + reviewers = + SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || []; const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); return match && match.length ? match[1] : null; @@ -309,6 +314,12 @@ class GfmAutoComplete { } else if (command === MEMBER_COMMAND.UNASSIGN) { // Only include members which are assigned to Issuable currently return data.filter((member) => assignees.includes(member.search)); + } else if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) { + // Only include members which are not assigned as a reviewer to Issuable currently + return data.filter((member) => !reviewers.includes(member.search)); + } else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) { + // Only include members which are not assigned as a reviewer to Issuable currently + return data.filter((member) => reviewers.includes(member.search)); } return data; diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 1630f0d689c..cec689a44ca 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; import { createHttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; +import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; @@ -83,15 +84,27 @@ export default (resolvers = {}, config = {}) => { }); }); - return new ApolloClient({ - typeDefs, - link: ApolloLink.from([ + const hasSubscriptionOperation = ({ query: { definitions } }) => { + return definitions.some( + ({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription', + ); + }; + + const appLink = ApolloLink.split( + hasSubscriptionOperation, + new ActionCableLink(), + ApolloLink.from([ requestCounterLink, performanceBarLink, new StartupJSLink(), apolloCaptchaLink, uploadsLink, ]), + ); + + return new ApolloClient({ + typeDefs, + link: appLink, cache: new InMemoryCache({ ...cacheConfig, freezeResults: assumeImmutableResults, diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue new file mode 100644 index 00000000000..091b202e10b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue @@ -0,0 +1,155 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + DEFAULT_SUCCESS, + LOAD_FAILURE_UNKNOWN, +} from '../../constants'; +import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue'; +import { + CODE_SNIPPET_SOURCE_URL_PARAM, + CODE_SNIPPET_SOURCES, +} from '../code_snippet_alert/constants'; + +export default { + components: { + GlAlert, + CodeSnippetAlert, + }, + errorTexts: { + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + [DEFAULT_FAILURE]: __('Something went wrong on our end.'), + [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + }, + successTexts: { + [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + [DEFAULT_SUCCESS]: __('Your action succeeded.'), + }, + props: { + failureType: { + type: String, + required: false, + default: null, + }, + failureReasons: { + type: Array, + required: false, + default: () => [], + }, + showFailure: { + type: Boolean, + required: false, + default: false, + }, + showSuccess: { + type: Boolean, + required: false, + default: false, + }, + successType: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + codeSnippetCopiedFrom: '', + }; + }, + computed: { + failure() { + switch (this.failureType) { + case LOAD_FAILURE_UNKNOWN: + return { + text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], + variant: 'danger', + }; + case COMMIT_FAILURE: + return { + text: this.$options.errorTexts[COMMIT_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT_FAILURE], + variant: 'danger', + }; + } + }, + success() { + switch (this.successType) { + case COMMIT_SUCCESS: + return { + text: this.$options.successTexts[COMMIT_SUCCESS], + variant: 'info', + }; + default: + return { + text: this.$options.successTexts[DEFAULT_SUCCESS], + variant: 'info', + }; + } + }, + }, + created() { + this.parseCodeSnippetSourceParam(); + }, + methods: { + dismissCodeSnippetAlert() { + this.codeSnippetCopiedFrom = ''; + }, + dismissFailure() { + this.$emit('hide-failure'); + }, + dismissSuccess() { + this.$emit('hide-success'); + }, + parseCodeSnippetSourceParam() { + const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM); + if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) { + this.codeSnippetCopiedFrom = codeSnippetCopiedFrom; + window.history.replaceState( + {}, + document.title, + removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]), + ); + } + }, + }, +}; +</script> + +<template> + <div> + <code-snippet-alert + v-if="codeSnippetCopiedFrom" + :source="codeSnippetCopiedFrom" + class="gl-mb-5" + @dismiss="dismissCodeSnippetAlert" + /> + <gl-alert + v-if="showSuccess" + :variant="success.variant" + class="gl-mb-5" + @dismiss="dismissSuccess" + > + {{ success.text }} + </gl-alert> + <gl-alert + v-if="showFailure" + :variant="failure.variant" + class="gl-mb-5" + @dismiss="dismissFailure" + > + {{ failure.text }} + <ul v-if="failureReasons.length" class="gl-mb-0"> + <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> + </ul> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 8d0ec6c3e2d..56862f17858 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -14,6 +14,7 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; +export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS'; export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export const CREATE_TAB = 'CREATE_TAB'; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 5f9662e90e6..bf36e00c662 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,22 +1,15 @@ <script> -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; import httpStatusCodes from '~/lib/utils/http_status'; -import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; -import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue'; -import { - CODE_SNIPPET_SOURCE_URL_PARAM, - CODE_SNIPPET_SOURCES, -} from './components/code_snippet_alert/constants'; + import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; +import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue'; import { - COMMIT_FAILURE, - COMMIT_SUCCESS, - DEFAULT_FAILURE, EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_LOADING, @@ -32,11 +25,10 @@ import PipelineEditorHome from './pipeline_editor_home.vue'; export default { components: { ConfirmUnsavedChangesDialog, - GlAlert, GlLoadingIcon, PipelineEditorEmptyState, PipelineEditorHome, - CodeSnippetAlert, + PipelineEditorMessages, }, inject: { ciConfigPath: { @@ -51,15 +43,14 @@ export default { ciConfigData: {}, failureType: null, failureReasons: [], - showStartScreen: false, initialCiFileContent: '', isNewCiConfigFile: false, lastCommittedContent: '', currentCiFileContent: '', - showFailureAlert: false, - showSuccessAlert: false, successType: null, - codeSnippetCopiedFrom: '', + showStartScreen: false, + showSuccess: false, + showFailure: false, }; }, @@ -152,50 +143,12 @@ export default { isEmpty() { return this.currentCiFileContent === ''; }, - failure() { - switch (this.failureType) { - case LOAD_FAILURE_UNKNOWN: - return { - text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], - variant: 'danger', - }; - case COMMIT_FAILURE: - return { - text: this.$options.errorTexts[COMMIT_FAILURE], - variant: 'danger', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT_FAILURE], - variant: 'danger', - }; - } - }, - success() { - switch (this.successType) { - case COMMIT_SUCCESS: - return { - text: this.$options.successTexts[COMMIT_SUCCESS], - variant: 'info', - }; - default: - return null; - } - }, }, i18n: { tabEdit: s__('Pipelines|Write pipeline configuration'), tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), }, - errorTexts: { - [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), - [DEFAULT_FAILURE]: __('Something went wrong on our end.'), - [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), - }, - successTexts: { - [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), - }, watch: { isEmpty(flag) { if (flag) { @@ -203,9 +156,6 @@ export default { } }, }, - created() { - this.parseCodeSnippetSourceParam(); - }, methods: { handleBlobContentError(error = {}) { const { networkError } = error; @@ -223,12 +173,11 @@ export default { this.reportFailure(LOAD_FAILURE_UNKNOWN); } }, - - dismissFailure() { - this.showFailureAlert = false; + hideFailure() { + this.showFailure = false; }, - dismissSuccess() { - this.showSuccessAlert = false; + hideSuccess() { + this.showSuccess = false; }, async refetchContent() { this.$apollo.queries.initialCiFileContent.skip = false; @@ -238,13 +187,13 @@ export default { this.setAppStatus(EDITOR_APP_STATUS_ERROR); window.scrollTo({ top: 0, behavior: 'smooth' }); - this.showFailureAlert = true; + this.showFailure = true; this.failureType = type; this.failureReasons = reasons; }, reportSuccess(type) { window.scrollTo({ top: 0, behavior: 'smooth' }); - this.showSuccessAlert = true; + this.showSuccess = true; this.successType = type; }, resetContent() { @@ -277,20 +226,6 @@ export default { // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; }, - parseCodeSnippetSourceParam() { - const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM); - if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) { - this.codeSnippetCopiedFrom = codeSnippetCopiedFrom; - window.history.replaceState( - {}, - document.title, - removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]), - ); - } - }, - dismissCodeSnippetAlert() { - this.codeSnippetCopiedFrom = ''; - }, }, }; </script> @@ -303,31 +238,15 @@ export default { @createEmptyConfigFile="setNewEmptyCiConfigFile" /> <div v-else> - <code-snippet-alert - v-if="codeSnippetCopiedFrom" - :source="codeSnippetCopiedFrom" - class="gl-mb-5" - @dismiss="dismissCodeSnippetAlert" + <pipeline-editor-messages + :failure-type="failureType" + :failure-reasons="failureReasons" + :show-failure="showFailure" + :show-success="showSuccess" + :success-type="successType" + @hide-success="hideSuccess" + @hide-failure="hideFailure" /> - <gl-alert - v-if="showSuccessAlert" - :variant="success.variant" - class="gl-mb-5" - @dismiss="dismissSuccess" - > - {{ success.text }} - </gl-alert> - <gl-alert - v-if="showFailureAlert" - :variant="failure.variant" - class="gl-mb-5" - @dismiss="dismissFailure" - > - {{ failure.text }} - <ul v-if="failureReasons.length" class="gl-mb-0"> - <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> - </ul> - </gl-alert> <pipeline-editor-home :ci-config-data="ciConfigData" :ci-file-content="currentCiFileContent" diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index f98798582c1..e7ef731eed8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,6 +1,7 @@ <script> -import actionCable from '~/actioncable_consumer'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import produce from 'immer'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; import { assigneesQueries } from '~/sidebar/constants'; export default { @@ -12,60 +13,62 @@ export default { required: false, default: null, }, - issuableIid: { + issuableType: { type: String, required: true, }, - projectPath: { - type: String, + issuableId: { + type: Number, required: true, }, - issuableType: { - type: String, + queryVariables: { + type: Object, required: true, }, }, + computed: { + issuableClass() { + return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType); + }, + }, apollo: { - workspace: { + issuable: { query() { return assigneesQueries[this.issuableType].query; }, variables() { - return { - iid: this.issuableIid, - fullPath: this.projectPath, - }; + return this.queryVariables; + }, + update(data) { + return data.workspace?.issuable; }, - result(data) { - if (this.mediator) { - this.handleFetchResult(data); - } + subscribeToMore: { + document() { + return assigneesQueries[this.issuableType].subscription; + }, + variables() { + return { + issuableId: convertToGraphQLId(this.issuableClass, this.issuableId), + }; + }, + updateQuery(prev, { subscriptionData }) { + if (prev && subscriptionData?.data?.issuableAssigneesUpdated) { + const data = produce(prev, (draftData) => { + draftData.workspace.issuable.assignees.nodes = + subscriptionData.data.issuableAssigneesUpdated.assignees.nodes; + }); + if (this.mediator) { + this.handleFetchResult(data); + } + return data; + } + return prev; + }, }, }, }, - mounted() { - this.initActionCablePolling(); - }, - beforeDestroy() { - this.$options.subscription.unsubscribe(); - }, methods: { - received(data) { - if (data.event === 'updated') { - this.$apollo.queries.workspace.refetch(); - } - }, - initActionCablePolling() { - this.$options.subscription = actionCable.subscriptions.create( - { - channel: 'IssuesChannel', - project_path: this.projectPath, - iid: this.issuableIid, - }, - { received: this.received }, - ); - }, - handleFetchResult({ data }) { + handleFetchResult(data) { const { nodes } = data.workspace.issuable.assignees; const assignees = nodes.map((n) => ({ diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index e15ea595190..ca95599742a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -44,6 +44,10 @@ export default { type: String, required: true, }, + issuableId: { + type: Number, + required: true, + }, assigneeAvailabilityStatus: { type: Object, required: false, @@ -61,6 +65,12 @@ export default { // Note: Realtime is only available on issues right now, future support for MR wil be built later. return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; }, + queryVariables() { + return { + iid: this.issuableIid, + fullPath: this.projectPath, + }; + }, relativeUrlRoot() { return gon.relative_url_root ?? ''; }, @@ -121,9 +131,9 @@ export default { <div> <assignees-realtime v-if="shouldEnableRealtime" - :issuable-iid="issuableIid" - :project-path="projectPath" :issuable-type="issuableType" + :issuable-id="issuableId" + :query-variables="queryVariables" :mediator="mediator" /> <assignee-title diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 78cac989850..2fc25151d1c 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -73,6 +73,11 @@ export default { return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); }, }, + issuableId: { + type: Number, + required: false, + default: null, + }, multipleAssignees: { type: Boolean, required: false, @@ -340,9 +345,9 @@ export default { <div data-testid="assignees-widget"> <sidebar-assignees-realtime v-if="shouldEnableRealtime" - :project-path="fullPath" - :issuable-iid="iid" :issuable-type="issuableType" + :issuable-id="issuableId" + :query-variables="queryVariables" /> <sidebar-editable-item ref="toggle" diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 80e07d556bf..58e4b0348ca 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,5 +1,6 @@ import { IssuableType } from '~/issue_show/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; +import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; @@ -17,6 +18,7 @@ export const ASSIGNEES_DEBOUNCE_DELAY = 250; export const assigneesQueries = { [IssuableType.Issue]: { query: getIssueParticipants, + subscription: issuableAssigneesSubscription, mutation: updateAssigneesMutation, }, [IssuableType.MergeRequest]: { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 1304e84814b..52a1efa04e4 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) { if (!el) return; - const { iid, fullPath } = getSidebarOptions(); + const { id, iid, fullPath } = getSidebarOptions(); const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData(); // eslint-disable-next-line no-new new Vue({ @@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) { isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableId: id, assigneeAvailabilityStatus, }, }), @@ -85,7 +86,7 @@ function mountAssigneesComponent() { if (!el) return; - const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); + const { id, iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); // eslint-disable-next-line no-new new Vue({ el, @@ -108,6 +109,7 @@ function mountAssigneesComponent() { isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableId: id, multipleAssignees: !el.dataset.maxAssignees, }, scopedSlots: { diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql new file mode 100644 index 00000000000..47ce094418c --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +subscription issuableAssigneesUpdated($issuableId: IssuableID!) { + issuableAssigneesUpdated(issuableId: $issuableId) { + ... on Issue { + assignees { + nodes { + ...User + status { + availability + } + } + } + } + } +} diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index c6fde38579b..e5ea2920eaa 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -386,6 +386,7 @@ module IssuablesHelper rootPath: root_path, fullPath: issuable[:project_full_path], iid: issuable[:iid], + id: issuable[:id], severity: issuable[:severity], timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, createNoteEmail: issuable[:create_note_email], diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index ffa09cb12fb..0711a6b7e97 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -153,9 +153,9 @@ module ServicesHelper private def integration_level(integration) - if integration.instance + if integration.instance_level? 'instance' - elsif integration.group_id + elsif integration.group_level? 'group' else 'project' diff --git a/app/models/service.rb b/app/models/service.rb index 7782b016b52..51ac4555c1f 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -51,14 +51,14 @@ class Service < ApplicationRecord belongs_to :group, inverse_of: :services has_one :service_hook - validates :project_id, presence: true, unless: -> { template? || instance? || group_id } - validates :group_id, presence: true, unless: -> { template? || instance? || project_id } - validates :project_id, :group_id, absence: true, if: -> { template? || instance? } + validates :project_id, presence: true, unless: -> { template? || instance_level? || group_level? } + validates :group_id, presence: true, unless: -> { template? || instance_level? || project_level? } + validates :project_id, :group_id, absence: true, if: -> { template? || instance_level? } validates :type, presence: true validates :type, uniqueness: { scope: :template }, if: :template? - validates :type, uniqueness: { scope: :instance }, if: :instance? - validates :type, uniqueness: { scope: :project_id }, if: :project_id? - validates :type, uniqueness: { scope: :group_id }, if: :group_id? + validates :type, uniqueness: { scope: :instance }, if: :instance_level? + validates :type, uniqueness: { scope: :project_id }, if: :project_level? + validates :type, uniqueness: { scope: :group_id }, if: :group_level? validate :validate_is_instance_or_template validate :validate_belongs_to_project_or_group @@ -240,7 +240,7 @@ class Service < ApplicationRecord service.instance = false service.project_id = project_id service.group_id = group_id - service.inherit_from_id = integration.id if integration.instance? || integration.group + service.inherit_from_id = integration.id if integration.instance_level? || integration.group_level? service end @@ -409,7 +409,7 @@ class Service < ApplicationRecord # Disable test for instance-level and group-level services. # https://gitlab.com/gitlab-org/gitlab/-/issues/213138 def can_test? - !instance? && !group_id + !(instance_level? || group_level?) end def project_level? @@ -460,11 +460,11 @@ class Service < ApplicationRecord private def validate_is_instance_or_template - errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance? + errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance_level? end def validate_belongs_to_project_or_group - errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_id && group_id + errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level? end def validate_recipients? diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb index 253c3a84fef..2ce1756ef1a 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/admin/propagate_integration_service.rb @@ -5,7 +5,7 @@ module Admin include PropagateService def propagate - if integration.instance? + if integration.instance_level? update_inherited_integrations create_integration_for_groups_without_integration create_integration_for_projects_without_integration diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index c7ec3ab66d7..db5f58f8bd0 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -12,7 +12,7 @@ %li = image_tag avatar_icon_for_user(@user, 60), class: "avatar s60" %li - %span.light Profile page: + %span.light= _('Profile page:') %strong = link_to user_path(@user) do = @user.username @@ -20,25 +20,25 @@ .card .card-header - Account: + = _('Account:') %ul.content-list %li - %span.light Name: + %span.light= _('Name:') %strong= @user.name %li - %span.light Username: + %span.light= _('Username:') %strong = @user.username %li - %span.light Email: + %span.light= _('Email:') %strong = render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? } - @user.emails.each do |email| %li - %span.light Secondary email: + %span.light= _('Secondary email:') %strong = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } - = link_to remove_email_admin_user_path(@user, email), data: { confirm: "Are you sure you want to remove #{email.email}?" }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: 'Remove secondary email', id: "remove_email_#{email.id}" do + = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email } }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do = sprite_icon('close', size: 16, css_class: 'gl-icon') %li %span.light ID: @@ -50,65 +50,65 @@ = @user.namespace_id %li.two-factor-status - %span.light Two-factor Authentication: + %span.light= _('Two-factor Authentication:') %strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' } - if @user.two_factor_enabled? - Enabled - = link_to 'Disable', disable_two_factor_admin_user_path(@user), data: {confirm: 'Are you sure?'}, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: 'Disable Two-factor Authentication' + = _('Enabled') + = link_to _('Disable'), disable_two_factor_admin_user_path(@user), data: { confirm: _('Are you sure?') }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication') - else - Disabled + = _('Disabled') = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace %li - %span.light External User: + %span.light= _('External User:') %strong - = @user.external? ? "Yes" : "No" + = @user.external? ? _('Yes') : _('No') %li - %span.light Can create groups: + %span.light= _('Can create groups:') %strong - = @user.can_create_group ? "Yes" : "No" + = @user.can_create_group ? _('Yes') : _('No') %li - %span.light Personal projects limit: + %span.light= _('Personal projects limit:') %strong = @user.projects_limit %li - %span.light Member since: + %span.light= _('Member since:') %strong = @user.created_at.to_s(:medium) - if @user.confirmed_at %li - %span.light Confirmed at: + %span.light= _('Confirmed at:') %strong = @user.confirmed_at.to_s(:medium) - else %li - %span.light Confirmed: + %span.ligh= _('Confirmed:') %strong.cred - No + = _('No') %li - %span.light Current sign-in IP: + %span.light= _('Current sign-in IP:') %strong = @user.current_sign_in_ip || _('never') %li - %span.light Current sign-in at: + %span.light= _('Current sign-in at:') %strong = @user.current_sign_in_at&.to_s(:medium) || _('never') %li - %span.light Last sign-in IP: + %span.light= _('Last sign-in IP:') %strong = @user.last_sign_in_ip || _('never') %li - %span.light Last sign-in at: + %span.light= _('Last sign-in at:') %strong = @user.last_sign_in_at&.to_s(:medium) || _('never') %li - %span.light Sign-in count: + %span.light= _('Sign-in count:') %strong = @user.sign_in_count @@ -121,13 +121,13 @@ - if @user.ldap_user? %li - %span.light LDAP uid: + %span.light= _('LDAP uid:') %strong = @user.ldap_identity.extern_uid - if @user.created_by %li - %span.light Created by: + %span.light= _('Created by:') %strong = link_to @user.created_by.name, [:admin, @user.created_by] @@ -140,13 +140,13 @@ - if can_force_email_confirmation?(@user) .gl-card.border-info.gl-mb-5 .gl-card-header.bg-info.text-white - Confirm user + = _('Confirm user') .gl-card-body - if @user.unconfirmed_email.present? - email = " (#{@user.unconfirmed_email})" - %p This user has an unconfirmed email address#{email}. You may force a confirmation. + %p= _('This user has an unconfirmed email address %{email}. You may force a confirmation.') % { email: email } %br - = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' } + = link_to _('Confirm user'), confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?'), qa_selector: 'confirm_user_button' } = render 'admin/users/user_detail_note' @@ -154,7 +154,7 @@ - if @user.deactivated? .gl-card.border-info.gl-mb-5 .gl-card-header.bg-info.text-white - Reactivate this user + = _('Reactivate this user') .gl-card-body = render partial: 'admin/users/user_activation_effects' %br @@ -163,7 +163,7 @@ - elsif @user.can_be_deactivated? .gl-card.border-warning.gl-mb-5 .gl-card-header.bg-warning.text-white - Deactivate this user + = _('Deactivate this user') .gl-card-body = user_deactivation_effects %br @@ -176,12 +176,12 @@ - else .gl-card.border-info.gl-mb-5 .gl-card-header.gl-bg-blue-500.gl-text-white - This user is blocked + = _('This user is blocked') .gl-card-body - %p A blocked user cannot: + %p= _('A blocked user cannot:') %ul - %li Log in - %li Access Git repositories + %li= _('Log in') + %li= _('Access Git repositories') %br %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unblock_data(@user) } = s_('AdminUsers|Unblock user') @@ -191,18 +191,18 @@ - if @user.access_locked? .card.border-info.gl-mb-5 .card-header.bg-info.text-white - This account has been locked + = _('This account has been locked') .card-body - %p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account. + %p= _('This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.') %br - = link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' } + = link_to _('Unlock user'), unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?') } - if !@user.blocked_pending_approval? .gl-card.border-danger.gl-mb-5 .gl-card-header.bg-danger.text-white = s_('AdminUsers|Delete user') .gl-card-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) - %p Deleting a user has the following effects: + %p= _('Deleting a user has the following effects:') = render 'users/deletion_guidance', user: @user %br %button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete', @@ -213,13 +213,13 @@ - else - if @user.solo_owned_groups.present? %p - This user is currently an owner in these groups: + = _('This user is currently an owner in these groups:') %strong= @user.solo_owned_groups.map(&:name).join(', ') %p - You must transfer ownership or delete these groups before you can delete this user. + = _('You must transfer ownership or delete these groups before you can delete this user.') - else %p - You don't have access to delete this user. + = _("You don't have access to delete this user.") .gl-card.border-danger .gl-card-header.bg-danger.text-white @@ -227,13 +227,8 @@ .gl-card-body - if can?(current_user, :destroy_user, @user) %p - This option deletes the user and any contributions that - would usually be moved to the - = succeed "." do - = link_to "system ghost user", help_page_path("user/profile/account/delete_account") - As well as the user's personal projects, groups owned solely by - the user, and projects in them, will also be removed. Commits - to other projects are unaffected. + - link_to_ghost_user = link_to(_("system ghost user"), help_page_path("user/profile/account/delete_account")) + = _("This option deletes the user and any contributions that would usually be moved to the %{link_to_ghost_user}. As well as the user's personal projects, groups owned solely by the user, and projects in them, will also be removed. Commits to other projects are unaffected.").html_safe % { link_to_ghost_user: link_to_ghost_user } %br %button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions', delete_user_url: admin_user_path(@user, hard_delete: true), @@ -242,6 +237,6 @@ = s_('AdminUsers|Delete user and contributions') - else %p - You don't have access to delete this user. + = _("You don't have access to delete this user.") = render partial: 'admin/users/modals' diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 01f3e441eef..1bf252b6282 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -9,13 +9,13 @@ %h4.gl-mt-0 = page_title %p + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') } - if current_user.can?(:create_resource_access_tokens, @project) - = _('You can generate an access token scoped to this project for each application to use the GitLab API.') - -# Commented out until https://gitlab.com/gitlab-org/gitlab/-/issues/219551 is fixed - -# %p - -# = _('You can also use project access tokens to authenticate against Git over HTTP.') + = _('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.') + %p + = _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - else - = _('Project access token creation is disabled in this group. You can still use and manage existing tokens.') + = _('Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %p - root_group = @project.group.root_ancestor - if current_user.can?(:admin_group, root_group) @@ -23,7 +23,6 @@ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link } = _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - .col-lg-8 - if @new_project_access_token = render 'shared/access_tokens/created_container', diff --git a/app/workers/propagate_integration_group_worker.rb b/app/workers/propagate_integration_group_worker.rb index 01155753877..f763406b380 100644 --- a/app/workers/propagate_integration_group_worker.rb +++ b/app/workers/propagate_integration_group_worker.rb @@ -11,7 +11,7 @@ class PropagateIntegrationGroupWorker integration = Service.find_by_id(integration_id) return unless integration - batch = if integration.instance? + batch = if integration.instance_level? Group.where(id: min_id..max_id).without_integration(integration) else integration.group.descendants.where(id: min_id..max_id).without_integration(integration) diff --git a/app/workers/propagate_integration_project_worker.rb b/app/workers/propagate_integration_project_worker.rb index 188d81e5fc1..50c68c85f03 100644 --- a/app/workers/propagate_integration_project_worker.rb +++ b/app/workers/propagate_integration_project_worker.rb @@ -12,7 +12,7 @@ class PropagateIntegrationProjectWorker return unless integration batch = Project.where(id: min_id..max_id).without_integration(integration) - batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_id + batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_level? return if batch.empty? diff --git a/changelogs/unreleased/Externalize-strings-in-show-html-haml.yml b/changelogs/unreleased/Externalize-strings-in-show-html-haml.yml new file mode 100644 index 00000000000..f4926316a54 --- /dev/null +++ b/changelogs/unreleased/Externalize-strings-in-show-html-haml.yml @@ -0,0 +1,5 @@ +--- +title: Externalize strings in /users/show.html.haml +merge_request: 58126 +author: nuwe1 +type: other diff --git a/changelogs/unreleased/ap-access-tokens-ui-text-okr.yml b/changelogs/unreleased/ap-access-tokens-ui-text-okr.yml new file mode 100644 index 00000000000..b9926faa4c1 --- /dev/null +++ b/changelogs/unreleased/ap-access-tokens-ui-text-okr.yml @@ -0,0 +1,5 @@ +--- +title: Revise project access tokens UI text +merge_request: 59878 +author: +type: other diff --git a/changelogs/unreleased/issue-220040-fix-rails-savebang-wiki-model.yml b/changelogs/unreleased/issue-220040-fix-rails-savebang-wiki-model.yml new file mode 100644 index 00000000000..14becc0eac8 --- /dev/null +++ b/changelogs/unreleased/issue-220040-fix-rails-savebang-wiki-model.yml @@ -0,0 +1,5 @@ +--- +title: Fix Rails/SaveBang Rubocop offenses for wiki_page models +merge_request: 57899 +author: Huzaifa Iftikhar @huzaifaiftikhar +type: fixed diff --git a/config/metrics/counts_28d/20210216181002_projects_with_tracing_enabled.yml b/config/metrics/counts_28d/20210216181002_projects_with_tracing_enabled.yml index 6a559c1460f..b934fa26cd4 100644 --- a/config/metrics/counts_28d/20210216181002_projects_with_tracing_enabled.yml +++ b/config/metrics/counts_28d/20210216181002_projects_with_tracing_enabled.yml @@ -4,13 +4,15 @@ description: Projects with tracing enabled product_section: ops product_stage: product_group: group::monitor -product_category: +product_category: tracing value_type: number status: data_available time_frame: 28d -data_source: +data_source: database distribution: -- ce + - ce + - ee tier: -- free -skip_validation: true + - free + - premium + - ultimate diff --git a/config/metrics/counts_all/20210216180929_projects_with_tracing_enabled.yml b/config/metrics/counts_all/20210216180929_projects_with_tracing_enabled.yml index 6ce96c5750d..a3a5f2d8ffb 100644 --- a/config/metrics/counts_all/20210216180929_projects_with_tracing_enabled.yml +++ b/config/metrics/counts_all/20210216180929_projects_with_tracing_enabled.yml @@ -10,7 +10,9 @@ status: data_available time_frame: all data_source: database distribution: -- ce + - ce + - ee tier: -- free -skip_validation: true + - free + - premium + - ultimate diff --git a/config/metrics/counts_all/20210216180951_projects_with_tracing_enabled.yml b/config/metrics/counts_all/20210216180951_projects_with_tracing_enabled.yml index fc362feda69..26c5073cae1 100644 --- a/config/metrics/counts_all/20210216180951_projects_with_tracing_enabled.yml +++ b/config/metrics/counts_all/20210216180951_projects_with_tracing_enabled.yml @@ -8,9 +8,11 @@ product_category: tracing value_type: number status: data_available time_frame: all -data_source: +data_source: database distribution: -- ce + - ce + - ee tier: -- free -skip_validation: true + - free + - premium + - ultimate diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index fcf9e4b91cf..beff7def776 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11900,22 +11900,6 @@ Represents the Geo sync and verification state of a snippet repository. | <a id="submoduletype"></a>`type` | [`EntryType!`](#entrytype) | Type of tree entry. | | <a id="submoduleweburl"></a>`webUrl` | [`String`](#string) | Web URL for the sub-module. | -### `Subscription` - -#### Fields with arguments - -##### `Subscription.issuableAssigneesUpdated` - -Triggered when the assignees of an issuable are updated. - -Returns [`Issuable`](#issuable). - -###### Arguments - -| Name | Type | Description | -| ---- | ---- | ----------- | -| <a id="subscriptionissuableassigneesupdatedissuableid"></a>`issuableId` | [`IssuableID!`](#issuableid) | ID of the issuable. | - ### `TaskCompletionStatus` Completion status of tasks. diff --git a/doc/development/usage_ping/dictionary.md b/doc/development/usage_ping/dictionary.md index 99a29a9f2c8..dc2d5d65872 100644 --- a/doc/development/usage_ping/dictionary.md +++ b/doc/development/usage_ping/dictionary.md @@ -5082,7 +5082,7 @@ Group: `group::monitor` Status: `data_available` -Tiers: `free` +Tiers: `free`, `premium`, `ultimate` ### `counts.projects_youtrack_active` @@ -15680,7 +15680,7 @@ Group: `group::monitor` Status: `data_available` -Tiers: `free` +Tiers: `free`, `premium`, `ultimate` ### `usage_activity_by_stage.package.projects_with_packages` @@ -17600,7 +17600,7 @@ Group: `group::monitor` Status: `data_available` -Tiers: `free` +Tiers: `free`, `premium`, `ultimate` ### `usage_activity_by_stage_monthly.package.projects_with_packages` diff --git a/doc/university/training/index.md b/doc/university/training/index.md index f69bd51b341..7aabd6b2757 100644 --- a/doc/university/training/index.md +++ b/doc/university/training/index.md @@ -17,7 +17,6 @@ All training material is open to public contribution. This section contains the following topics: -- [Agile and Git](topics/agile_git.md). - [Bisect](topics/bisect.md). - [Cherry pick](topics/cherry_picking.md). - [Code review and collaboration with Merge Requests](topics/merge_requests.md). diff --git a/doc/university/training/topics/agile_git.md b/doc/university/training/topics/agile_git.md index cb82d3cec64..f912f92fad2 100644 --- a/doc/university/training/topics/agile_git.md +++ b/doc/university/training/topics/agile_git.md @@ -1,33 +1,8 @@ --- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments -comments: false +redirect_to: '../../../user/project/issue_board.md' --- -# Agile and Git +Information about using Agile concepts in GitLab can be found in [another location](../../../user/project/issue_board.md). -## Agile - -Lean software development methods focused on collaboration and interaction -with fast and smaller deployment cycles. - -## Where Git comes in - -Git is an excellent tool for an Agile team considering that it allows -decentralized and simultaneous development. - -### Branching And Workflows - -Branching in an Agile environment usually happens around user stories with one -or more developers working on it. - -If more than one developer then another branch for each developer is also used -with their initials, and US ID. - -After its tested merge into master and remove the branch. - -## What about GitLab - -Tools like GitLab enhance collaboration by adding dialog around code mainly -through issues and merge requests. +<!-- This redirect file can be deleted after <2021-07-23>. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/lib/gitlab/graphql/docs/helper.rb b/lib/gitlab/graphql/docs/helper.rb index ce5fb575b54..5274b2ee3ba 100644 --- a/lib/gitlab/graphql/docs/helper.rb +++ b/lib/gitlab/graphql/docs/helper.rb @@ -347,7 +347,7 @@ module Gitlab mutations = schema.mutation&.fields&.keys&.to_set || [] graphql_object_types - .reject { |object_type| object_type[:name]["__"] } # We ignore introspection types. + .reject { |object_type| object_type[:name]["__"] || object_type[:name] == 'Subscription' } # We ignore introspection and subscription types. .map do |type| name = type[:name] type.merge( diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 79dd8f1ba43..6fea3589adc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1370,6 +1370,9 @@ msgstr "" msgid "A basic template for developing Linux programs using Kotlin Native" msgstr "" +msgid "A blocked user cannot:" +msgstr "" + msgid "A complete DevOps platform" msgstr "" @@ -1667,6 +1670,9 @@ msgstr "" msgid "Acceptable for use in this project" msgstr "" +msgid "Access Git repositories" +msgstr "" + msgid "Access Tokens" msgstr "" @@ -1793,6 +1799,9 @@ msgstr "" msgid "Account and limit" msgstr "" +msgid "Account:" +msgstr "" + msgid "Account: %{account}" msgstr "" @@ -4268,6 +4277,9 @@ msgstr "" msgid "Are you sure you want to reindex?" msgstr "" +msgid "Are you sure you want to remove %{email}?" +msgstr "" + msgid "Are you sure you want to remove %{group_name}?" msgstr "" @@ -5696,6 +5708,9 @@ msgstr "" msgid "Can be manually deployed to" msgstr "" +msgid "Can create groups:" +msgstr "" + msgid "Can't apply as the source branch was deleted." msgstr "" @@ -8374,6 +8389,9 @@ msgstr "" msgid "Confirm new password" msgstr "" +msgid "Confirm user" +msgstr "" + msgid "Confirm your account" msgstr "" @@ -8386,6 +8404,12 @@ msgstr "" msgid "Confirmation required" msgstr "" +msgid "Confirmed at:" +msgstr "" + +msgid "Confirmed:" +msgstr "" + msgid "Confluence" msgstr "" @@ -9619,6 +9643,12 @@ msgstr "" msgid "Current password" msgstr "" +msgid "Current sign-in IP:" +msgstr "" + +msgid "Current sign-in at:" +msgstr "" + msgid "Current vulnerabilities count" msgstr "" @@ -10375,6 +10405,9 @@ msgstr "" msgid "Days to merge" msgstr "" +msgid "Deactivate this user" +msgstr "" + msgid "Dear Administrator," msgstr "" @@ -10615,6 +10648,9 @@ msgstr "" msgid "Deleting a project places it into a read-only state until %{date}, at which point the project will be permanently deleted. Are you ABSOLUTELY sure?" msgstr "" +msgid "Deleting a user has the following effects:" +msgstr "" + msgid "Deleting the project will delete its repository and all related resources including issues, merge requests, etc." msgstr "" @@ -11361,6 +11397,9 @@ msgstr "" msgid "Disable" msgstr "" +msgid "Disable Two-factor Authentication" +msgstr "" + msgid "Disable for this project" msgstr "" @@ -11879,6 +11918,9 @@ msgstr "" msgid "Email updates (optional)" msgstr "" +msgid "Email:" +msgstr "" + msgid "Email: %{email}" msgstr "" @@ -13169,6 +13211,9 @@ msgstr "" msgid "External URL" msgstr "" +msgid "External User:" +msgstr "" + msgid "External authentication" msgstr "" @@ -14218,6 +14263,9 @@ msgstr "" msgid "Generate new token" msgstr "" +msgid "Generate project access tokens scoped to this project for your applications that need access to the GitLab API." +msgstr "" + msgid "Generate site and private keys at" msgstr "" @@ -18625,6 +18673,9 @@ msgstr "" msgid "LDAP synchronizations" msgstr "" +msgid "LDAP uid:" +msgstr "" + msgid "LFS" msgstr "" @@ -18780,6 +18831,12 @@ msgstr "" msgid "Last sign-in" msgstr "" +msgid "Last sign-in IP:" +msgstr "" + +msgid "Last sign-in at:" +msgstr "" + msgid "Last successful sync" msgstr "" @@ -19439,6 +19496,9 @@ msgstr "" msgid "Locks the discussion." msgstr "" +msgid "Log in" +msgstr "" + msgid "Login with smartcard" msgstr "" @@ -19922,6 +19982,9 @@ msgstr "" msgid "Member since %{date}" msgstr "" +msgid "Member since:" +msgstr "" + msgid "MemberInviteEmail|%{member_name} invited you to join GitLab" msgstr "" @@ -23390,6 +23453,9 @@ msgstr "" msgid "Personal projects" msgstr "" +msgid "Personal projects limit:" +msgstr "" + msgid "Phabricator Server Import" msgstr "" @@ -24407,6 +24473,9 @@ msgstr "" msgid "Profile image guideline" msgstr "" +msgid "Profile page:" +msgstr "" + msgid "ProfileSession|on" msgstr "" @@ -24827,7 +24896,7 @@ msgstr "" msgid "Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group." msgstr "" -msgid "Project access token creation is disabled in this group. You can still use and manage existing tokens." +msgid "Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}" msgstr "" msgid "Project already deleted" @@ -26291,6 +26360,9 @@ msgstr "" msgid "Re-verification interval" msgstr "" +msgid "Reactivate this user" +msgstr "" + msgid "Read documentation" msgstr "" @@ -26700,6 +26772,9 @@ msgstr "" msgid "Remove runner" msgstr "" +msgid "Remove secondary email" +msgstr "" + msgid "Remove secondary node" msgstr "" @@ -28169,6 +28244,9 @@ msgstr "" msgid "Secondary" msgstr "" +msgid "Secondary email:" +msgstr "" + msgid "Seconds" msgstr "" @@ -29478,6 +29556,9 @@ msgstr "" msgid "Sign up was successful! Please confirm your email to sign in." msgstr "" +msgid "Sign-in count:" +msgstr "" + msgid "Sign-in page" msgstr "" @@ -32289,6 +32370,9 @@ msgstr "" msgid "This URL is already used for another link; duplicate URLs are not allowed" msgstr "" +msgid "This account has been locked" +msgstr "" + msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention." msgstr "" @@ -32631,6 +32715,9 @@ msgstr "" msgid "This only applies to repository indexing operations." msgstr "" +msgid "This option deletes the user and any contributions that would usually be moved to the %{link_to_ghost_user}. As well as the user's personal projects, groups owned solely by the user, and projects in them, will also be removed. Commits to other projects are unaffected." +msgstr "" + msgid "This option is only available on GitLab.com" msgstr "" @@ -32724,6 +32811,12 @@ msgstr "" msgid "This user does not have a pending request" msgstr "" +msgid "This user has an unconfirmed email address %{email}. You may force a confirmation." +msgstr "" + +msgid "This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account." +msgstr "" + msgid "This user has no active %{type}." msgstr "" @@ -32739,6 +32832,12 @@ msgstr "" msgid "This user has the %{access} role in the %{name} project." msgstr "" +msgid "This user is blocked" +msgstr "" + +msgid "This user is currently an owner in these groups:" +msgstr "" + msgid "This user is the author of this %{noteable}." msgstr "" @@ -33711,6 +33810,9 @@ msgstr "" msgid "Two-factor Authentication Recovery codes" msgstr "" +msgid "Two-factor Authentication:" +msgstr "" + msgid "Two-factor authentication" msgstr "" @@ -33972,6 +34074,9 @@ msgstr "" msgid "Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment." msgstr "" +msgid "Unlock user" +msgstr "" + msgid "Unlocked" msgstr "" @@ -34824,6 +34929,9 @@ msgstr "" msgid "Username or email" msgstr "" +msgid "Username:" +msgstr "" + msgid "Username: %{username}" msgstr "" @@ -36195,6 +36303,9 @@ msgstr "" msgid "You can also upload existing files from your computer using the instructions below." msgstr "" +msgid "You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}" +msgstr "" + msgid "You can always edit this later" msgstr "" @@ -36240,9 +36351,6 @@ msgstr "" msgid "You can find more information about GitLab subscriptions in %{subscriptions_doc_link}." msgstr "" -msgid "You can generate an access token scoped to this project for each application to use the GitLab API." -msgstr "" - msgid "You can get started by cloning the repository or start adding files to it with one of the following options." msgstr "" @@ -36378,6 +36486,9 @@ msgstr "" msgid "You do not have permissions to run the import." msgstr "" +msgid "You don't have access to delete this user." +msgstr "" + msgid "You don't have any U2F devices registered yet." msgstr "" @@ -36528,6 +36639,9 @@ msgstr "" msgid "You must solve the CAPTCHA in order to submit" msgstr "" +msgid "You must transfer ownership or delete these groups before you can delete this user." +msgstr "" + msgid "You must upload a file with the same file name when dropping onto an existing design." msgstr "" @@ -36771,6 +36885,9 @@ msgstr "" msgid "Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO." msgstr "" +msgid "Your action succeeded." +msgstr "" + msgid "Your applications (%{size})" msgstr "" @@ -38404,6 +38521,9 @@ msgstr "" msgid "suggestPipeline|We’re adding a GitLab CI configuration file to add a pipeline to the project. You could create it manually, but we recommend that you start with a GitLab template that works out of the box." msgstr "" +msgid "system ghost user" +msgstr "" + msgid "tag name" msgstr "" diff --git a/spec/features/action_cable_logging_spec.rb b/spec/features/action_cable_logging_spec.rb index ce7c0e03aad..2e6ce93f7f7 100644 --- a/spec/features/action_cable_logging_spec.rb +++ b/spec/features/action_cable_logging_spec.rb @@ -22,11 +22,7 @@ RSpec.describe 'ActionCable logging', :js do subscription_data = a_hash_including( remote_ip: '127.0.0.1', user_id: user.id, - username: user.username, - params: a_hash_including( - project_path: project.full_path, - iid: issue.iid.to_s - ) + username: user.username ) expect(ActiveSupport::Notifications).to receive(:instrument).with('subscribe.action_cable', subscription_data) diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 7c564d76f70..289088a3c87 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -119,6 +119,59 @@ RSpec.describe 'File blob', :js do end end + context 'when ref switch' do + def switch_ref_to(ref_name) + first('.qa-branches-select').click + + page.within '.project-refs-form' do + click_link ref_name + end + end + + it 'displays single highlighted line number of different ref' do + visit_blob('files/js/application.js', anchor: 'L1') + + switch_ref_to('feature') + + page.within '.blob-content' do + expect(find_by_id('LC1')[:class]).to include("hll") + end + end + + it 'displays multiple highlighted line numbers of different ref' do + visit_blob('files/js/application.js', anchor: 'L1-3') + + switch_ref_to('feature') + + page.within '.blob-content' do + expect(find_by_id('LC1')[:class]).to include("hll") + expect(find_by_id('LC2')[:class]).to include("hll") + expect(find_by_id('LC3')[:class]).to include("hll") + end + end + + it 'displays no highlighted number of different ref' do + Files::UpdateService.new( + project, + project.owner, + commit_message: 'Update', + start_branch: 'feature', + branch_name: 'feature', + file_path: 'files/js/application.js', + file_content: 'new content' + ).execute + + project.commit('feature').diffs.diff_files.first + + visit_blob('files/js/application.js', anchor: 'L3') + switch_ref_to('feature') + + page.within '.blob-content' do + expect(page).not_to have_css('.hll') + end + end + end + context 'visiting with a line number anchor' do before do visit_blob('files/markdown/ruby-style-guide.md', anchor: 'L1') diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb index 8083c851bb7..76d5d7308d1 100644 --- a/spec/features/projects/settings/access_tokens_spec.rb +++ b/spec/features/projects/settings/access_tokens_spec.rb @@ -99,7 +99,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do visit project_settings_access_tokens_path(personal_project) expect(page).to have_selector('#new_project_access_token') - expect(page).to have_text('You can generate an access token scoped to this project for each application to use the GitLab API.') + expect(page).to have_text('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.') end end diff --git a/spec/frontend/actioncable_link_spec.js b/spec/frontend/actioncable_link_spec.js new file mode 100644 index 00000000000..f3e3556f7bb --- /dev/null +++ b/spec/frontend/actioncable_link_spec.js @@ -0,0 +1,110 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; +import cable from '~/actioncable_consumer'; +import ActionCableLink from '~/actioncable_link'; + +// Mock uuids module for determinism +jest.mock('~/diffs/utils/uuids', () => ({ + uuids: () => ['testuuid'], +})); + +const TEST_OPERATION = { + query: gql` + query foo { + project { + id + } + } + `, + operationName: 'foo', + variables: [], +}; + +/** + * Create an observer that passes calls to the given spy. + * + * This helps us assert which calls were made in what order. + */ +const createSpyObserver = (spy) => ({ + next: (...args) => spy('next', ...args), + error: (...args) => spy('error', ...args), + complete: (...args) => spy('complete', ...args), +}); + +const notify = (...notifications) => { + notifications.forEach((data) => cable.subscriptions.notifyAll('received', data)); +}; + +const getSubscriptionCount = () => cable.subscriptions.subscriptions.length; + +describe('~/actioncable_link', () => { + let cableLink; + + beforeEach(() => { + jest.spyOn(cable.subscriptions, 'create'); + + cableLink = new ActionCableLink(); + }); + + describe('request', () => { + let subscription; + let spy; + + beforeEach(() => { + spy = jest.fn(); + subscription = cableLink.request(TEST_OPERATION).subscribe(createSpyObserver(spy)); + }); + + afterEach(() => { + subscription.unsubscribe(); + }); + + it('creates a subscription', () => { + expect(getSubscriptionCount()).toBe(1); + expect(cable.subscriptions.create).toHaveBeenCalledWith( + { + channel: 'GraphqlChannel', + nonce: 'testuuid', + ...TEST_OPERATION, + query: print(TEST_OPERATION.query), + }, + { received: expect.any(Function) }, + ); + }); + + it('when "unsubscribe", unsubscribes underlying cable subscription', () => { + subscription.unsubscribe(); + + expect(getSubscriptionCount()).toBe(0); + }); + + it('when receives data, triggers observer until no ".more"', () => { + notify( + { result: 'test result', more: true }, + { result: 'test result 2', more: true }, + { result: 'test result 3' }, + { result: 'test result 4' }, + ); + + expect(spy.mock.calls).toEqual([ + ['next', 'test result'], + ['next', 'test result 2'], + ['next', 'test result 3'], + ['complete'], + ]); + }); + + it('when receives errors, triggers observer', () => { + notify( + { result: 'test result', more: true }, + { result: 'test result 2', errors: ['boom!'], more: true }, + { result: 'test result 3' }, + ); + + expect(spy.mock.calls).toEqual([ + ['next', 'test result'], + ['error', ['boom!']], + ]); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js new file mode 100644 index 00000000000..93ebbc648fe --- /dev/null +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js @@ -0,0 +1,137 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; +import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; +import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + DEFAULT_SUCCESS, + LOAD_FAILURE_UNKNOWN, +} from '~/pipeline_editor/constants'; + +describe('Pipeline Editor messages', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(PipelineEditorMessages, { + propsData: props, + }); + }; + + const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert); + const findAlert = () => wrapper.findComponent(GlAlert); + + describe('success alert', () => { + it('shows a message for successful commit type', () => { + createComponent({ successType: COMMIT_SUCCESS, showSuccess: true }); + + expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]); + }); + + it('does not show alert when there is a successType but visibility is off', () => { + createComponent({ successType: COMMIT_SUCCESS, showSuccess: false }); + + expect(findAlert().exists()).toBe(false); + }); + + it('shows a success alert with default copy if `showSuccess` is true and the `successType` is not valid,', () => { + createComponent({ successType: 'random', showSuccess: true }); + + expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[DEFAULT_SUCCESS]); + }); + + it('emit `hide-success` event when clicking on the dismiss button', async () => { + const expectedEvent = 'hide-success'; + + createComponent({ successType: COMMIT_SUCCESS, showSuccess: true }); + expect(wrapper.emitted(expectedEvent)).not.toBeDefined(); + + await findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted(expectedEvent)).toBeDefined(); + }); + }); + + describe('failure alert', () => { + it.each` + failureType | message | expectedFailureType + ${COMMIT_FAILURE} | ${'failed commit'} | ${COMMIT_FAILURE} + ${LOAD_FAILURE_UNKNOWN} | ${'loading failure'} | ${LOAD_FAILURE_UNKNOWN} + ${'random'} | ${'error without a specified type'} | ${DEFAULT_FAILURE} + `('shows a message for $message', ({ failureType, expectedFailureType }) => { + createComponent({ failureType, showFailure: true }); + + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[expectedFailureType]); + }); + + it('show failure reasons when there are some', () => { + const failureReasons = ['There was a problem', 'ouppps']; + createComponent({ failureType: COMMIT_FAILURE, failureReasons, showFailure: true }); + + expect(wrapper.html()).toContain(failureReasons[0]); + expect(wrapper.html()).toContain(failureReasons[1]); + }); + + it('does not show a message for error with a disabled visibility', () => { + createComponent({ failureType: 'random', showFailure: false }); + + expect(findAlert().exists()).toBe(false); + }); + + it('emit `hide-failure` event when clicking on the dismiss button', async () => { + const expectedEvent = 'hide-failure'; + + createComponent({ failureType: COMMIT_FAILURE, showFailure: true }); + expect(wrapper.emitted(expectedEvent)).not.toBeDefined(); + + await findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted(expectedEvent)).toBeDefined(); + }); + }); + + describe('code snippet alert', () => { + const setCodeSnippetUrlParam = (value) => { + global.jsdom.reconfigure({ + url: `${TEST_HOST}/?code_snippet_copied_from=${value}`, + }); + }; + + it('does not show by default', () => { + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(false); + }); + + it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => { + jest.spyOn(window.history, 'replaceState'); + setCodeSnippetUrlParam(source); + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(true); + expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`); + }); + + it('does not show if URL param is invalid', () => { + setCodeSnippetUrlParam('foo_bar'); + createComponent(); + + expect(findCodeSnippetAlert().exists()).toBe(false); + }); + + it('disappears on dismiss', async () => { + setCodeSnippetUrlParam('api_fuzzing'); + createComponent(); + const alert = findCodeSnippetAlert(); + + expect(alert.exists()).toBe(true); + + await alert.vm.$emit('dismiss'); + + expect(alert.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index adb8c8836bc..b3cc1a1479e 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -2,17 +2,15 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; -import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; -import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; -import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants'; +import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; +import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; @@ -56,6 +54,7 @@ describe('Pipeline editor app component', () => { CommitForm, PipelineEditorHome, PipelineEditorTabs, + PipelineEditorMessages, EditorLite: MockEditorLite, PipelineEditorEmptyState, }, @@ -113,7 +112,6 @@ describe('Pipeline editor app component', () => { const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyStateButton = () => wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); - const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -133,48 +131,6 @@ describe('Pipeline editor app component', () => { }); }); - describe('code snippet alert', () => { - const setCodeSnippetUrlParam = (value) => { - global.jsdom.reconfigure({ - url: `${TEST_HOST}/?code_snippet_copied_from=${value}`, - }); - }; - - it('does not show by default', () => { - createComponent(); - - expect(findCodeSnippetAlert().exists()).toBe(false); - }); - - it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => { - jest.spyOn(window.history, 'replaceState'); - setCodeSnippetUrlParam(source); - createComponent(); - - expect(findCodeSnippetAlert().exists()).toBe(true); - expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`); - }); - - it('does not show if URL param is invalid', () => { - setCodeSnippetUrlParam('foo_bar'); - createComponent(); - - expect(findCodeSnippetAlert().exists()).toBe(false); - }); - - it('disappears on dismiss', async () => { - setCodeSnippetUrlParam('api_fuzzing'); - createComponent(); - const alert = findCodeSnippetAlert(); - - expect(alert.exists()).toBe(true); - - await alert.vm.$emit('dismiss'); - - expect(alert.exists()).toBe(false); - }); - }); - describe('when queries are called', () => { beforeEach(() => { mockBlobContentData.mockResolvedValue(mockCiYml); @@ -235,11 +191,14 @@ describe('Pipeline editor app component', () => { describe('because of a fetching error', () => { it('shows a unkown error message', async () => { + const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.'; + mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(false); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); + + expect(findAlert().text()).toBe(loadUnknownFailureText); expect(findEditorHome().exists()).toBe(true); }); }); @@ -273,6 +232,7 @@ describe('Pipeline editor app component', () => { describe('when the user commits', () => { const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; + const updateSuccessMessage = 'Your changes have been successfully committed.'; describe('and the commit mutation succeeds', () => { beforeEach(() => { @@ -283,7 +243,7 @@ describe('Pipeline editor app component', () => { }); it('shows a confirmation message', () => { - expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]); + expect(findAlert().text()).toBe(updateSuccessMessage); }); it('scrolls to the top of the page to bring attention to the confirmation message', () => { diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index f0a6fa40d67..8b579d1f8f9 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -1,41 +1,44 @@ -import ActionCable from '@rails/actioncable'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; -import { assigneesQueries } from '~/sidebar/constants'; +import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; -import Mock from './mock_data'; +import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; -jest.mock('@rails/actioncable', () => { - const mockConsumer = { - subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) }, - }; - return { - createConsumer: jest.fn().mockReturnValue(mockConsumer), - }; -}); +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Assignees Realtime', () => { let wrapper; let mediator; + let fakeApollo; + + const issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse); + const subscriptionInitialHandler = jest.fn().mockResolvedValue(subscriptionNullResponse); - const createComponent = (issuableType = 'issue') => { + const createComponent = ({ + issuableType = 'issue', + issuableId = 1, + subscriptionHandler = subscriptionInitialHandler, + } = {}) => { + fakeApollo = createMockApollo([ + [getIssueParticipantsQuery, issuableQueryHandler], + [issuableAssigneesSubscription, subscriptionHandler], + ]); wrapper = shallowMount(AssigneesRealtime, { propsData: { - issuableIid: '1', - mediator, - projectPath: 'path/to/project', issuableType, - }, - mocks: { - $apollo: { - query: assigneesQueries[issuableType].query, - queries: { - workspace: { - refetch: jest.fn(), - }, - }, + issuableId, + queryVariables: { + issuableIid: '1', + projectPath: 'path/to/project', }, + mediator, }, + apolloProvider: fakeApollo, + localVue, }); }; @@ -45,59 +48,24 @@ describe('Assignees Realtime', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; + fakeApollo = null; SidebarMediator.singleton = null; }); - describe('when handleFetchResult is called from smart query', () => { - it('sets assignees to the store', () => { - const data = { - workspace: { - issuable: { - assignees: { - nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], - }, - }, - }, - }; - const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }]; - createComponent(); + it('calls the query with correct variables', () => { + createComponent(); - wrapper.vm.handleFetchResult({ data }); - - expect(mediator.store.assignees).toEqual(expected); + expect(issuableQueryHandler).toHaveBeenCalledWith({ + issuableIid: '1', + projectPath: 'path/to/project', }); }); - describe('when mounted', () => { - it('calls create subscription', () => { - const cable = ActionCable.createConsumer(); - - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(cable.subscriptions.create).toHaveBeenCalledTimes(1); - expect(cable.subscriptions.create).toHaveBeenCalledWith( - { - channel: 'IssuesChannel', - iid: wrapper.props('issuableIid'), - project_path: wrapper.props('projectPath'), - }, - { received: wrapper.vm.received }, - ); - }); - }); - }); - - describe('when subscription is recieved', () => { - it('refetches the GraphQL project query', () => { - createComponent(); - - wrapper.vm.received({ event: 'updated' }); + it('calls the subscription with correct variable for issue', () => { + createComponent(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1); - }); + expect(subscriptionInitialHandler).toHaveBeenCalledWith({ + issuableId: 'gid://gitlab/Issue/1', }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 824f6d49c65..543bc1c128a 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -487,6 +487,9 @@ describe('Sidebar assignees widget', () => { it('when realtime feature flag is enabled', async () => { createComponent({ + props: { + issuableId: 1, + }, provide: { glFeatures: { realTimeIssueSidebar: true, diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 2a4858a6320..3bb41548941 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -401,4 +401,10 @@ export const updateIssueAssigneesMutationResponse = { }, }; +export const subscriptionNullResponse = { + data: { + issuableAssigneesUpdated: null, + }, +}; + export default mockData; diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js index e737b57e33d..dc121dcb897 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/sidebar_assignees_spec.js @@ -17,6 +17,7 @@ describe('sidebar assignees', () => { wrapper = shallowMount(SidebarAssignees, { propsData: { issuableIid: '1', + issuableId: 1, mediator, field: '', projectPath: 'projectPath', diff --git a/spec/models/wiki_page/meta_spec.rb b/spec/models/wiki_page/meta_spec.rb index 42ec98c3491..37a282657d9 100644 --- a/spec/models/wiki_page/meta_spec.rb +++ b/spec/models/wiki_page/meta_spec.rb @@ -42,7 +42,7 @@ RSpec.describe WikiPage::Meta do subject { described_class.find(meta.id) } let_it_be(:meta) do - described_class.create(title: generate(:wiki_page_title), project: project) + described_class.create!(title: generate(:wiki_page_title), project: project) end context 'there are no slugs' do @@ -183,7 +183,7 @@ RSpec.describe WikiPage::Meta do # an old slug that = canonical_slug different_slug = generate(:sluggified_title) create(:wiki_page_meta, project: project, canonical_slug: different_slug) - .slugs.create(slug: wiki_page.slug) + .slugs.create!(slug: wiki_page.slug) end shared_examples 'metadata examples' do |