diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-19 23:18:09 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-19 23:18:09 +0000 |
commit | 6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde (patch) | |
tree | dc4d20fe6064752c0bd323187252c77e0a89144b /app | |
parent | 9868dae7fc0655bd7ce4a6887d4e6d487690eeed (diff) | |
download | gitlab-ce-6ed4ec3e0b1340f96b7c043ef51d1b33bbe85fde.tar.gz |
Add latest changes from gitlab-org/gitlab@15-4-stable-eev15.4.0-rc42
Diffstat (limited to 'app')
1512 files changed, 20454 insertions, 9163 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue index 59f0e0dd17d..461b2dad479 100644 --- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -140,6 +140,7 @@ export default { <template #cell(action)="{ item: { revokePath } }"> <gl-button + v-if="revokePath" category="tertiary" :aria-label="$options.i18n.revokeButton" :data-confirm="modalMessage" diff --git a/app/assets/javascripts/access_tokens/components/constants.js b/app/assets/javascripts/access_tokens/components/constants.js index 84e50bc099f..9cd7cb5bb3a 100644 --- a/app/assets/javascripts/access_tokens/components/constants.js +++ b/app/assets/javascripts/access_tokens/components/constants.js @@ -12,8 +12,6 @@ export const FIELDS = [ key: 'name', label: __('Token name'), sortable: true, - tdClass: `gl-text-black-normal`, - thClass: `gl-text-black-normal`, }, { formatter(scopes) { @@ -22,40 +20,30 @@ export const FIELDS = [ key: 'scopes', label: __('Scopes'), sortable: true, - tdClass: `gl-text-black-normal`, - thClass: `gl-text-black-normal`, }, { key: 'createdAt', label: s__('AccessTokens|Created'), sortable: true, - tdClass: `gl-text-black-normal`, - thClass: `gl-text-black-normal`, }, { key: 'lastUsedAt', label: __('Last Used'), sortable: true, - tdClass: `gl-text-black-normal`, - thClass: `gl-text-black-normal`, }, { key: 'expiresAt', label: __('Expires'), sortable: true, - tdClass: `gl-text-black-normal`, - thClass: `gl-text-black-normal`, }, { key: 'role', label: __('Role'), - tdClass: `gl-text-black-normal`, - thClass: `gl-text-black-normal`, sortable: true, }, { key: 'action', label: __('Action'), - thClass: `gl-text-black-normal`, + tdClass: 'gl-py-3!', }, ]; diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue index 5516fd0daf6..38501d63d3a 100644 --- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue +++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue @@ -16,6 +16,16 @@ export default { import('ee_component/access_tokens/components/max_expiration_date_message.vue'), }, props: { + defaultDateOffset: { + type: Number, + required: false, + default: 30, + }, + description: { + type: String, + required: false, + default: null, + }, inputAttrs: { type: Object, required: false, @@ -33,9 +43,15 @@ export default { }, }, computed: { - in30Days() { - const today = new Date(); - return getDateInFuture(today, 30); + defaultDate() { + const defaultDate = getDateInFuture(new Date(), this.defaultDateOffset); + // The maximum date can be set by admins. If the maximum date is sooner + // than the default expiration date we use the maximum date as default + // expiration date. + if (this.maxDate && this.maxDate < defaultDate) { + return this.maxDate; + } + return defaultDate; }, }, }; @@ -47,7 +63,7 @@ export default { :target="null" :min-date="minDate" :max-date="maxDate" - :default-date="in30Days" + :default-date="defaultDate" show-clear-button :input-name="inputAttrs.name" :input-id="inputAttrs.id" @@ -55,7 +71,10 @@ export default { data-qa-selector="expiry_date_field" /> <template #description> - <max-expiration-date-message :max-date="maxDate" /> + <template v-if="description"> + {{ description }} + </template> + <max-expiration-date-message v-else :max-date="maxDate" /> </template> </gl-form-group> </template> diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue index e111ae91e5c..6b52bd84656 100644 --- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue +++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue @@ -42,7 +42,6 @@ export default { formInputGroupProps() { return { id: this.$options.tokenInputId, - class: 'qa-created-access-token', 'data-qa-selector': 'created_access_token_field', name: this.$options.tokenInputId, }; @@ -82,7 +81,14 @@ export default { this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO }); - this.form.reset(); + // Selectively reset all input fields except for the date picker and submit. + // The form token creation is not controlled by Vue. + this.form.querySelectorAll('input[type=text]:not([id$=expires_at])').forEach((el) => { + el.value = ''; + }); + this.form.querySelectorAll('input[type=checkbox]').forEach((el) => { + el.checked = false; + }); }, }, }; diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 9801aa08e28..f0c1b415157 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -61,7 +61,7 @@ export const initExpiresAtField = () => { } const { expiresAt: inputAttrs } = parseRailsFormFields(el); - const { minDate, maxDate } = el.dataset; + const { minDate, maxDate, defaultDateOffset, description } = el.dataset; return new Vue({ el, @@ -71,6 +71,8 @@ export const initExpiresAtField = () => { inputAttrs, minDate: minDate ? new Date(minDate) : undefined, maxDate: maxDate ? new Date(maxDate) : undefined, + defaultDateOffset: defaultDateOffset ? Number(defaultDateOffset) : undefined, + description, }, }); }, diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue new file mode 100644 index 00000000000..2f74b44625f --- /dev/null +++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue @@ -0,0 +1,52 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export default { + components: { + GlLink, + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + }, + i18n: { + fieldHelpText: s__( + 'AdminSettings|If no unit is written, it defaults to seconds. For example, these are all equivalent: %{oneDayInSeconds}, %{oneDayInHoursHumanReadable}, or %{oneDayHumanReadable}. Minimum value is two hours. %{linkStart}Learn more.%{linkEnd}', + ), + }, + computed: { + helpUrl() { + return helpPagePath('ci/runners/configure_runners', { + anchor: 'authentication-token-security', + }); + }, + }, +}; +</script> +<template> + <p> + {{ message }} + <gl-sprintf :message="$options.i18n.fieldHelpText"> + <template #oneDayInSeconds> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>86400</code> + </template> + <template #oneDayInHoursHumanReadable> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>24 hours</code> + </template> + <template #oneDayHumanReadable> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>1 day</code> + </template> + <template #link> + <gl-link :href="helpUrl" target="_blank">{{ __('Learn more.') }}</gl-link> + </template> + </gl-sprintf> + </p> +</template> diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue new file mode 100644 index 00000000000..371a26d2664 --- /dev/null +++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue @@ -0,0 +1,123 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue'; +import ExpirationIntervalDescription from './expiration_interval_description.vue'; + +export default { + components: { + ChronicDurationInput, + ExpirationIntervalDescription, + GlFormGroup, + }, + props: { + instanceRunnerExpirationInterval: { + type: Number, + required: false, + default: null, + }, + groupRunnerExpirationInterval: { + type: Number, + required: false, + default: null, + }, + projectRunnerExpirationInterval: { + type: Number, + required: false, + default: null, + }, + }, + data() { + return { + perInput: { + instance: { + value: this.instanceRunnerExpirationInterval, + valid: null, + feedback: '', + }, + group: { + value: this.groupRunnerExpirationInterval, + valid: null, + feedback: '', + }, + project: { + value: this.projectRunnerExpirationInterval, + valid: null, + feedback: '', + }, + }, + }; + }, + methods: { + updateValidity(obj, event) { + /* eslint-disable no-param-reassign */ + obj.valid = event.valid; + obj.feedback = event.feedback; + /* eslint-enable no-param-reassign */ + }, + }, + i18n: { + instanceRunnerTitle: s__('AdminSettings|Instance runners expiration'), + instanceRunnerDescription: s__( + 'AdminSettings|Set the expiration time of authentication tokens of newly registered instance runners. Authentication tokens are automatically reset at these intervals.', + ), + groupRunnerTitle: s__('AdminSettings|Group runners expiration'), + groupRunnerDescription: s__( + 'AdminSettings|Set the expiration time of authentication tokens of newly registered group runners.', + ), + projectRunnerTitle: s__('AdminSettings|Project runners expiration'), + projectRunnerDescription: s__( + 'AdminSettings|Set the expiration time of authentication tokens of newly registered project runners.', + ), + }, +}; +</script> +<template> + <div> + <gl-form-group + :label="$options.i18n.instanceRunnerTitle" + :invalid-feedback="perInput.instance.feedback" + :state="perInput.instance.valid" + > + <template #description> + <expiration-interval-description :message="$options.i18n.instanceRunnerDescription" /> + </template> + <chronic-duration-input + v-model="perInput.instance.value" + name="application_setting[runner_token_expiration_interval]" + :state="perInput.instance.valid" + @valid="updateValidity(perInput.instance, $event)" + /> + </gl-form-group> + <gl-form-group + :label="$options.i18n.groupRunnerTitle" + :invalid-feedback="perInput.group.feedback" + :state="perInput.group.valid" + > + <template #description> + <expiration-interval-description :message="$options.i18n.groupRunnerDescription" /> + </template> + <chronic-duration-input + v-model="perInput.group.value" + name="application_setting[group_runner_token_expiration_interval]" + :state="perInput.group.valid" + @valid="updateValidity(perInput.group, $event)" + /> + </gl-form-group> + <gl-form-group + :label="$options.i18n.projectRunnerTitle" + :invalid-feedback="perInput.project.feedback" + :state="perInput.project.valid" + > + <template #description> + <expiration-interval-description :message="$options.i18n.projectRunnerDescription" /> + </template> + <chronic-duration-input + v-model="perInput.project.value" + name="application_setting[project_runner_token_expiration_interval]" + :state="perInput.project.valid" + @valid="updateValidity(perInput.project, $event)" + /> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js new file mode 100644 index 00000000000..79d7ff0451a --- /dev/null +++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import { parseInterval } from '~/runner/utils'; +import ExpirationIntervals from './components/expiration_intervals.vue'; + +const initRunnerTokenExpirationIntervals = (selector = '#js-runner-token-expiration-intervals') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { + instanceRunnerTokenExpirationInterval, + groupRunnerTokenExpirationInterval, + projectRunnerTokenExpirationInterval, + } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(ExpirationIntervals, { + props: { + instanceRunnerExpirationInterval: parseInterval(instanceRunnerTokenExpirationInterval), + groupRunnerExpirationInterval: parseInterval(groupRunnerTokenExpirationInterval), + projectRunnerExpirationInterval: parseInterval(projectRunnerTokenExpirationInterval), + }, + }); + }, + }); +}; + +export default initRunnerTokenExpirationIntervals; diff --git a/app/assets/javascripts/admin/topics/components/merge_topics.vue b/app/assets/javascripts/admin/topics/components/merge_topics.vue new file mode 100644 index 00000000000..921b762bbef --- /dev/null +++ b/app/assets/javascripts/admin/topics/components/merge_topics.vue @@ -0,0 +1,141 @@ +<script> +import { GlAlert, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import csrf from '~/lib/utils/csrf'; +import TopicSelect from './topic_select.vue'; + +export default { + components: { + GlAlert, + GlButton, + GlModal, + GlSprintf, + TopicSelect, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['path'], + data() { + return { + sourceTopic: {}, + targetTopic: {}, + }; + }, + computed: { + sourceTopicId() { + return getIdFromGraphQLId(this.sourceTopic?.id); + }, + targetTopicId() { + return getIdFromGraphQLId(this.targetTopic?.id); + }, + validSelectedTopics() { + return ( + Object.keys(this.sourceTopic).length && + Object.keys(this.targetTopic).length && + this.sourceTopic !== this.targetTopic + ); + }, + actionPrimary() { + return { + text: __('Merge'), + attributes: { + variant: 'danger', + disabled: !this.validSelectedTopics, + }, + }; + }, + }, + methods: { + selectSourceTopic(topic) { + this.sourceTopic = topic; + }, + selectTargetTopic(topic) { + this.targetTopic = topic; + }, + mergeTopics() { + this.$refs.mergeForm.submit(); + }, + }, + i18n: { + title: s__('MergeTopics|Merge topics'), + body: s__( + 'MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic.', + ), + sourceTopic: s__('MergeTopics|Source topic'), + targetTopic: s__('MergeTopics|Target topic'), + warningTitle: s__('MergeTopics|Merging topics will cause the following:'), + warningBody: s__('MergeTopics|This action cannot be undone.'), + warningRemoveTopic: s__('MergeTopics|%{sourceTopic} will be removed'), + warningMoveProjects: s__('MergeTopics|All assigned projects will be moved to %{targetTopic}'), + }, + modal: { + id: 'merge-topics', + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, + csrf, +}; +</script> +<template> + <div class="gl-mr-3"> + <gl-button v-gl-modal="$options.modal.id" category="secondary">{{ + $options.i18n.title + }}</gl-button> + <gl-modal + :title="$options.i18n.title" + :action-primary="actionPrimary" + :action-secondary="$options.modal.actionSecondary" + :modal-id="$options.modal.id" + size="sm" + @primary="mergeTopics" + > + <p>{{ $options.i18n.body }}</p> + <topic-select + :selected-topic="sourceTopic" + :label-text="$options.i18n.sourceTopic" + @click="selectSourceTopic" + /> + <topic-select + :selected-topic="targetTopic" + :label-text="$options.i18n.targetTopic" + @click="selectTargetTopic" + /> + <gl-alert + v-if="validSelectedTopics" + :title="$options.i18n.warningTitle" + :dismissible="false" + variant="danger" + > + <ul> + <li> + <gl-sprintf :message="$options.i18n.warningRemoveTopic"> + <template #sourceTopic> + <strong>{{ sourceTopic.name }}</strong> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.warningMoveProjects"> + <template #targetTopic> + <strong>{{ targetTopic.name }}</strong> + </template> + </gl-sprintf> + </li> + </ul> + {{ $options.i18n.warningBody }} + </gl-alert> + <form ref="mergeForm" method="post" :action="path"> + <input type="hidden" name="_method" value="post" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <input type="hidden" name="source_topic_id" :value="sourceTopicId" /> + <input type="hidden" name="target_topic_id" :value="targetTopicId" /> + </form> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue new file mode 100644 index 00000000000..8bf5be1afd1 --- /dev/null +++ b/app/assets/javascripts/admin/topics/components/topic_select.vue @@ -0,0 +1,106 @@ +<script> +import { + GlAvatarLabeled, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; + +export default { + components: { + GlAvatarLabeled, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + }, + props: { + selectedTopic: { + type: Object, + required: false, + default: () => ({}), + }, + labelText: { + type: String, + required: false, + default: null, + }, + }, + apollo: { + topics: { + query: searchProjectTopics, + variables() { + return { + search: this.search, + }; + }, + update(data) { + return data.topics?.nodes || []; + }, + debounce: 250, + }, + }, + data() { + return { + topics: [], + search: '', + }; + }, + computed: { + loading() { + return this.$apollo.queries.topics.loading; + }, + isResultEmpty() { + return this.topics.length === 0; + }, + dropdownText() { + if (Object.keys(this.selectedTopic).length) { + return this.selectedTopic.name; + } + + return this.$options.i18n.dropdownText; + }, + }, + methods: { + selectTopic(topic) { + this.$emit('click', topic); + }, + }, + i18n: { + dropdownText: s__('TopicSelect|Select a topic'), + searchPlaceholder: s__('TopicSelect|Search topics'), + emptySearchResult: s__('TopicSelect|No matching results'), + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> + +<template> + <div> + <label v-if="labelText">{{ labelText }}</label> + <gl-dropdown block :text="dropdownText"> + <gl-search-box-by-type + v-model="search" + :is-loading="loading" + :placeholder="$options.i18n.searchPlaceholder" + /> + <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)"> + <gl-avatar-labeled + :label="topic.title" + :sub-label="topic.name" + :src="topic.avatarUrl" + :entity-name="topic.name" + :size="32" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + /> + </gl-dropdown-item> + <gl-dropdown-text v-if="isResultEmpty && !loading"> + <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> + </gl-dropdown-text> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js index 09e9b20f220..d81690e8f4c 100644 --- a/app/assets/javascripts/admin/topics/index.js +++ b/app/assets/javascripts/admin/topics/index.js @@ -1,7 +1,20 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import showToast from '~/vue_shared/plugins/global_toast'; import RemoveAvatar from './components/remove_avatar.vue'; +import MergeTopics from './components/merge_topics.vue'; -export default () => { +const toasts = document.querySelectorAll('.js-toast-message'); +toasts.forEach((toast) => showToast(toast.dataset.message)); + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initRemoveAvatar = () => { const el = document.querySelector('.js-remove-topic-avatar'); if (!el) { @@ -21,3 +34,20 @@ export default () => { }, }); }; + +export const initMergeTopics = () => { + const el = document.querySelector('.js-merge-topics'); + + if (!el) return false; + + const { path } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { path }, + render(createElement) { + return createElement(MergeTopics); + }, + }); +}; diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index 696e7f359d1..388d925196b 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -109,7 +109,7 @@ export default { v-for="template in templates" :key="template.key" data-qa-selector="incident_templates_item" - :is-check-item="true" + is-check-item :is-checked="isTemplateSelected(template.key)" @click="selectIssueTemplate(template.key)" > diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue index 7df66d1b2be..92ccac59057 100644 --- a/app/assets/javascripts/analytics/shared/components/daterange.vue +++ b/app/assets/javascripts/analytics/shared/components/daterange.vue @@ -1,13 +1,10 @@ <script> -import { GlDaterangePicker, GlSprintf } from '@gitlab/ui'; -import { getDayDifference } from '~/lib/utils/datetime_utility'; -import { __, sprintf } from '~/locale'; -import { OFFSET_DATE_BY_ONE } from '../constants'; +import { GlDaterangePicker } from '@gitlab/ui'; +import { n__, __, sprintf } from '~/locale'; export default { components: { GlDaterangePicker, - GlSprintf, }, props: { show: { @@ -69,9 +66,10 @@ export default { this.$emit('change', { startDate, endDate }); }, }, - numberOfDays() { - const dayDifference = getDayDifference(this.startDate, this.endDate); - return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference; + }, + methods: { + numberOfDays(daysSelected) { + return n__('1 day selected', '%d days selected', daysSelected); }, }, }; @@ -83,7 +81,7 @@ export default { > <gl-daterange-picker v-model="dateRange" - class="d-flex flex-column flex-lg-row" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row" :default-start-date="startDate" :default-end-date="endDate" :default-min-date="minDate" @@ -93,12 +91,12 @@ export default { :tooltip="maxDateRangeTooltip" theme="animate-picker" start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0" - end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center gl-mb-2 gl-lg-mb-0" + end-picker-class="js-daterange-picker-to gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-mb-2 gl-lg-mb-0" label-class="gl-mb-2 gl-lg-mb-0" > - <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)"> - <template #numberOfDays>{{ numberOfDays }}</template> - </gl-sprintf> + <template #default="{ daysSelected }"> + {{ numberOfDays(daysSelected) }} + </template> </gl-daterange-picker> </div> </template> diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js index e1bc59b36ef..c62736d55a8 100644 --- a/app/assets/javascripts/analytics/shared/constants.js +++ b/app/assets/javascripts/analytics/shared/constants.js @@ -1,8 +1,7 @@ -import { masks } from 'dateformat'; +import { masks } from '~/lib/dateformat'; import { s__ } from '~/locale'; export const DATE_RANGE_LIMIT = 180; -export const OFFSET_DATE_BY_ONE = 1; export const PROJECTS_PER_PAGE = 50; const { isoDate, mediumDate } = masks; diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js index 1887f2affc3..bc52e38fc81 100644 --- a/app/assets/javascripts/analytics/shared/utils.js +++ b/app/assets/javascripts/analytics/shared/utils.js @@ -1,5 +1,5 @@ -import dateFormat from 'dateformat'; import { hideFlash } from '~/flash'; +import dateFormat from '~/lib/dateformat'; import { slugify } from '~/lib/utils/text_utility'; import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { dateFormats } from './constants'; diff --git a/app/assets/javascripts/analytics/usage_trends/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js index 91907877ed6..9474d264363 100644 --- a/app/assets/javascripts/analytics/usage_trends/utils.js +++ b/app/assets/javascripts/analytics/usage_trends/utils.js @@ -1,5 +1,5 @@ -import { masks } from 'dateformat'; import { get } from 'lodash'; +import { masks } from '~/lib/dateformat'; import { formatDate } from '~/lib/utils/datetime_utility'; const { isoDate } = masks; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 0c870a89760..b02dd9321b3 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -47,6 +47,7 @@ const Api = { projectSharePath: '/api/:version/projects/:id/share', projectMilestonesPath: '/api/:version/projects/:id/milestones', projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', + projectCreateIssuePath: '/api/:version/projects/:id/issues', mergeRequestsPath: '/api/:version/merge_requests', groupLabelsPath: '/api/:version/groups/:namespace_path/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', diff --git a/app/assets/javascripts/api/harbor_registry.js b/app/assets/javascripts/api/harbor_registry.js new file mode 100644 index 00000000000..eb241342567 --- /dev/null +++ b/app/assets/javascripts/api/harbor_registry.js @@ -0,0 +1,49 @@ +import axios from '~/lib/utils/axios_utils'; +import { buildApiUrl } from '~/api/api_utils'; + +// the :request_path is loading API-like resources, not part of our REST API. +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82784#note_1077703806 +const HARBOR_REPOSITORIES_PATH = '/:request_path.json'; +const HARBOR_ARTIFACTS_PATH = '/:request_path/:repo_name/artifacts.json'; +const HARBOR_TAGS_PATH = '/:request_path/:repo_name/artifacts/:digest/tags.json'; + +export function getHarborRepositoriesList({ requestPath, limit, page, sort, search = '' }) { + const url = buildApiUrl(HARBOR_REPOSITORIES_PATH).replace('/:request_path', requestPath); + + return axios.get(url, { + params: { + limit, + page, + search, + sort, + }, + }); +} + +export function getHarborArtifacts({ requestPath, repoName, limit, page, sort, search = '' }) { + const url = buildApiUrl(HARBOR_ARTIFACTS_PATH) + .replace('/:request_path', requestPath) + .replace(':repo_name', repoName); + + return axios.get(url, { + params: { + limit, + page, + search, + sort, + }, + }); +} + +export function getHarborTags({ requestPath, repoName, digest, page }) { + const url = buildApiUrl(HARBOR_TAGS_PATH) + .replace('/:request_path', requestPath) + .replace(':repo_name', repoName) + .replace(':digest', digest); + + return axios.get(url, { + params: { + page, + }, + }); +} diff --git a/app/assets/javascripts/api/integrations_api.js b/app/assets/javascripts/api/integrations_api.js deleted file mode 100644 index 692aae21a4f..00000000000 --- a/app/assets/javascripts/api/integrations_api.js +++ /dev/null @@ -1,21 +0,0 @@ -import axios from '../lib/utils/axios_utils'; -import { buildApiUrl } from './api_utils'; - -const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions'; - -export function addJiraConnectSubscription(namespacePath, { jwt, accessToken }) { - const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH); - - return axios.post( - url, - { - jwt, - namespace_path: namespacePath, - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, // eslint-disable-line @gitlab/require-i18n-strings - }, - }, - ); -} diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index c362253f52e..c743b18d572 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -12,7 +12,6 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects'; const USER_POST_STATUS_PATH = '/api/:version/user/status'; const USER_FOLLOW_PATH = '/api/:version/users/:id/follow'; const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow'; -const CURRENT_USER_PATH = '/api/:version/user'; export function getUsers(query, options) { const url = buildApiUrl(USERS_PATH); @@ -82,8 +81,3 @@ export function unfollowUser(userId) { const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId)); return axios.post(url); } - -export function getCurrentUser(options) { - const url = buildApiUrl(CURRENT_USER_PATH); - return axios.get(url, { ...options }); -} diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 8381dcec9c3..5ab66acaf80 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -5,7 +5,7 @@ import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { constructor(field, key, fallbackKey, lockVersion) { this.field = field; - + this.type = this.field.prop('type'); this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); if (key.join != null) { key = key.join('/'); @@ -22,11 +22,12 @@ export default class Autosave { restore() { if (!this.isLocalStorageAvailable) return; if (!this.field.length) return; - const text = window.localStorage.getItem(this.key); const fallbackText = window.localStorage.getItem(this.fallbackKey); - if (text) { + if (this.type === 'checkbox') { + this.field.prop('checked', text || fallbackText); + } else if (text) { this.field.val(text); } else if (fallbackText) { this.field.val(fallbackText); @@ -49,17 +50,16 @@ export default class Autosave { save() { if (!this.field.length) return; + const value = this.type === 'checkbox' ? this.field.is(':checked') : this.field.val(); - const text = this.field.val(); - - if (this.isLocalStorageAvailable && text) { + if (this.isLocalStorageAvailable && value) { if (this.fallbackKey) { - window.localStorage.setItem(this.fallbackKey, text); + window.localStorage.setItem(this.fallbackKey, value); } if (this.lockVersion !== undefined) { window.localStorage.setItem(this.lockVersionKey, this.lockVersion); } - return window.localStorage.setItem(this.key, text); + return window.localStorage.setItem(this.key, value); } return this.reset(); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index a030797c698..a3ffb4df7b7 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -165,6 +165,7 @@ export class AwardsHandler { `; const targetEl = this.targetContainerEl ? this.targetContainerEl : document.body; + // eslint-disable-next-line no-unsanitized/method targetEl.insertAdjacentHTML('beforeend', emojiMenuMarkup); this.addRemainingEmojiMenuCategories(); @@ -198,6 +199,7 @@ export class AwardsHandler { emojisInCategory, ); requestAnimationFrame(() => { + // eslint-disable-next-line no-unsanitized/method emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup); resolve(); }); diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 0eb4e6e7709..71560c7de3a 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -67,6 +67,7 @@ export default { }, content() { const el = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property el.innerHTML = this.draft.note_html; return el.textContent; diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index 54b9953270b..acc3cbe10a0 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,7 +1,16 @@ <script> import $ from 'jquery'; -import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlLink } from '@gitlab/ui'; +import { + GlDropdown, + GlButton, + GlIcon, + GlForm, + GlFormGroup, + GlLink, + GlFormCheckbox, +} from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; +import { createAlert } from '~/flash'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; import Autosave from '~/autosave'; @@ -15,29 +24,46 @@ export default { GlForm, GlFormGroup, GlLink, + GlFormCheckbox, MarkdownField, + ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'), }, data() { return { isSubmitting: false, - note: '', + noteData: { + noteable_type: '', + noteable_id: '', + note: '', + approve: false, + approval_password: '', + }, }; }, computed: { ...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']), }, + watch: { + 'noteData.approve': function noteDataApproveWatch() { + setTimeout(() => { + this.repositionDropdown(); + }); + }, + }, mounted() { this.autosave = new Autosave( $(this.$refs.textarea), `submit_review_dropdown/${this.getNoteableData.id}`, ); + this.noteData.noteable_type = this.noteableType; + this.noteData.noteable_id = this.getNoteableData.id; // We override the Bootstrap Vue click outside behaviour // to allow for clicking in the autocomplete dropdowns // without this override the submit dropdown will close // whenever a item in the autocomplete dropdown is clicked - const originalClickOutHandler = this.$refs.dropdown.$refs.dropdown.clickOutHandler; - this.$refs.dropdown.$refs.dropdown.clickOutHandler = (e) => { + const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler; + this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => { if (!e.target.closest('.atwho-container')) { originalClickOutHandler(e); } @@ -45,26 +71,32 @@ export default { }, methods: { ...mapActions('batchComments', ['publishReview']), + repositionDropdown() { + this.$refs.submitDropdown?.$refs.dropdown?.updatePopper(); + }, async submitReview() { - const noteData = { - noteable_type: this.noteableType, - noteable_id: this.getNoteableData.id, - note: this.note, - }; - this.isSubmitting = true; - await this.publishReview(noteData); + try { + await this.publishReview(this.noteData); + + this.autosave.reset(); - this.autosave.reset(); + if (window.mrTabs && (this.noteData.note || this.noteData.approve)) { + if (this.noteData.note) { + window.location.hash = `note_${this.getCurrentUserLastNote.id}`; + } - if (window.mrTabs && this.note) { - window.location.hash = `note_${this.getCurrentUserLastNote.id}`; - window.mrTabs.tabShown('show'); + window.mrTabs.tabShown('show'); - setTimeout(() => - scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)), - ); + setTimeout(() => + scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)), + ); + } + } catch (e) { + if (e.data?.message) { + createAlert({ message: e.data.message, captureError: true }); + } } this.isSubmitting = false; @@ -79,8 +111,9 @@ export default { <template> <gl-dropdown - ref="dropdown" + ref="submitDropdown" right + dropup class="submit-review-dropdown" data-qa-selector="submit_review_dropdown" variant="info" @@ -110,7 +143,7 @@ export default { <markdown-field :is-submitting="isSubmitting" :add-spacing-classes="false" - :textarea-value="note" + :textarea-value="noteData.note" :markdown-preview-path="getNoteableData.preview_note_path" :markdown-docs-path="getNotesData.markdownDocsPath" :quick-actions-docs-path="getNotesData.quickActionsDocsPath" @@ -122,7 +155,7 @@ export default { <textarea id="review-note-body" ref="textarea" - v-model="note" + v-model="noteData.note" dir="auto" :disabled="isSubmitting" name="review[note]" @@ -139,6 +172,18 @@ export default { </div> </div> </gl-form-group> + <template v-if="getNoteableData.current_user.can_approve"> + <gl-form-checkbox v-model="noteData.approve" data-testid="approve_merge_request"> + {{ __('Approve merge request') }} + </gl-form-checkbox> + <approval-password + v-if="getNoteableData.require_password_to_approve" + v-show="noteData.approve" + v-model="noteData.approval_password" + class="gl-mt-3" + data-testid="approve_password" + /> + </template> <div class="gl-display-flex gl-justify-content-start gl-mt-5"> <gl-button :loading="isSubmitting" diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index a44b9827fe9..2b0aaa74e83 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -84,7 +84,11 @@ export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => { .publish(getters.getNotesData.draftsPublishPath, noteData) .then(() => dispatch('updateDiscussionsAfterPublish')) .then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS)) - .catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR)); + .catch((e) => { + commit(types.RECEIVE_PUBLISH_REVIEW_ERROR); + + throw e.response; + }); }; export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => { diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index 6d2a4c245cc..a653769b60f 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -22,6 +22,7 @@ class CopyCodeButton extends HTMLElement { 'data-clipboard-target': `pre#${this.for}`, }); + // eslint-disable-next-line no-unsanitized/property button.innerHTML = spriteIcon('copy-to-clipboard'); return button; diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index 07fd6dae76a..4b337dce8f3 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -102,8 +102,12 @@ export default function initCopyToClipboard() { * @param {HTMLElement} btnElement */ export function clickCopyToClipboardButton(btnElement) { - // Ensure the button has already been tooltip'd. - add([btnElement], { show: true }); + const { clipboardHandleTooltip = true } = btnElement.dataset; + + if (parseBoolean(clipboardHandleTooltip)) { + // Ensure the button has already been tooltip'd. + add([btnElement], { show: true }); + } btnElement.click(); } diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index af7aac4cf36..ac41af4df7a 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -91,6 +91,7 @@ class SafeMathRenderer { `; if (!wrapperElement.classList.contains('lazy-alert-shown')) { + // eslint-disable-next-line no-unsanitized/property wrapperElement.innerHTML = html; wrapperElement.append(codeElement); wrapperElement.classList.add('lazy-alert-shown'); @@ -111,6 +112,7 @@ class SafeMathRenderer { } try { + // eslint-disable-next-line no-unsanitized/property displayContainer.innerHTML = this.katex.renderToString(text, { displayMode: el.dataset.mathStyle === 'display', throwOnError: true, diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 82229b5aa8f..97ba9e15c0f 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -1,9 +1,11 @@ import $ from 'jquery'; +import ClipboardJS from 'clipboard'; import Mousetrap from 'mousetrap'; -import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import toast from '~/vue_shared/plugins/global_toast'; +import { s__ } from '~/locale'; import Sidebar from '~/right_sidebar'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { @@ -21,6 +23,15 @@ export default class ShortcutsIssuable extends Shortcuts { constructor() { super(); + this.inMemoryButton = document.createElement('button'); + this.clipboardInstance = new ClipboardJS(this.inMemoryButton); + this.clipboardInstance.on('success', () => { + toast(s__('GlobalShortcuts|Copied source branch name to clipboard.')); + }); + this.clipboardInstance.on('error', () => { + toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.')); + }); + Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () => ShortcutsIssuable.openSidebarDropdown('assignee'), ); @@ -32,7 +43,7 @@ export default class ShortcutsIssuable extends Shortcuts { ); Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText); Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue); - Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName); + Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), () => this.copyBranchName()); /** * We're attaching a global focus event listener on document for @@ -153,17 +164,14 @@ export default class ShortcutsIssuable extends Shortcuts { return false; } - static copyBranchName() { - // There are two buttons - one that is shown when the sidebar - // is expanded, and one that is shown when it's collapsed. - const allCopyBtns = Array.from(document.querySelectorAll('.js-source-branch-copy')); + async copyBranchName() { + const button = document.querySelector('.js-source-branch-copy'); + const branchName = button?.dataset.clipboardText; - // Select whichever button is currently visible so that - // the "Copied" tooltip is shown when a click is simulated. - const visibleBtn = allCopyBtns.find(isElementVisible); + if (branchName) { + this.inMemoryButton.dataset.clipboardText = branchName; - if (visibleBtn) { - clickCopyToClipboardButton(visibleBtn); + this.inMemoryButton.dispatchEvent(new CustomEvent('click')); } } } diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js index d4efe409fef..2831c37838b 100644 --- a/app/assets/javascripts/blob/3d_viewer/index.js +++ b/app/assets/javascripts/blob/3d_viewer/index.js @@ -1,11 +1,8 @@ -import OrbitControlsClass from 'three-orbit-controls'; -import STLLoaderClass from 'three-stl-loader'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'; import * as THREE from 'three/build/three.module'; import MeshObject from './mesh_object'; -const STLLoader = STLLoaderClass(THREE); -const OrbitControls = OrbitControlsClass(THREE); - export default class Renderer { constructor(container) { this.renderWrapper = this.render.bind(this); diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js index c55a9ca8926..5322dc00e86 100644 --- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js +++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js @@ -22,7 +22,7 @@ export default class MeshObject extends Mesh { if (this.geometry.boundingSphere.radius > 4) { const scale = 4 / this.geometry.boundingSphere.radius; - this.geometry.applyMatrix(new Matrix4().makeScale(scale, scale, scale)); + this.geometry.applyMatrix4(new Matrix4().makeScale(scale, scale, scale)); this.geometry.computeBoundingSphere(); this.position.x = -this.geometry.boundingSphere.center.x; diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue index d2a841c88f1..dc1a9cb865a 100644 --- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue +++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue @@ -1,11 +1,11 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import notebookLab from '~/notebook/index.vue'; +import NotebookLab from '~/notebook/index.vue'; export default { components: { - notebookLab, + NotebookLab, GlLoadingIcon, }, props: { @@ -66,7 +66,7 @@ export default { <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> - <notebook-lab v-if="!loading && !error" :notebook="json" code-css-class="code white" /> + <notebook-lab v-if="!loading && !error" :notebook="json" /> <p v-if="error" class="text-center"> <span v-if="loadError" ref="loadErrorMessage">{{ __('An error occurred while loading the file. Please try again later.') diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js index a92161bbc1b..bb29224cda2 100644 --- a/app/assets/javascripts/blob/sketch/index.js +++ b/app/assets/javascripts/blob/sketch/index.js @@ -1,5 +1,5 @@ import JSZip from 'jszip'; -import JSZipUtils from 'jszip-utils'; +import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; export default class SketchLoader { @@ -7,35 +7,28 @@ export default class SketchLoader { this.container = container; this.loadingIcon = this.container.querySelector('.js-loading-icon'); - this.load(); + this.load().catch(() => { + this.error(); + }); } - load() { - return this.getZipFile() - .then((data) => JSZip.loadAsync(data)) - .then((asyncResult) => asyncResult.files['previews/preview.png'].async('uint8array')) - .then((content) => { - const url = window.URL || window.webkitURL; - const blob = new Blob([new Uint8Array(content)], { - type: 'image/png', - }); - const previewUrl = url.createObjectURL(blob); + async load() { + const zipContents = await this.getZipContents(); + const previewContents = await zipContents.files['previews/preview.png'].async('uint8array'); + + const blob = new Blob([previewContents], { + type: 'image/png', + }); - this.render(previewUrl); - }) - .catch(this.error.bind(this)); + this.render(window.URL.createObjectURL(blob)); } - getZipFile() { - return new Promise((resolve, reject) => { - JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => { - if (err) { - reject(err); - } else { - resolve(data); - } - }); + async getZipContents() { + const { data } = await axios.get(this.container.dataset.endpoint, { + responseType: 'arraybuffer', }); + + return JSZip.loadAsync(data); } render(previewUrl) { diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index a0d4f7ef4f2..5ca3f131d99 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -45,6 +45,7 @@ const loadViewer = (viewerParam) => { viewer.dataset.loading = 'true'; return axios.get(url).then(({ data }) => { + // eslint-disable-next-line no-unsanitized/property viewer.innerHTML = data.html; window.requestIdleCallback(() => { diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 425de914c17..d73e1cc43b0 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -63,6 +63,7 @@ export default () => { const isMarkdown = editBlobForm.data('is-markdown'); const previewMarkdownPath = editBlobForm.data('previewMarkdownPath'); const commitButton = $('.js-commit-button'); + const commitButtonLoading = $('.js-commit-button-loading'); const cancelLink = $('#cancel-changes'); import('./edit_blob') @@ -88,6 +89,8 @@ export default () => { }); commitButton.on('click', () => { + commitButton.addClass('gl-display-none'); + commitButtonLoading.removeClass('gl-display-none'); window.onbeforeunload = null; }); diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index c4a2f83ab50..1899d42fa4d 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -102,7 +102,7 @@ export default { data-qa-selector="board_add_new_list" > <div - class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" > <h3 class="gl-font-size-h2 gl-px-5 gl-py-5 gl-m-0" data-testid="board-add-column-form-title"> {{ $options.i18n.newList }} diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue index b81edb4dfe6..3f8a596abd8 100644 --- a/app/assets/javascripts/boards/components/board_blocked_icon.vue +++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; -import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; @@ -10,10 +10,12 @@ export default { i18n: { issuableType: { [issuableTypes.issue]: __('issue'), + [issuableTypes.epic]: __('epic'), }, }, graphQLIdType: { [issuableTypes.issue]: TYPE_ISSUE, + [issuableTypes.epic]: TYPE_EPIC, }, referenceFormatter: { [issuableTypes.issue]: (r) => r.split('/')[1], @@ -40,7 +42,7 @@ export default { type: String, required: true, validator(value) { - return [issuableTypes.issue].includes(value); + return [issuableTypes.issue, issuableTypes.epic].includes(value); }, }, }, @@ -53,14 +55,21 @@ export default { return blockingIssuablesQueries[this.issuableType].query; }, variables() { + if (this.isEpic) { + return { + fullPath: this.item.group.fullPath, + iid: Number(this.item.iid), + }; + } return { id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id), }; }, update(data) { this.skip = true; + const issuable = this.isEpic ? data?.group?.issuable : data?.issuable; - return data?.issuable?.blockingIssuables?.nodes || []; + return issuable?.blockingIssuables?.nodes || []; }, error(error) { const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), { @@ -77,13 +86,16 @@ export default { }; }, computed: { + isEpic() { + return this.issuableType === issuableTypes.epic; + }, displayedIssuables() { const { defaultDisplayLimit, referenceFormatter } = this.$options; return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => { return { ...i, title: truncate(i.title, this.$options.textTruncateWidth), - reference: referenceFormatter[this.issuableType](i.reference), + reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference), }; }); }, @@ -106,6 +118,9 @@ export default { }, ); }, + blockIcon() { + return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked'; + }, glIconId() { return `blocked-icon-${this.uniqueId}`; }, @@ -153,8 +168,8 @@ export default { <gl-icon :id="glIconId" ref="icon" - name="issue-block" - class="issue-blocked-icon gl-mr-2 gl-cursor-pointer" + :name="blockIcon" + class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500" data-testid="issue-blocked-icon" @mouseenter="handleMouseEnter" /> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 3638fdd2ca5..44c16324950 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -30,6 +30,11 @@ export default { default: 0, required: false, }, + showWorkItemTypeIcon: { + type: Boolean, + default: false, + required: false, + }, }, computed: { ...mapState(['selectedBoardItems', 'activeId']), @@ -81,10 +86,10 @@ export default { data-qa-selector="board_card" :class="[ { - 'multi-select': multiSelectVisible, + 'multi-select gl-bg-blue-50 gl-border-blue-200': multiSelectVisible, 'gl-cursor-grab': isDraggable, 'is-disabled': isDisabled, - 'is-active': isActive, + 'is-active gl-bg-blue-50': isActive, 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading, }, colorClass, @@ -95,9 +100,15 @@ export default { :data-item-path="item.referencePath" :style="cardStyle" data-testid="board_card" - class="board-card gl-p-5 gl-rounded-base" + class="board-card gl-p-5 gl-rounded-base gl-line-height-normal gl-relative gl-mb-3" @click="toggleIssue($event)" > - <board-card-inner :list="list" :item="item" :update-filters="true" /> + <board-card-inner + :list="list" + :item="item" + :update-filters="true" + :index="index" + :show-work-item-type-icon="showWorkItemTypeIcon" + /> </li> </template> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 8dc521317cd..92a623d65d4 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -15,6 +15,8 @@ import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { ListType } from '../constants'; import eventHub from '../eventhub'; import BoardBlockedIcon from './board_blocked_icon.vue'; @@ -34,6 +36,10 @@ export default { IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), BoardBlockedIcon, GlSprintf, + BoardCardMoveToPosition, + WorkItemTypeIcon, + IssueHealthStatus: () => + import('ee_component/related_items_tree/components/issue_health_status.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -55,6 +61,15 @@ export default { required: false, default: false, }, + index: { + type: Number, + required: true, + }, + showWorkItemTypeIcon: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -202,7 +217,7 @@ export default { <template> <div> <div class="gl-display-flex" dir="auto"> - <h4 class="board-card-title gl-mb-0 gl-mt-0"> + <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word"> <board-blocked-icon v-if="item.blocked" :item="item" @@ -215,7 +230,7 @@ export default { v-gl-tooltip name="eye-slash" :title="__('Confidential')" - class="confidential-icon gl-mr-2" + class="confidential-icon gl-mr-2 gl-text-orange-500 gl-cursor-help" :aria-label="__('Confidential')" /> <gl-icon @@ -223,24 +238,25 @@ export default { v-gl-tooltip name="spam" :title="__('This issue is hidden because its author has been banned')" - class="gl-mr-2 hidden-icon" + class="gl-mr-2 hidden-icon gl-text-orange-500 gl-cursor-help" data-testid="hidden-icon" /> <a :href="item.path || item.webUrl || ''" :title="item.title" :class="{ 'gl-text-gray-400!': item.isLoading }" - class="js-no-trigger" + class="js-no-trigger gl-text-body gl-hover-text-gray-900" @mousemove.stop >{{ item.title }}</a > </h4> + <board-card-move-to-position :item="item" :list="list" :index="index" /> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> <template v-for="label in orderedLabels"> <gl-label :key="label.id" - class="js-no-trigger" + class="js-no-trigger gl-mt-2 gl-mr-2" :background-color="label.color" :title="label.title" :description="label.description" @@ -260,9 +276,14 @@ export default { <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> <span v-if="item.referencePath" - class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > + <work-item-type-icon + v-if="showWorkItemTypeIcon" + :work-item-type="item.type" + show-tooltip-on-hover + /> <tooltip-on-truncate v-if="showReferencePath" :title="itemReferencePath" @@ -321,7 +342,10 @@ export default { </p> </gl-tooltip> - <span ref="countBadge" class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3"> + <span + ref="countBadge" + class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3 gl-text-secondary gl-cursor-help" + > <span v-if="allowSubEpics" class="gl-mr-3"> <gl-icon name="epic" /> {{ totalEpicsCount }} @@ -339,7 +363,7 @@ export default { <span v-if="shouldRenderEpicProgress" ref="progressBadge" - class="board-card-info gl-pl-0" + class="board-card-info gl-pl-0 gl-text-secondary gl-cursor-help" > <span class="gl-mr-3" data-testid="epic-progress"> <gl-icon name="progress" /> @@ -359,10 +383,11 @@ export default { :weight="item.weight" @click="filterByWeight(item.weight)" /> + <issue-health-status v-if="item.healthStatus" :health-status="item.healthStatus" /> </span> </span> </div> - <div class="board-card-assignee gl-display-flex gl-gap-3"> + <div class="board-card-assignee gl-display-flex gl-gap-3 gl-mb-n2"> <user-avatar-link v-for="assignee in cappedAssignees" :key="assignee.id" @@ -370,7 +395,7 @@ export default { :img-alt="avatarUrlTitle(assignee)" :img-src="avatarUrl(assignee)" :img-size="avatarSize" - class="js-no-trigger" + class="js-no-trigger user-avatar-link" tooltip-placement="bottom" :enforce-gl-avatar="true" > @@ -384,7 +409,7 @@ export default { v-if="shouldRenderCounter" v-gl-tooltip :title="assigneeCounterTooltip" - class="avatar-counter" + class="avatar-counter gl-bg-gray-400 gl-cursor-help gl-font-weight-bold gl-ml-n4 gl-border-0 gl-line-height-24" data-placement="bottom" >{{ assigneeCounterLabel }}</span > diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue new file mode 100644 index 00000000000..ff938219475 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -0,0 +1,128 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; + +import Tracking from '~/tracking'; + +export default { + i18n: { + moveToStartText: s__('Boards|Move to start of list'), + moveToEndText: s__('Boards|Move to end of list'), + }, + name: 'BoardCardMoveToPosition', + components: { + GlDropdown, + GlDropdownItem, + }, + mixins: [Tracking.mixin()], + props: { + item: { + type: Object, + required: true, + validator: (item) => ['id', 'iid', 'referencePath'].every((key) => item[key]), + }, + list: { + type: Object, + required: false, + default: () => ({}), + }, + index: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['pageInfoByListId']), + ...mapGetters(['getBoardItemsByList']), + tracking() { + return { + category: 'boards:list', + label: 'move_to_position', + property: `type_card`, + }; + }, + listItems() { + return this.getBoardItemsByList(this.list.id); + }, + listHasNextPage() { + return this.pageInfoByListId[this.list.id]?.hasNextPage; + }, + lengthOfListItemsInBoard() { + return this.listItems?.length; + }, + itemIdentifier() { + return `${this.item.id}-${this.item.iid}-${this.index}`; + }, + isFirstItemInList() { + return this.index === 0; + }, + isLastItemInList() { + return this.index === this.lengthOfListItemsInBoard - 1; + }, + }, + methods: { + ...mapActions(['moveItem']), + moveToStart() { + this.track('click_toggle_button', { + label: 'move_to_start', + }); + /** in case it is the first in the list don't call any action/mutation * */ + if (this.isFirstItemInList) { + return; + } + this.moveToPosition({ + positionInList: 0, + }); + }, + moveToEnd() { + this.track('click_toggle_button', { + label: 'move_to_end', + }); + /** in case it is the last in the list don't call any action/mutation * */ + if (this.isLastItemInList) { + return; + } + this.moveToPosition({ + positionInList: -1, + }); + }, + moveToPosition({ positionInList }) { + this.moveItem({ + itemId: this.item.id, + itemIid: this.item.iid, + itemPath: this.item.referencePath, + fromListId: this.list.id, + toListId: this.list.id, + positionInList, + atIndex: this.index, + allItemsLoadedInList: !this.listHasNextPage, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + :key="itemIdentifier" + icon="ellipsis_v" + :text="s__('Boards|Move card')" + :text-sr-only="true" + class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3" + category="tertiary" + :tabindex="index" + no-caret + @keydown.esc.native="$emit('hide')" + > + <div> + <gl-dropdown-item @click.stop="moveToStart"> + {{ $options.i18n.moveToStartText }} + </gl-dropdown-item> + <gl-dropdown-item @click.stop="moveToEnd"> + {{ $options.i18n.moveToEndText }} + </gl-dropdown-item> + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index bcf5b12b209..8fc76c02e14 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -76,7 +76,7 @@ export default { <div :class="{ 'is-draggable': isListDraggable, - 'is-collapsed': list.collapsed, + 'is-collapsed gl-w-10': list.collapsed, 'board-type-assignee': list.listType === 'assignee', }" :data-list-id="list.id" @@ -84,7 +84,7 @@ export default { data-qa-selector="board_list" > <div - class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" :class="{ 'board-column-highlighted': highlighted }" > <board-list-header :list="list" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 8868b9b2f3e..d99afa8455d 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -75,7 +75,7 @@ export default { v-if="!isSwimlanesOn" ref="list" v-bind="draggableOptions" - class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap" + class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-scroll" @end="moveList" > <board-column diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index d25169b5b9d..00b4e6c96a9 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -57,6 +57,9 @@ export default { labelsFilterBasePath: { default: '', }, + canUpdate: { + default: false, + }, }, inheritAttrs: false, computed: { @@ -163,6 +166,7 @@ export default { :full-path="fullPath" :initial-assignees="activeBoardItem.assignees" :allow-multiple-assignees="multipleAssigneesFeatureAvailable" + :editable="canUpdate" @assignees-updated="setAssignees" /> <sidebar-dropdown-widget diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 66388f4eb43..edf1a5ee7e6 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -66,7 +66,7 @@ export default { }, }, computed: { - ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']), + ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']), ...mapGetters(['isEpicBoard']), listItemsCount() { return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount; @@ -132,6 +132,9 @@ export default { return this.canMoveIssue ? options : {}; }, + disableScrollingWhenMutationInProgress() { + return this.hasNextPage && this.isUpdateIssueOrderInProgress; + }, }, watch: { boardItems() { @@ -265,7 +268,7 @@ export default { <template> <div v-show="!list.collapsed" - class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column" + class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column gl-min-h-0" data-qa-selector="board_list_cards_area" > <div @@ -285,9 +288,13 @@ export default { v-bind="treeRootOptions" :data-board="list.id" :data-board-type="list.listType" - :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" + :class="{ + 'bg-danger-100': boardItemsSizeExceedsMax, + 'gl-overflow-hidden': disableScrollingWhenMutationInProgress, + 'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress, + }" draggable=".board-card" - class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0" + class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0 gl-overflow-x-hidden" data-testid="tree-root-wrapper" @start="handleDragOnStart" @end="handleDragOnEnd" @@ -301,9 +308,14 @@ export default { :item="item" :data-draggable-item-type="$options.draggableItemTypes.card" :disabled="disabled" + :show-work-item-type-icon="!isEpicBoard" /> <gl-intersection-observer @appear="onReachingListBottom"> - <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> + <li + v-if="showCount" + class="board-list-count gl-text-center gl-text-secondary gl-py-4" + data-issue-id="-1" + > <gl-loading-icon v-if="loadingMore" size="sm" diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index e3012f5b36d..230fa4e1e0f 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -252,7 +252,7 @@ export default { <header :class="{ 'gl-h-full': list.collapsed, - 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, + 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base gl-bg-gray-50': isSwimlanesHeader, }" :style="headerStyle" class="board-header gl-relative" @@ -267,14 +267,15 @@ export default { 'gl-py-2': list.collapsed && isSwimlanesHeader, 'gl-flex-direction-column': list.collapsed, }" - class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 gl-h-9" > <gl-button v-gl-tooltip.hover :aria-label="chevronTooltip" :title="chevronTooltip" :icon="chevronIcon" - class="board-title-caret no-drag gl-cursor-pointer" + class="board-title-caret no-drag gl-cursor-pointer gl-hover-bg-gray-50" + :class="{ 'gl-mt-1': list.collapsed, 'gl-mr-2': !list.collapsed }" category="tertiary" size="small" data-testid="board-title-caret" @@ -307,6 +308,7 @@ export default { 'gl-display-none': list.collapsed && isSwimlanesHeader, 'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed, 'gl-flex-grow-1': !list.collapsed, + 'gl-rotate-90': list.collapsed, }" > <!-- EE start --> @@ -324,7 +326,7 @@ export default { <span v-if="listType === 'assignee'" v-show="!list.collapsed" - class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + class="gl-ml-2 gl-font-weight-normal gl-text-secondary" > @{{ listAssignee }} </span> @@ -345,7 +347,7 @@ export default { v-if="isSwimlanesHeader && list.collapsed" ref="collapsedInfo" aria-hidden="true" - class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500" + class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-secondary gl-hover-text-gray-900" > <gl-icon name="information" /> </span> @@ -369,14 +371,14 @@ export default { <!-- EE end --> <div - class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-gray-500" + class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary" data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, 'gl-p-0': list.collapsed, }" > - <span class="gl-display-inline-flex"> + <span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }"> <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> <gl-icon class="gl-mr-2" :name="countIcon" :size="16" /> diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue index 600917683cd..084b7519d1f 100644 --- a/app/assets/javascripts/boards/components/board_new_item.vue +++ b/app/assets/javascripts/boards/components/board_new_item.vue @@ -69,7 +69,7 @@ export default { </script> <template> - <div class="board-new-issue-form"> + <div class="board-new-issue-form gl-z-index-3 gl-m-3"> <div class="board-card position-relative gl-p-5 rounded"> <gl-form @submit.prevent="handleFormSubmit" @reset="handleFormCancel"> <label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 73ec008c2b6..b09b1d48ca5 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -1,6 +1,6 @@ <script> import { GlTooltip, GlIcon } from '@gitlab/ui'; -import dateFormat from 'dateformat'; +import dateFormat from '~/lib/dateformat'; import { getDayDifference, getTimeago, @@ -85,7 +85,11 @@ export default { <template> <span> - <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> + <span + ref="issueDueDate" + :class="cssClass" + class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help" + > <gl-icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon gl-mr-2" diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 9312db06efe..bc12717a92d 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -36,7 +36,7 @@ export default { <template> <span> - <span ref="issueTimeEstimate" class="board-card-info card-number"> + <span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help"> <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" /> <time class="board-card-info-text">{{ timeEstimate }}</time> </span> diff --git a/app/assets/javascripts/boards/components/item_count.vue b/app/assets/javascripts/boards/components/item_count.vue index a11c23e5625..dab82abb646 100644 --- a/app/assets/javascripts/boards/components/item_count.vue +++ b/app/assets/javascripts/boards/components/item_count.vue @@ -30,7 +30,9 @@ export default { {{ itemsSize }} </span> <span v-if="isMaxLimitSet" class="max-issue-size"> - {{ maxIssueCount }} + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + {{ `/ ${maxIssueCount}` }} + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> </span> </div> </template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 0f290f566ba..ed22a375271 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql'; +import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; import updateBoardListMutation from './graphql/board_list_update.mutation.graphql'; @@ -70,6 +71,9 @@ export const blockingIssuablesQueries = { [issuableTypes.issue]: { query: boardBlockingIssuesQuery, }, + [issuableTypes.epic]: { + query: boardBlockingEpicsQuery, + }, }; export const updateListQueries = { @@ -146,3 +150,5 @@ export default { BoardType, ListType, }; + +export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10; diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js index 1745ab3bab4..a452d32ef15 100644 --- a/app/assets/javascripts/boards/filters/due_date_filters.js +++ b/app/assets/javascripts/boards/filters/due_date_filters.js @@ -1,5 +1,5 @@ -import dateFormat from 'dateformat'; import Vue from 'vue'; +import dateFormat from '~/lib/dateformat'; Vue.filter('due-date', (value) => { const date = new Date(value); diff --git a/app/assets/javascripts/boards/graphql/board.fragment.graphql b/app/assets/javascripts/boards/graphql/board.fragment.graphql deleted file mode 100644 index 872a4c4afbc..00000000000 --- a/app/assets/javascripts/boards/graphql/board.fragment.graphql +++ /dev/null @@ -1,4 +0,0 @@ -fragment BoardFragment on Board { - id - name -} diff --git a/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql new file mode 100644 index 00000000000..071a6d7410f --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql @@ -0,0 +1,17 @@ +query BoardBlockingEpics($fullPath: ID!, $iid: ID) { + group(fullPath: $fullPath) { + id + issuable: epic(iid: $iid) { + id + blockingIssuables: blockedByEpics { + nodes { + id + iid + title + reference(full: true) + webUrl + } + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql index 0823c4f5a83..ce9f7bbfd2a 100644 --- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql @@ -1,12 +1,11 @@ -#import "ee_else_ce/boards/graphql/board.fragment.graphql" - query group_boards($fullPath: ID!) { group(fullPath: $fullPath) { id boards { edges { node { - ...BoardFragment + id + name } } } diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql index 827c08486b1..b9fe778d4d4 100644 --- a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql @@ -1,12 +1,11 @@ -#import "ee_else_ce/boards/graphql/board.fragment.graphql" - query group_recent_boards($fullPath: ID!) { group(fullPath: $fullPath) { id recentIssueBoards { edges { node { - ...BoardFragment + id + name } } } diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql index b8879bc260c..770c246a95b 100644 --- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql @@ -1,12 +1,11 @@ -#import "ee_else_ce/boards/graphql/board.fragment.graphql" - query project_boards($fullPath: ID!) { project(fullPath: $fullPath) { id boards { edges { node { - ...BoardFragment + id + name } } } diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql index 4d38e9b0498..c633107a409 100644 --- a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql @@ -1,12 +1,11 @@ -#import "ee_else_ce/boards/graphql/board.fragment.graphql" - query project_recent_boards($fullPath: ID!) { project(fullPath: $fullPath) { id recentIssueBoards { edges { node { - ...BoardFragment + id + name } } } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 791182af806..c2e346da606 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -15,6 +15,7 @@ import { FilterFields, ListTypeTitles, DraggableItemTypes, + DEFAULT_BOARD_LIST_ITEMS_SIZE, } from 'ee_else_ce/boards/constants'; import { formatIssueInput, @@ -429,7 +430,7 @@ export default { filters: filterParams, isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, - first: 10, + first: DEFAULT_BOARD_LIST_ITEMS_SIZE, after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; @@ -478,16 +479,25 @@ export default { toListId, moveBeforeId, moveAfterId, + positionInList, + allItemsLoadedInList, } = moveData; commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + if (reordering && !allItemsLoadedInList && positionInList === -1) { + return; + } + if (reordering) { commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: toListId, moveBeforeId, moveAfterId, + positionInList, + atIndex: originalIndex, + allItemsLoadedInList, }); return; @@ -499,6 +509,7 @@ export default { listId: toListId, moveBeforeId, moveAfterId, + positionInList, }); } @@ -552,7 +563,15 @@ export default { updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => { try { - const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData; + const { + itemId, + fromListId, + toListId, + moveBeforeId, + moveAfterId, + itemNotInToList, + positionInList, + } = moveData; const { fullBoardId, filterParams, @@ -561,6 +580,8 @@ export default { }, } = state; + commit(types.MUTATE_ISSUE_IN_PROGRESS, true); + const { data } = await gqlClient.mutate({ mutation: issueMoveListMutation, variables: { @@ -571,6 +592,7 @@ export default { toListId: getIdFromGraphQLId(toListId), moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined, moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined, + positionInList, // 'mutationVariables' allows EE code to pass in extra parameters. ...mutationVariables, }, @@ -642,7 +664,9 @@ export default { } commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue }); + commit(types.MUTATE_ISSUE_IN_PROGRESS, false); } catch { + commit(types.MUTATE_ISSUE_IN_PROGRESS, false); commit( types.SET_ERROR, s__('Boards|An error occurred while moving the issue. Please try again.'), diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 43268f21f96..0e496677b7b 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -44,3 +44,4 @@ export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; export const SET_ERROR = 'SET_ERROR'; +export const MUTATE_ISSUE_IN_PROGRESS = 'MUTATE_ISSUE_IN_PROGRESS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 26a98a645b3..44abb2030c7 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -20,17 +20,28 @@ export const removeItemFromList = ({ state, listId, itemId }) => { updateListItemsCount({ state, listId, value: -1 }); }; -export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => { +export const addItemToList = ({ + state, + listId, + itemId, + moveBeforeId, + moveAfterId, + atIndex, + positionInList, +}) => { const listIssues = state.boardItemsByListId[listId]; let newIndex = atIndex || 0; + const moveToStartOrLast = positionInList !== undefined; if (moveBeforeId) { newIndex = listIssues.indexOf(moveBeforeId) + 1; } else if (moveAfterId) { newIndex = listIssues.indexOf(moveAfterId); + } else if (moveToStartOrLast) { + newIndex = positionInList === -1 ? listIssues.length : 0; } listIssues.splice(newIndex, 0, itemId); Vue.set(state.boardItemsByListId, listId, listIssues); - updateListItemsCount({ state, listId, value: 1 }); + updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 }); }; export default { @@ -205,12 +216,34 @@ export default { Vue.set(state.boardItems, issue.id, formatIssue(issue)); }, + [mutationTypes.MUTATE_ISSUE_IN_PROGRESS](state, isLoading) { + state.isUpdateIssueOrderInProgress = isLoading; + }, + [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( state, - { itemId, listId, moveBeforeId, moveAfterId, atIndex, inProgress = false }, + { + itemId, + listId, + moveBeforeId, + moveAfterId, + atIndex, + positionInList, + allItemsLoadedInList, + inProgress = false, + }, ) => { Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress }); - addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }); + addItemToList({ + state, + listId, + itemId, + moveBeforeId, + moveAfterId, + atIndex, + positionInList, + allItemsLoadedInList, + }); }, [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => { diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index b62c032b921..bf3f777ea7d 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -40,4 +40,5 @@ export default () => ({ }, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, + isUpdateIssueOrderInProgress: false, }); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue index 83bad9eb518..59ddf4b19d8 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue @@ -11,11 +11,11 @@ import { import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql'; import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql'; import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql'; -import ciVariableSettings from './ci_variable_settings.vue'; +import CiVariableSettings from './ci_variable_settings.vue'; export default { components: { - ciVariableSettings, + CiVariableSettings, }, inject: ['endpoint'], data() { diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue index 3af83ffa8ed..3522243e3e7 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue @@ -14,11 +14,11 @@ import { import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql'; -import ciVariableSettings from './ci_variable_settings.vue'; +import CiVariableSettings from './ci_variable_settings.vue'; export default { components: { - ciVariableSettings, + CiVariableSettings, }, mixins: [glFeatureFlagsMixin()], inject: ['endpoint', 'groupPath', 'groupId'], diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue new file mode 100644 index 00000000000..29db02a3c59 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue @@ -0,0 +1,120 @@ +<script> +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql'; +import getProjectVariables from '../graphql/queries/project_variables.query.graphql'; +import { mapEnvironmentNames } from '../utils'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + GRAPHQL_PROJECT_TYPE, + UPDATE_MUTATION_ACTION, + environmentFetchErrorText, + genericMutationErrorText, + variableFetchErrorText, +} from '../constants'; +import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql'; +import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql'; +import updateProjectVariable from '../graphql/mutations/project_update_variable.mutation.graphql'; +import CiVariableSettings from './ci_variable_settings.vue'; + +export default { + components: { + CiVariableSettings, + }, + inject: ['endpoint', 'projectFullPath', 'projectId'], + data() { + return { + projectEnvironments: [], + projectVariables: [], + }; + }, + apollo: { + projectEnvironments: { + query: getProjectEnvironments, + variables() { + return { + fullPath: this.projectFullPath, + }; + }, + update(data) { + return mapEnvironmentNames(data?.project?.environments?.nodes); + }, + error() { + createFlash({ message: environmentFetchErrorText }); + }, + }, + projectVariables: { + query: getProjectVariables, + variables() { + return { + fullPath: this.projectFullPath, + }; + }, + update(data) { + return data?.project?.ciVariables?.nodes || []; + }, + error() { + createFlash({ message: variableFetchErrorText }); + }, + }, + }, + computed: { + isLoading() { + return ( + this.$apollo.queries.projectVariables.loading || + this.$apollo.queries.projectEnvironments.loading + ); + }, + }, + methods: { + addVariable(variable) { + this.variableMutation(ADD_MUTATION_ACTION, variable); + }, + deleteVariable(variable) { + this.variableMutation(DELETE_MUTATION_ACTION, variable); + }, + updateVariable(variable) { + this.variableMutation(UPDATE_MUTATION_ACTION, variable); + }, + async variableMutation(mutationAction, variable) { + try { + const currentMutation = this.$options.mutationData[mutationAction]; + const { data } = await this.$apollo.mutate({ + mutation: currentMutation.action, + variables: { + endpoint: this.endpoint, + fullPath: this.projectFullPath, + projectId: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId), + variable, + }, + }); + + const { errors } = data[currentMutation.name]; + if (errors.length > 0) { + createFlash({ message: errors[0] }); + } + } catch (e) { + createFlash({ message: genericMutationErrorText }); + } + }, + }, + mutationData: { + [ADD_MUTATION_ACTION]: { action: addProjectVariable, name: 'addProjectVariable' }, + [UPDATE_MUTATION_ACTION]: { action: updateProjectVariable, name: 'updateProjectVariable' }, + [DELETE_MUTATION_ACTION]: { action: deleteProjectVariable, name: 'deleteProjectVariable' }, + }, +}; +</script> + +<template> + <ci-variable-settings + :are-scoped-variables-available="true" + :environments="projectEnvironments" + :is-loading="isLoading" + :variables="projectVariables" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @update-variable="updateVariable" + /> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 5ba63de8c96..56c1804910a 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -108,7 +108,6 @@ export default { return { newEnvironments: [], isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', - typeOptions: variableOptions, validationErrorEventProperty: '', variable: { ...defaultVariableState, ...this.selectedVariable }, }; @@ -259,6 +258,7 @@ export default { }, }, defaultScope: allEnvironments.text, + variableOptions, }; </script> @@ -277,6 +277,7 @@ export default { v-model="variable.key" :token-list="$options.tokenList" :label-text="__('Key')" + data-testid="pipeline-form-ci-variable-key" data-qa-selector="ci_variable_key_field" /> @@ -293,21 +294,26 @@ export default { :state="variableValidationState" rows="3" max-rows="6" + data-testid="pipeline-form-ci-variable-value" data-qa-selector="ci_variable_value_field" class="gl-font-monospace!" /> </gl-form-group> - <div class="d-flex"> - <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5"> + <div class="gl-display-flex"> + <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="gl-w-half gl-mr-5"> <gl-form-select id="ci-variable-type" v-model="variable.variableType" - :options="typeOptions" + :options="$options.variableOptions" /> </gl-form-group> - <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope"> + <gl-form-group + label-for="ci-variable-env" + class="gl-w-half" + data-testid="environment-scope" + > <template #label> {{ __('Environment scope') }} <gl-link @@ -380,7 +386,7 @@ export default { data-testid="aws-guidance-tip" @dismiss="dismissTip" > - <div class="gl-display-flex gl-flex-direction-row"> + <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap"> <div> <p> <gl-sprintf :message="$options.awsTipMessage"> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue index cebb7eb85ac..1fbe52388c9 100644 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue @@ -255,6 +255,7 @@ export default { v-model="key" :token-list="$options.tokenList" :label-text="__('Key')" + data-testid="pipeline-form-ci-variable-key" data-qa-selector="ci_variable_key_field" /> @@ -271,6 +272,7 @@ export default { :state="variableValidationState" rows="3" max-rows="6" + data-testid="pipeline-form-ci-variable-value" data-qa-selector="ci_variable_value_field" class="gl-font-monospace!" /> diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index 5d22974ffbb..e2dd28cdaa1 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -10,7 +10,7 @@ export const displayText = { }; export const variableTypes = { - variableType: 'ENV_VAR', + envType: 'ENV_VAR', fileType: 'FILE', }; @@ -29,13 +29,13 @@ export const allEnvironments = { export const variableText = { [types.variableType]: __('Variable'), [types.fileType]: __('File'), - [variableTypes.variableType]: __('Variable'), + [variableTypes.envType]: __('Variable'), [variableTypes.fileType]: __('File'), }; export const variableOptions = [ - { value: types.variableType, text: variableText[types.variableType] }, - { value: types.fileType, text: variableText[types.fileType] }, + { value: variableTypes.envType, text: variableText[variableTypes.envType] }, + { value: variableTypes.fileType, text: variableText[variableTypes.fileType] }, ]; export const defaultVariableState = { @@ -44,7 +44,7 @@ export const defaultVariableState = { masked: false, protected: false, value: '', - variableType: types.variableType, + variableType: variableTypes.envType, }; // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql new file mode 100644 index 00000000000..45109762e80 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql @@ -0,0 +1,3 @@ +mutation addProjectEnvironment($environment: CiEnvironment, $fullPath: ID!) { + addProjectEnvironment(environment: $environment, fullPath: $fullPath) @client +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql new file mode 100644 index 00000000000..ab3a46da854 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addProjectVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $projectId: ID! +) { + addProjectVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + projectId: $projectId + ) @client { + project { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiProjectVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql new file mode 100644 index 00000000000..e83dc9a5e5e --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteProjectVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $projectId: ID! +) { + deleteProjectVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + projectId: $projectId + ) @client { + project { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiProjectVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql new file mode 100644 index 00000000000..4788911431b --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateProjectVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $projectId: ID! +) { + updateProjectVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + projectId: $projectId + ) @client { + project { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiProjectVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql new file mode 100644 index 00000000000..921e0ca25b9 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql @@ -0,0 +1,11 @@ +query getProjectEnvironments($fullPath: ID!) { + project(fullPath: $fullPath) { + id + environments { + nodes { + id + name + } + } + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql new file mode 100644 index 00000000000..a60a50e4bc4 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql @@ -0,0 +1,15 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +query getProjectVariables($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciVariables { + nodes { + ...BaseCiVariable + environmentScope + masked + protected + } + } + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js index be7e3f88cfd..c041531ae30 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js +++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js @@ -4,9 +4,16 @@ import { convertObjectPropsToSnakeCase, } from '../../lib/utils/common_utils'; import { getIdFromGraphQLId } from '../../graphql_shared/utils'; -import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants'; -import getAdminVariables from './queries/variables.query.graphql'; +import { + GRAPHQL_GROUP_TYPE, + GRAPHQL_PROJECT_TYPE, + groupString, + instanceString, + projectString, +} from '../constants'; +import getProjectVariables from './queries/project_variables.query.graphql'; import getGroupVariables from './queries/group_variables.query.graphql'; +import getAdminVariables from './queries/variables.query.graphql'; const prepareVariableForApi = ({ variable, destroy = false }) => { return { @@ -28,6 +35,20 @@ const mapVariableTypes = (variables = [], kind) => { }); }; +const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => { + return { + errors, + project: { + __typename: GRAPHQL_PROJECT_TYPE, + id: projectId, + ciVariables: { + __typename: 'CiVariableConnection', + nodes: mapVariableTypes(data.variables, projectString), + }, + }, + }; +}; + const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => { return { errors, @@ -52,6 +73,28 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => { }; }; +const callProjectEndpoint = async ({ + endpoint, + fullPath, + variable, + projectId, + cache, + destroy = false, +}) => { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + return prepareProjectGraphQLResponse({ data, projectId }); + } catch (e) { + return prepareProjectGraphQLResponse({ + data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }), + projectId, + errors: [...e.response.data], + }); + } +}; + const callGroupEndpoint = async ({ endpoint, fullPath, @@ -91,6 +134,15 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) export const resolvers = { Mutation: { + addProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => { + return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache }); + }, + updateProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => { + return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache }); + }, + deleteProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => { + return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache, destroy: true }); + }, addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache }); }, diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index a74af8aed12..f5bdd4c7b1e 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import CiAdminVariables from './components/ci_admin_variables.vue'; import CiGroupVariables from './components/ci_group_variables.vue'; +import CiProjectVariables from './components/ci_project_variables.vue'; import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue'; import { resolvers } from './graphql/resolvers'; import createStore from './store'; @@ -37,6 +38,8 @@ const mountCiVariableListApp = (containerEl) => { if (parsedIsGroup) { component = CiGroupVariables; + } else if (parsedIsProject) { + component = CiProjectVariables; } Vue.use(VueApollo); @@ -77,7 +80,7 @@ const mountLegacyCiVariableListApp = (containerEl) => { const { endpoint, projectId, - group, + isGroup, maskableRegex, protectedByDefault, awsLogoSvgPath, @@ -89,13 +92,13 @@ const mountLegacyCiVariableListApp = (containerEl) => { maskedEnvironmentVariablesLink, environmentScopeLink, } = containerEl.dataset; - const isGroup = parseBoolean(group); + const parsedIsGroup = parseBoolean(isGroup); const isProtectedByDefault = parseBoolean(protectedByDefault); const store = createStore({ endpoint, projectId, - isGroup, + isGroup: parsedIsGroup, maskableRegex, isProtectedByDefault, awsLogoSvgPath, diff --git a/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue b/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue new file mode 100644 index 00000000000..59de6df1e49 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue @@ -0,0 +1,66 @@ +<script> +import { GlLink, GlIcon, GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + components: { + GlLink, + GlIcon, + GlBadge, + }, + mixins: [glFeatureFlagMixin()], + i18n: { + premiumTitle: s__('ClusterAgents|Premium'), + }, + props: { + text: { + required: true, + type: String, + }, + icon: { + required: false, + type: String, + default: 'information', + }, + iconClass: { + required: false, + type: String, + default: 'text-info', + }, + helpUrl: { + required: false, + type: String, + default: null, + }, + featureName: { + required: false, + type: String, + default: null, + }, + }, + computed: { + showPremiumBadge() { + return this.featureName && !this.glFeatures[this.featureName]; + }, + }, +}; +</script> + +<template> + <li class="gl-mb-3"> + <gl-icon :name="icon" :size="16" :class="iconClass" class="gl-mr-2" /> + + <gl-link v-if="helpUrl" :href="helpUrl">{{ text }}</gl-link> + <span v-else>{{ text }}</span> + + <gl-badge + v-if="showPremiumBadge" + size="md" + class="gl-ml-2 gl-vertical-align-middle" + icon="license" + variant="tier" + >{{ $options.i18n.premiumTitle }}</gl-badge + > + </li> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/integration_status.vue b/app/assets/javascripts/clusters/agents/components/integration_status.vue new file mode 100644 index 00000000000..68a77dfbc8e --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/integration_status.vue @@ -0,0 +1,98 @@ +<script> +import { GlCollapse, GlButton, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { AGENT_STATUSES } from '~/clusters_list/constants'; +import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util'; +import { + INTEGRATION_STATUS_VALID_TOKEN, + INTEGRATION_STATUS_NO_TOKEN, + INTEGRATION_STATUS_RESTRICTED_CI_CD, +} from '../constants'; +import AgentIntegrationStatusRow from './agent_integration_status_row.vue'; + +export default { + components: { + GlCollapse, + GlButton, + GlIcon, + AgentIntegrationStatusRow, + }, + i18n: { + title: s__('ClusterAgents|Integration Status'), + }, + AGENT_STATUSES, + props: { + tokens: { + required: true, + type: Array, + }, + }, + data() { + return { + isVisible: false, + }; + }, + computed: { + chevronIcon() { + return this.isVisible ? 'chevron-down' : 'chevron-right'; + }, + agentStatus() { + const lastContact = getAgentLastContact(this.tokens); + return getAgentStatus(lastContact); + }, + integrationStatuses() { + const statuses = []; + + if (this.agentStatus === 'active') { + statuses.push(INTEGRATION_STATUS_VALID_TOKEN); + } + + if (!this.tokens.length) { + statuses.push(INTEGRATION_STATUS_NO_TOKEN); + } + + statuses.push(INTEGRATION_STATUS_RESTRICTED_CI_CD); + + return statuses; + }, + }, + methods: { + toggleCollapse() { + this.isVisible = !this.isVisible; + }, + }, +}; +</script> + +<template> + <div> + <gl-button + :icon="chevronIcon" + variant="link" + size="small" + class="gl-mr-3" + @click="toggleCollapse" + > + {{ $options.i18n.title }} </gl-button + ><span data-testid="agent-status"> + <gl-icon + :name="$options.AGENT_STATUSES[agentStatus].icon" + :class="$options.AGENT_STATUSES[agentStatus].class" + class="gl-mr-2" + />{{ $options.AGENT_STATUSES[agentStatus].name }} + </span> + <gl-collapse v-model="isVisible" class="gl-ml-5 gl-mt-5"> + <ul class="gl-list-style-none gl-pl-2 gl-mb-0"> + <agent-integration-status-row + v-for="(status, index) in integrationStatuses" + :key="index" + :icon="status.icon" + :icon-class="status.iconClass" + :text="status.text" + :help-url="status.helpUrl" + :feature-name="status.featureName" + /> + </ul> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index e3de8339325..f1bd36b4a63 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -14,6 +14,7 @@ import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants'; import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; import TokenTable from './token_table.vue'; import ActivityEvents from './activity_events_list.vue'; +import IntegrationStatus from './integration_status.vue'; export default { i18n: { @@ -51,6 +52,7 @@ export default { TimeAgoTooltip, TokenTable, ActivityEvents, + IntegrationStatus, }, inject: ['agentName', 'projectPath'], data() { @@ -105,11 +107,11 @@ export default { <template> <section> - <h2>{{ agentName }}</h2> + <h1>{{ agentName }}</h1> <gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" /> - <div v-else-if="clusterAgent"> + <template v-else-if="clusterAgent"> <p data-testid="cluster-agent-create-info"> <gl-sprintf :message="$options.i18n.installedInfo"> <template #name> @@ -122,7 +124,16 @@ export default { </gl-sprintf> </p> - <gl-tabs sync-active-tab-with-query-params lazy> + <integration-status + :tokens="tokens" + class="gl-py-5 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100" + /> + + <gl-tabs + sync-active-tab-with-query-params + lazy + class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100" + > <gl-tab :title="$options.i18n.activity" query-param-value="activity"> <activity-events :agent-name="agentName" :project-path="projectPath" /> </gl-tab> @@ -151,7 +162,7 @@ export default { </div> </gl-tab> </gl-tabs> - </div> + </template> <gl-alert v-else variant="danger" :dismissible="false"> {{ $options.i18n.loadingError }} diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue index f74d66f6b8f..667d10e1753 100644 --- a/app/assets/javascripts/clusters/agents/components/token_table.vue +++ b/app/assets/javascripts/clusters/agents/components/token_table.vue @@ -44,36 +44,43 @@ export default { }, computed: { fields() { + const tdClass = 'gl-vertical-align-middle!'; return [ { key: 'name', label: this.$options.i18n.name, tdAttr: { 'data-testid': 'agent-token-name' }, + tdClass, }, { key: 'lastUsed', label: this.$options.i18n.lastUsed, tdAttr: { 'data-testid': 'agent-token-used' }, + tdClass, }, { key: 'createdAt', label: this.$options.i18n.dateCreated, tdAttr: { 'data-testid': 'agent-token-created-time' }, + tdClass, }, { key: 'createdBy', label: this.$options.i18n.createdBy, tdAttr: { 'data-testid': 'agent-token-created-user' }, + tdClass, }, { key: 'description', label: this.$options.i18n.description, tdAttr: { 'data-testid': 'agent-token-description' }, + tdClass, }, { key: 'actions', label: '', tdAttr: { 'data-testid': 'agent-token-revoke' }, + tdClass, }, ]; }, diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index 962fa243903..76af552181f 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const MAX_LIST_COUNT = 25; @@ -46,3 +47,24 @@ export const EVENT_ACTIONS_CLICK = 'click_button'; export const TOKEN_NAME_LIMIT = 255; export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}'; + +export const INTEGRATION_STATUS_VALID_TOKEN = { + icon: 'status-success', + iconClass: 'text-success-500', + text: s__('ClusterAgents|Valid access token'), +}; +export const INTEGRATION_STATUS_NO_TOKEN = { + icon: 'status-alert', + iconClass: 'text-danger-500', + text: s__('ClusterAgents|No agent access token'), +}; + +export const INTEGRATION_STATUS_RESTRICTED_CI_CD = { + icon: 'information', + iconClass: 'text-info', + text: s__('ClusterAgents|CI/CD workflow with restricted access'), + helpUrl: helpPagePath('user/clusters/agent/ci_cd_workflow', { + anchor: 'restrict-project-and-group-access-by-using-impersonation', + }), + featureName: 'clusterAgentsCiImpersonation', +}; diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js index e2d01723dde..ee36a295513 100644 --- a/app/assets/javascripts/clusters_list/clusters_util.js +++ b/app/assets/javascripts/clusters_list/clusters_util.js @@ -1,3 +1,5 @@ +import { ACTIVE_CONNECTION_TIME } from './constants'; + export function generateAgentRegistrationCommand({ name, token, version, address }) { return `helm repo add gitlab https://charts.gitlab.io helm repo update @@ -12,3 +14,24 @@ helm upgrade --install ${name} gitlab/gitlab-agent \\ export function getAgentConfigPath(clusterAgentName) { return `.gitlab/agents/${clusterAgentName}`; } + +export function getAgentLastContact(tokens = []) { + let lastContact = null; + tokens.forEach((token) => { + const lastContactToDate = new Date(token.lastUsedAt).getTime(); + if (lastContactToDate > lastContact) { + lastContact = lastContactToDate; + } + }); + return lastContact; +} + +export function getAgentStatus(lastContact) { + if (lastContact) { + const now = new Date().getTime(); + const diff = now - lastContact; + + return diff >= ACTIVE_CONNECTION_TIME ? 'inactive' : 'active'; + } + return 'unused'; +} diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 8a4a81d3e96..36f0f8e61ba 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -3,13 +3,9 @@ import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { - MAX_LIST_COUNT, - ACTIVE_CONNECTION_TIME, - AGENT_FEEDBACK_ISSUE, - AGENT_FEEDBACK_KEY, -} from '../constants'; +import { MAX_LIST_COUNT, AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; +import { getAgentLastContact, getAgentStatus } from '../clusters_util'; import AgentEmptyState from './agent_empty_state.vue'; import AgentTable from './agent_table.vue'; @@ -88,8 +84,8 @@ export default { if (list) { list = list.map((agent) => { const configFolder = this.folderList[agent.name]; - const lastContact = this.getLastContact(agent); - const status = this.getStatus(lastContact); + const lastContact = getAgentLastContact(agent?.tokens?.nodes); + const status = getAgentStatus(lastContact); return { ...agent, configFolder, lastContact, status }; }); } @@ -141,28 +137,6 @@ export default { }); } }, - getLastContact(agent) { - const tokens = agent?.tokens?.nodes; - let lastContact = null; - if (tokens?.length) { - tokens.forEach((token) => { - const lastContactToDate = new Date(token.lastUsedAt).getTime(); - if (lastContactToDate > lastContact) { - lastContact = lastContactToDate; - } - }); - } - return lastContact; - }, - getStatus(lastContact) { - if (lastContact) { - const now = new Date().getTime(); - const diff = now - lastContact; - - return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active'; - } - return 'unused'; - }, emitAgentsLoaded() { const count = this.agents?.project?.clusterAgents?.count; this.$emit('onAgentsLoad', count); diff --git a/app/assets/javascripts/code_navigation/utils/dom_utils.js b/app/assets/javascripts/code_navigation/utils/dom_utils.js index 1a65c1a64a2..90af31b715c 100644 --- a/app/assets/javascripts/code_navigation/utils/dom_utils.js +++ b/app/assets/javascripts/code_navigation/utils/dom_utils.js @@ -23,6 +23,7 @@ const wrapTextWithSpan = (el, text) => { const wrapNodes = (text) => { const wrapper = createSpan(); + // eslint-disable-next-line no-unsanitized/property wrapper.innerHTML = wrapSpacesWithSpans(text); wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text)); return wrapper.childNodes; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 95ee3a0d90e..6890d7f6f44 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -293,7 +293,7 @@ export default { </div> <gl-modal - v-if="canRenderPipelineButton" + v-if="canRenderPipelineButton || shouldRenderEmptyState" :id="modalId" ref="modal" :modal-id="modalId" diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index 6bb654a434f..9cb7cd9607f 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -40,7 +40,7 @@ export default { <gl-dropdown-item v-for="project in projects" :key="project.id" - :is-check-item="true" + is-check-item :is-checked="project.id === selectedProject.id" @click="selectProject(project)" > diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue new file mode 100644 index 00000000000..3891274e35e --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue @@ -0,0 +1,60 @@ +<script> +import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'; + +export default { + name: 'BubbleMenu', + inject: ['tiptapEditor'], + props: { + pluginKey: { + type: String, + required: true, + }, + shouldShow: { + type: Function, + required: true, + }, + tippyOptions: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + menuVisible: false, + }; + }, + async mounted() { + await this.$nextTick(); + + this.tiptapEditor.registerPlugin( + BubbleMenuPlugin({ + pluginKey: this.pluginKey, + editor: this.tiptapEditor, + element: this.$el, + shouldShow: this.shouldShow, + tippyOptions: { + ...this.tippyOptions, + onShow: (...args) => { + this.$emit('show', ...args); + this.menuVisible = true; + }, + onHidden: (...args) => { + this.$emit('hidden', ...args); + this.menuVisible = false; + }, + }, + }), + ); + }, + + beforeDestroy() { + this.tiptapEditor.unregisterPlugin(this.pluginKey); + }, +}; +</script> +<template> + <div> + <slot v-if="menuVisible"></slot> + </div> +</template> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue index 6c0ac8e54d2..a9668ebdb69 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue @@ -10,13 +10,13 @@ import { GlSearchBoxByType, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { BubbleMenu } from '@tiptap/vue-2'; import { getParentByTagName } from '~/lib/utils/dom_utils'; import codeBlockLanguageLoader from '../../services/code_block_language_loader'; import CodeBlockHighlight from '../../extensions/code_block_highlight'; import Diagram from '../../extensions/diagram'; import Frontmatter from '../../extensions/frontmatter'; import EditorStateObserver from '../editor_state_observer.vue'; +import BubbleMenu from './bubble_menu.vue'; const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; @@ -129,6 +129,10 @@ export default { deleteCodeBlock() { this.tiptapEditor.chain().focus().deleteNode(this.codeBlockType).run(); }, + + tippyOptions() { + return { getReferenceClientRect: this.getReferenceClientRect.bind(this) }; + }, }, }; </script> @@ -136,12 +140,9 @@ export default { <bubble-menu data-testid="code-block-bubble-menu" class="gl-shadow gl-rounded-base gl-bg-white" - :editor="tiptapEditor" plugin-key="bubbleMenuCodeBlock" :should-show="shouldShow" - :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - getReferenceClientRect, - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :tippy-options="tippyOptions()" > <editor-state-observer @transaction="updateCodeBlockInfoToState"> <gl-button-group> @@ -181,7 +182,7 @@ export default { </template> <template v-if="!showCustomLanguageInput" #highlighted-items> - <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true"> + <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item is-checked> {{ selectedLanguage.label }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue index 05ca7fd75c3..327b0967229 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue @@ -1,6 +1,5 @@ <script> import { GlButtonGroup } from '@gitlab/ui'; -import { BubbleMenu } from '@tiptap/vue-2'; import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants'; import trackUIControl from '../../services/track_ui_control'; import Paragraph from '../../extensions/paragraph'; @@ -9,6 +8,7 @@ import Audio from '../../extensions/audio'; import Video from '../../extensions/video'; import Image from '../../extensions/image'; import ToolbarButton from '../toolbar_button.vue'; +import BubbleMenu from './bubble_menu.vue'; export default { components: { @@ -34,14 +34,17 @@ export default { ); }, }, + toggleLinkCommandParams: { + href: '', + }, }; </script> <template> <bubble-menu data-testid="formatting-bubble-menu" class="gl-shadow gl-rounded-base gl-bg-white" - :editor="tiptapEditor" :should-show="shouldShow" + :plugin-key="'formatting'" > <gl-button-group> <toolbar-button @@ -109,9 +112,7 @@ export default { content-type="link" icon-name="link" editor-command="toggleLink" - :editor-command-params="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - href: '', - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :editor-command-params="$options.toggleLinkCommandParams" category="tertiary" size="medium" :label="__('Insert link')" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue index dae0bc63b5a..a4713eb3275 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue @@ -8,9 +8,9 @@ import { GlButtonGroup, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { BubbleMenu } from '@tiptap/vue-2'; import Link from '../../extensions/link'; import EditorStateObserver from '../editor_state_observer.vue'; +import BubbleMenu from './bubble_menu.vue'; export default { components: { @@ -36,18 +36,9 @@ export default { isEditing: false, }; }, - watch: { - linkCanonicalSrc(value) { - if (!value) this.isEditing = true; - }, - }, methods: { shouldShow() { - const shouldShow = this.tiptapEditor.isActive(Link.name); - - if (!shouldShow) this.isEditing = false; - - return shouldShow; + return this.tiptapEditor.isActive(Link.name); }, startEditingLink() { @@ -92,13 +83,23 @@ export default { }, updateLinkToState() { - if (!this.tiptapEditor.isActive(Link.name)) return; + const editor = this.tiptapEditor; + + const { href, title, canonicalSrc } = editor.getAttributes(Link.name); - const { href, title, canonicalSrc } = this.tiptapEditor.getAttributes(Link.name); + if ( + canonicalSrc === this.linkCanonicalSrc && + href === this.linkHref && + title === this.linkTitle + ) { + return; + } this.linkTitle = title; this.linkHref = href; this.linkCanonicalSrc = canonicalSrc || href; + + this.isEditing = !this.linkCanonicalSrc; }, copyLinkHref() { @@ -108,6 +109,15 @@ export default { removeLink() { this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run(); }, + + resetBubbleMenuState() { + this.linkTitle = undefined; + this.linkHref = undefined; + this.linkCanonicalSrc = undefined; + }, + }, + tippyOptions: { + placement: 'bottom', }, }; </script> @@ -115,14 +125,13 @@ export default { <bubble-menu data-testid="link-bubble-menu" class="gl-shadow gl-rounded-base gl-bg-white" - :editor="tiptapEditor" plugin-key="bubbleMenuLink" - :should-show="() => shouldShow()" - :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - placement: 'bottom', - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :should-show="shouldShow" + :tippy-options="$options.tippyOptions" + @show="updateLinkToState" + @hidden="resetBubbleMenuState" > - <editor-state-observer @transaction="updateLinkToState"> + <editor-state-observer @selectionUpdate="updateLinkToState"> <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> <gl-link v-gl-tooltip diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue index a36a860c440..310bb1be81f 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue @@ -9,13 +9,13 @@ import { GlButtonGroup, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { BubbleMenu } from '@tiptap/vue-2'; import { __ } from '~/locale'; import Audio from '../../extensions/audio'; import Image from '../../extensions/image'; import Video from '../../extensions/video'; import EditorStateObserver from '../editor_state_observer.vue'; import { acceptedMimes } from '../../services/upload_helpers'; +import BubbleMenu from './bubble_menu.vue'; const MEDIA_TYPES = [Audio.name, Image.name, Video.name]; @@ -189,9 +189,8 @@ export default { <bubble-menu data-testid="media-bubble-menu" class="gl-shadow gl-rounded-base gl-bg-white" - :editor="tiptapEditor" plugin-key="bubbleMenuMedia" - :should-show="() => shouldShow()" + :should-show="shouldShow" > <editor-state-observer @transaction="updateMediaInfoToState"> <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index c3c881d9135..659c447e861 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,13 +1,16 @@ <script> import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; +import { __ } from '~/locale'; +import { VARIANT_DANGER } from '~/flash'; import { createContentEditor } from '../services/create_content_editor'; +import { ALERT_EVENT } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; -import FormattingBubbleMenu from './bubble_menus/formatting.vue'; -import CodeBlockBubbleMenu from './bubble_menus/code_block.vue'; -import LinkBubbleMenu from './bubble_menus/link.vue'; -import MediaBubbleMenu from './bubble_menus/media.vue'; +import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue'; +import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue'; +import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue'; +import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue'; import TopToolbar from './top_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -43,12 +46,26 @@ export default { required: false, default: () => {}, }, + markdown: { + type: String, + required: false, + default: '', + }, }, data() { return { focused: false, + isLoading: false, + latestMarkdown: null, }; }, + watch: { + markdown(markdown) { + if (markdown !== this.latestMarkdown) { + this.setSerializedContent(markdown); + } + }, + }, created() { const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this; @@ -61,21 +78,61 @@ export default { }); }, mounted() { - this.$emit('initialized', this.contentEditor); + this.$emit('initialized'); + this.setSerializedContent(this.markdown); }, beforeDestroy() { this.contentEditor.dispose(); }, methods: { + async setSerializedContent(markdown) { + this.notifyLoading(); + + try { + await this.contentEditor.setSerializedContent(markdown); + this.contentEditor.setEditable(true); + this.notifyLoadingSuccess(); + this.latestMarkdown = markdown; + } catch { + this.contentEditor.eventHub.$emit(ALERT_EVENT, { + message: __( + 'An error occurred while trying to render the content editor. Please try again.', + ), + variant: VARIANT_DANGER, + actionLabel: __('Retry'), + action: () => { + this.setSerializedContent(markdown); + }, + }); + this.contentEditor.setEditable(false); + this.notifyLoadingError(); + } + }, focus() { this.focused = true; }, blur() { this.focused = false; }, + notifyLoading() { + this.isLoading = true; + this.$emit('loading'); + }, + notifyLoadingSuccess() { + this.isLoading = false; + this.$emit('loadingSuccess'); + }, + notifyLoadingError(error) { + this.isLoading = false; + this.$emit('loadingError', error); + }, notifyChange() { + this.latestMarkdown = this.contentEditor.getSerializedContent(); + this.$emit('change', { empty: this.contentEditor.empty, + changed: this.contentEditor.changed, + markdown: this.latestMarkdown, }); }, }, @@ -84,14 +141,7 @@ export default { <template> <content-editor-provider :content-editor="contentEditor"> <div> - <editor-state-observer - @docUpdate="notifyChange" - @focus="focus" - @blur="blur" - @loading="$emit('loading')" - @loadingSuccess="$emit('loadingSuccess')" - @loadingError="$emit('loadingError')" - /> + <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> <content-editor-alert /> <div data-testid="content-editor" @@ -105,8 +155,12 @@ export default { <code-block-bubble-menu /> <link-bubble-menu /> <media-bubble-menu /> - <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> - <loading-indicator /> + <tiptap-editor-content + class="md" + data-testid="content_editor_editablebox" + :editor="contentEditor.tiptapEditor" + /> + <loading-indicator v-if="isLoading" /> </div> </div> </div> diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue index c6737da1d77..87eff2451ec 100644 --- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue +++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue @@ -14,19 +14,32 @@ export default { }; }, methods: { - displayAlert({ message, variant }) { + displayAlert({ message, variant, action, actionLabel }) { this.message = message; this.variant = variant; + this.action = action; + this.actionLabel = actionLabel; }, dismissAlert() { this.message = null; }, + primaryAction() { + this.dismissAlert(); + this.action?.(); + }, }, }; </script> <template> <editor-state-observer @alert="displayAlert"> - <gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert"> + <gl-alert + v-if="message" + class="gl-mb-6" + :variant="variant" + :primary-button-text="actionLabel" + @dismiss="dismissAlert" + @primaryAction="primaryAction" + > {{ message }} </gl-alert> </editor-state-observer> diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue index 252f69f7a5d..41c3771bf41 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -1,11 +1,6 @@ <script> import { debounce } from 'lodash'; -import { - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, - ALERT_EVENT, -} from '../constants'; +import { ALERT_EVENT } from '../constants'; export const tiptapToComponentMap = { update: 'docUpdate', @@ -15,12 +10,7 @@ export const tiptapToComponentMap = { blur: 'blur', }; -export const eventHubEvents = [ - ALERT_EVENT, - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, -]; +export const eventHubEvents = [ALERT_EVENT]; const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue index 7bc953e0dc3..e2af6cabddb 100644 --- a/app/assets/javascripts/content_editor/components/loading_indicator.vue +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -1,40 +1,18 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import EditorStateObserver from './editor_state_observer.vue'; export default { components: { GlLoadingIcon, - EditorStateObserver, - }, - data() { - return { - isLoading: false, - }; - }, - methods: { - displayLoadingIndicator() { - this.isLoading = true; - }, - hideLoadingIndicator() { - this.isLoading = false; - }, }, }; </script> <template> - <editor-state-observer - @loading="displayLoadingIndicator" - @loadingSuccess="hideLoadingIndicator" - @loadingError="hideLoadingIndicator" + <div + data-testid="content-editor-loading-indicator" + class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" > - <div - v-if="isLoading" - data-testid="content-editor-loading-indicator" - class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" - > - <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> - <gl-loading-icon size="lg" /> - </div> - </editor-state-observer> + <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> + <gl-loading-icon size="lg" /> + </div> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue index 649e23c29aa..8ed4dfce6de 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue @@ -71,27 +71,31 @@ export default { }; </script> <template> - <gl-dropdown - v-gl-tooltip - :aria-label="__('Insert image')" - :title="__('Insert image')" - size="small" - category="tertiary" - icon="media" - @hidden="resetFields()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')"> - <template #append> - <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item @click="openFileUpload"> - {{ __('Upload image') }} - </gl-dropdown-item> - + <span class="gl-display-inline-flex"> + <gl-dropdown + v-gl-tooltip + :text="__('Insert image')" + :title="__('Insert image')" + size="small" + category="tertiary" + icon="media" + lazy + text-sr-only + data-testid="insert-image-toolbar-button" + @hidden="resetFields()" + > + <gl-dropdown-form class="gl-px-3!"> + <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')"> + <template #append> + <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button> + </template> + </gl-form-input-group> + </gl-dropdown-form> + <gl-dropdown-divider /> + <gl-dropdown-item @click="openFileUpload"> + {{ __('Upload image') }} + </gl-dropdown-item> + </gl-dropdown> <input ref="fileSelector" type="file" @@ -101,5 +105,5 @@ export default { data-qa-selector="file_upload_field" @change="onFileSelect" /> - </gl-dropdown> + </span> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue index ff525e52873..4fb1e8ce16f 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue @@ -89,31 +89,34 @@ export default { </script> <template> <editor-state-observer @transaction="updateLinkState"> - <gl-dropdown - v-gl-tooltip - :aria-label="__('Insert link')" - :title="__('Insert link')" - :toggle-class="{ active: isActive }" - size="small" - category="tertiary" - icon="link" - @show="selectLink()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> - <template #append> - <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item v-if="isActive" @click="removeLink"> - {{ __('Remove link') }} - </gl-dropdown-item> - <gl-dropdown-item v-else @click="openFileUpload"> - {{ __('Upload file') }} - </gl-dropdown-item> - + <span class="gl-display-inline-flex"> + <gl-dropdown + v-gl-tooltip + :title="__('Insert link')" + :text="__('Insert link')" + :toggle-class="{ active: isActive }" + size="small" + category="tertiary" + icon="link" + text-sr-only + lazy + @show="selectLink()" + > + <gl-dropdown-form class="gl-px-3!"> + <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> + <template #append> + <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button> + </template> + </gl-form-input-group> + </gl-dropdown-form> + <gl-dropdown-divider /> + <gl-dropdown-item v-if="isActive" @click="removeLink"> + {{ __('Remove link') }} + </gl-dropdown-item> + <gl-dropdown-item v-else @click="openFileUpload"> + {{ __('Upload file') }} + </gl-dropdown-item> + </gl-dropdown> <input ref="fileSelector" type="file" @@ -121,6 +124,6 @@ export default { class="gl-display-none" @change="onFileSelect" /> - </gl-dropdown> + </span> </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 9ad739e7358..6bb122153ef 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -46,7 +46,18 @@ export default { }; </script> <template> - <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right> + <gl-dropdown + v-gl-tooltip + size="small" + category="tertiary" + icon="plus" + :text="__('More')" + :title="__('More')" + text-sr-only + class="content-editor-dropdown" + right + lazy + > <gl-dropdown-item @click="insert('codeBlock')"> {{ __('Code block') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index 18928acef3c..4b1929e1a20 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownDivider, + GlDropdownForm, + GlButton, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { clamp } from '../services/utils'; @@ -17,6 +23,9 @@ export default { GlDropdownDivider, GlDropdownForm, }, + directives: { + GlTooltip, + }, inject: ['tiptapEditor'], data() { return { @@ -62,7 +71,18 @@ export default { }; </script> <template> - <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right> + <gl-dropdown + v-gl-tooltip + size="small" + category="tertiary" + icon="table" + :title="__('Insert table')" + :text="__('Insert table')" + class="content-editor-dropdown" + right + text-sr-only + lazy + > <gl-dropdown-form class="gl-px-3!"> <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> <gl-button diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue index 13728d4001d..2bf32a70cd1 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue @@ -64,6 +64,7 @@ export default { data-qa-selector="text_style_dropdown" :disabled="!activeItem" :text="activeItemLabel" + lazy > <gl-dropdown-item v-for="(item, index) in $options.items" diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 1030ebbf838..460368b6a11 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -25,7 +25,7 @@ export default { </script> <template> <div - class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > <toolbar-text-style-dropdown data-testid="text-styles" diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index a39a243ec6b..564cca23afa 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -58,3 +58,11 @@ export const EXTENSION_PRIORITY_LOWER = 75; */ export const EXTENSION_PRIORITY_DEFAULT = 100; export const EXTENSION_PRIORITY_HIGHEST = 200; + +/** + * See lib/gitlab/file_type_detection.rb + */ +export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv']; +export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav']; + +export const DIAGRAM_LANGUAGES = ['plantuml', 'mermaid']; diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js index 9329bbcb2c7..2d4226ccd33 100644 --- a/app/assets/javascripts/content_editor/content_editor.stories.js +++ b/app/assets/javascripts/content_editor/content_editor.stories.js @@ -2,7 +2,7 @@ import { ContentEditor } from './index'; export default { component: ContentEditor, - title: 'content_editor/components/content_editor', + title: 'content_editor/content_editor', }; const Template = (_, { argTypes }) => ({ diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index f87e4d8d1dd..848c4c12a9a 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -3,13 +3,7 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/flash'; import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; -import { - ALERT_EVENT, - LOADING_CONTENT_EVENT, - LOADING_SUCCESS_EVENT, - LOADING_ERROR_EVENT, - EXTENSION_PRIORITY_HIGHEST, -} from '../constants'; +import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants'; import CodeBlockHighlight from './code_block_highlight'; import Diagram from './diagram'; import Frontmatter from './frontmatter'; @@ -34,10 +28,8 @@ export default Extension.create({ const { renderMarkdown, eventHub } = options; const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - eventHub.$emit(LOADING_CONTENT_EVENT); - deserializer - .deserialize({ schema: editor.schema, content: markdown }) + .deserialize({ schema: editor.schema, markdown }) .then(({ document }) => { if (!document) { return; @@ -48,14 +40,12 @@ export default Extension.create({ tr.replaceWith(selection.from - 1, selection.to, document.content); view.dispatch(tr); - eventHub.$emit(LOADING_SUCCESS_EVENT); }) .catch(() => { eventHub.$emit(ALERT_EVENT, { message: __('An error occurred while pasting text in the editor. Please try again.'), variant: VARIANT_DANGER, }); - eventHub.$emit(LOADING_ERROR_EVENT); }); return true; diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index f9de71f601b..54d69d83188 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -1,9 +1,11 @@ import { Extension } from '@tiptap/core'; +import Audio from './audio'; import Blockquote from './blockquote'; import Bold from './bold'; import BulletList from './bullet_list'; import Code from './code'; import CodeBlockHighlight from './code_block_highlight'; +import Diagram from './diagram'; import FootnoteReference from './footnote_reference'; import FootnoteDefinition from './footnote_definition'; import Frontmatter from './frontmatter'; @@ -25,17 +27,21 @@ import Table from './table'; import TableCell from './table_cell'; import TableHeader from './table_header'; import TableRow from './table_row'; +import TableOfContents from './table_of_contents'; +import Video from './video'; export default Extension.create({ addGlobalAttributes() { return [ { types: [ + Audio.name, Bold.name, Blockquote.name, BulletList.name, Code.name, CodeBlockHighlight.name, + Diagram.name, FootnoteReference.name, FootnoteDefinition.name, Frontmatter.name, @@ -56,6 +62,8 @@ export default Extension.create({ TableCell.name, TableHeader.name, TableRow.name, + TableOfContents.name, + Video.name, ...HTMLNodes.map((htmlNode) => htmlNode.name), ], attributes: { diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 75d8581890f..514ab9699bc 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,5 +1,3 @@ -import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; - /* eslint-disable no-underscore-dangle */ export class ContentEditor { constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) { @@ -20,14 +18,19 @@ export class ContentEditor { } get changed() { - return this._pristineDoc?.eq(this.tiptapEditor.state.doc); + if (!this._pristineDoc) { + return !this.empty; + } + + return !this._pristineDoc.eq(this.tiptapEditor.state.doc); } get empty() { - const doc = this.tiptapEditor?.state.doc; + return this.tiptapEditor.isEmpty; + } - // Makes sure the document has more than one empty paragraph - return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0); + get editable() { + return this.tiptapEditor.isEditable; } dispose() { @@ -55,24 +58,22 @@ export class ContentEditor { return this._assetResolver.renderDiagram(code, language); } + setEditable(editable = true) { + this._tiptapEditor.setOptions({ + editable, + }); + } + async setSerializedContent(serializedContent) { - const { _tiptapEditor: editor, _eventHub: eventHub } = this; + const { _tiptapEditor: editor } = this; const { doc, tr } = editor.state; - try { - eventHub.$emit(LOADING_CONTENT_EVENT); - const { document } = await this.deserialize(serializedContent); - - if (document) { - this._pristineDoc = document; - tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true); - editor.view.dispatch(tr); - } + const { document } = await this.deserialize(serializedContent); - eventHub.$emit(LOADING_SUCCESS_EVENT); - } catch (e) { - eventHub.$emit(LOADING_ERROR_EVENT, e); - throw e; + if (document) { + this._pristineDoc = document; + tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true); + editor.view.dispatch(tr); } } diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 7a289df94ea..5ed7f3dc23d 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -127,7 +127,7 @@ export const createContentEditor = ({ MathInline, OrderedList, Paragraph, - PasteMarkdown, + PasteMarkdown.configure({ eventHub, renderMarkdown }), Reference, ReferenceDefinition, Sourcemap, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 472a0a4815b..ba0cad6c91c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -108,7 +108,10 @@ const defaultSerializerConfig = { }, nodes: { - [Audio.name]: renderPlayable, + [Audio.name]: preserveUnchanged({ + render: renderPlayable, + inline: true, + }), [Blockquote.name]: preserveUnchanged((state, node) => { if (node.attrs.multiline) { state.write('>>>'); @@ -123,7 +126,7 @@ const defaultSerializerConfig = { }), [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), - [Diagram.name]: renderCodeBlock, + [Diagram.name]: preserveUnchanged(renderCodeBlock), [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); @@ -203,10 +206,10 @@ const defaultSerializerConfig = { }, overwriteSourcePreservationStrategy: true, }), - [TableOfContents.name]: (state, node) => { + [TableOfContents.name]: preserveUnchanged((state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); - }, + }), [Table.name]: preserveUnchanged(renderTable), [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, @@ -220,7 +223,10 @@ const defaultSerializerConfig = { else renderBulletList(state, node); }), [Text.name]: defaultMarkdownSerializer.nodes.text, - [Video.name]: renderPlayable, + [Video.name]: preserveUnchanged({ + render: renderPlayable, + inline: true, + }), [WordBreak.name]: (state) => state.write('<wbr>'), ...HTMLNodes.reduce((serializers, htmlNode) => { return { diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 8a15633708f..ca290efca11 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -1,7 +1,10 @@ import { render } from '~/lib/gfm'; import { isValidAttribute } from '~/lib/dompurify'; +import { SAFE_AUDIO_EXT, SAFE_VIDEO_EXT, DIAGRAM_LANGUAGES } from '../constants'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; +const ALL_AUDIO_VIDEO_EXT = [...SAFE_AUDIO_EXT, ...SAFE_VIDEO_EXT]; + const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; const isTaskItem = (hastNode) => { @@ -17,6 +20,32 @@ const getTableCellAttrs = (hastNode) => ({ rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1, }); +const getMediaAttrs = (hastNode) => ({ + src: hastNode.properties.src, + canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, + isReference: hastNode.properties.isReference === 'true', + title: hastNode.properties.title, + alt: hastNode.properties.alt, +}); + +const isMediaTag = (hastNode) => hastNode.tagName === 'img' && Boolean(hastNode.properties); + +const extractMediaFileExtension = (url) => { + try { + const parsedUrl = new URL(url, window.location.origin); + + return /\.(\w+)$/.exec(parsedUrl.pathname)?.[1] ?? null; + } catch { + return null; + } +}; + +const isCodeBlock = (hastNode) => hastNode.tagName === 'codeblock'; + +const isDiagramCodeBlock = (hastNode) => DIAGRAM_LANGUAGES.includes(hastNode.properties?.language); + +const getCodeBlockAttrs = (hastNode) => ({ language: hastNode.properties.language }); + const factorySpecs = { blockquote: { type: 'block', selector: 'blockquote' }, paragraph: { type: 'block', selector: 'p' }, @@ -45,8 +74,13 @@ const factorySpecs = { }, codeBlock: { type: 'block', - selector: 'codeblock', - getAttrs: (hastNode) => ({ ...hastNode.properties }), + selector: (hastNode) => isCodeBlock(hastNode) && !isDiagramCodeBlock(hastNode), + getAttrs: getCodeBlockAttrs, + }, + diagram: { + type: 'block', + selector: (hastNode) => isCodeBlock(hastNode) && isDiagramCodeBlock(hastNode), + getAttrs: getCodeBlockAttrs, }, horizontalRule: { type: 'block', @@ -121,16 +155,26 @@ const factorySpecs = { selector: 'pre', wrapInParagraph: true, }, + audio: { + type: 'inline', + selector: (hastNode) => + isMediaTag(hastNode) && + SAFE_AUDIO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, + }, image: { type: 'inline', - selector: 'img', - getAttrs: (hastNode) => ({ - src: hastNode.properties.src, - canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src, - isReference: hastNode.properties.isReference === 'true', - title: hastNode.properties.title, - alt: hastNode.properties.alt, - }), + selector: (hastNode) => + isMediaTag(hastNode) && + !ALL_AUDIO_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, + }, + video: { + type: 'inline', + selector: (hastNode) => + isMediaTag(hastNode) && + SAFE_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)), + getAttrs: getMediaAttrs, }, hardBreak: { type: 'inline', @@ -193,6 +237,11 @@ const factorySpecs = { language: hastNode.properties.language, }), }, + + tableOfContents: { + type: 'block', + selector: 'tableofcontents', + }, }; const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference']; @@ -250,6 +299,7 @@ export default () => { 'yaml', 'toml', 'json', + 'tableOfContents', ], }); diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js index 815289e075e..832efa90956 100644 --- a/app/assets/javascripts/crm/constants.js +++ b/app/assets/javascripts/crm/constants.js @@ -5,3 +5,7 @@ export const trackViewsOptions = { category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */, action: 'view_contacts_list', }; +export const organizationTrackViewsOptions = { + category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */, + action: 'view_organizations_list', +}; diff --git a/app/assets/javascripts/crm/organizations/bundle.js b/app/assets/javascripts/crm/organizations/bundle.js index 828d7cd426c..5897810a384 100644 --- a/app/assets/javascripts/crm/organizations/bundle.js +++ b/app/assets/javascripts/crm/organizations/bundle.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CrmOrganizationsRoot from './components/organizations_root.vue'; import routes from './routes'; @@ -21,7 +22,14 @@ export default () => { return false; } - const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset; + const { + basePath, + canAdminCrmOrganization, + groupFullPath, + groupId, + groupIssuesPath, + textQuery, + } = el.dataset; const router = new VueRouter({ base: basePath, @@ -33,7 +41,13 @@ export default () => { el, router, apolloProvider, - provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath }, + provide: { + canAdminCrmOrganization: parseBoolean(canAdminCrmOrganization), + groupFullPath, + groupId, + groupIssuesPath, + textQuery, + }, render(createElement) { return createElement(CrmOrganizationsRoot); }, diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql index 97b75091cac..1bdcd9ba352 100644 --- a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql @@ -1,12 +1,37 @@ #import "./crm_organization_fields.fragment.graphql" -query organizations($groupFullPath: ID!) { +query organizations( + $groupFullPath: ID! + $state: CustomerRelationsOrganizationState + $searchTerm: String + $sort: OrganizationSort + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" + $ids: [CustomerRelationsOrganizationID!] +) { group(fullPath: $groupFullPath) { id - organizations { + organizations( + state: $state + search: $searchTerm + sort: $sort + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ids: $ids + ) { nodes { ...OrganizationFragment } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } } } } diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql new file mode 100644 index 00000000000..fb6064e171f --- /dev/null +++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql @@ -0,0 +1,11 @@ +query organizationsCountByState($groupFullPath: ID!, $searchTerm: String) { + group(fullPath: $groupFullPath) { + __typename + id + organizationStateCounts(search: $searchTerm) { + all + active + inactive + } + } +} diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index 5fd0294b0ea..32900d45f22 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -36,7 +36,7 @@ export default { getQuery() { return { query: getGroupOrganizationsQuery, - variables: { groupFullPath: this.groupFullPath }, + variables: { groupFullPath: this.groupFullPath, ids: [this.organizationGraphQLId] }, }; }, title() { diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue index a165dd68603..155c8f00537 100644 --- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue +++ b/app/assets/javascripts/crm/organizations/components/organizations_root.vue @@ -1,36 +1,54 @@ <script> -import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants'; +import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; +import { + bodyTrClass, + initialPaginationState, +} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME, organizationTrackViewsOptions } from '../../constants'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; +import getGroupOrganizationsCountByStateQuery from './graphql/get_group_organizations_count_by_state.query.graphql'; export default { components: { - GlAlert, GlButton, GlLoadingIcon, GlTable, + PaginatedTableWithSearchAndTabs, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'], + inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath', 'textQuery'], data() { return { - organizations: [], + organizations: { list: [] }, + organizationsCount: {}, error: false, + filteredByStatus: '', + pagination: initialPaginationState, + statusFilter: 'all', + searchTerm: this.textQuery, + sort: 'NAME_ASC', + sortDesc: false, }; }, apollo: { organizations: { - query() { - return getGroupOrganizationsQuery; - }, + query: getGroupOrganizationsQuery, variables() { return { groupFullPath: this.groupFullPath, + searchTerm: this.searchTerm, + state: this.statusFilter, + sort: this.sort, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, }; }, update(data) { @@ -40,19 +58,52 @@ export default { this.error = true; }, }, + organizationsCount: { + query: getGroupOrganizationsCountByStateQuery, + variables() { + return { + groupFullPath: this.groupFullPath, + searchTerm: this.searchTerm, + }; + }, + update(data) { + return data?.group?.organizationStateCounts; + }, + error() { + this.error = true; + }, + }, }, computed: { isLoading() { return this.$apollo.queries.organizations.loading; }, - canAdmin() { - return parseBoolean(this.canAdminCrmOrganization); + tbodyTrClass() { + return { + [bodyTrClass]: !this.loading && !this.isEmpty, + }; }, }, methods: { + errorAlertDismissed() { + this.error = false; + }, extractOrganizations(data) { const organizations = data?.group?.organizations?.nodes || []; - return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); + const pageInfo = data?.group?.organizations?.pageInfo || {}; + return { + list: organizations, + pageInfo, + }; + }, + fetchSortedData({ sortBy, sortDesc }) { + const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); + const sortingDirection = sortDesc ? 'DESC' : 'ASC'; + this.pagination = initialPaginationState; + this.sort = `${sortingColumn}_${sortingDirection}`; + }, + filtersChanged({ searchTerm }) { + this.searchTerm = searchTerm; }, getIssuesPath(path, value) { return `${path}?crm_organization_id=${value}`; @@ -60,6 +111,13 @@ export default { getEditRoute(id) { return { name: this.$options.EDIT_ROUTE_NAME, params: { id } }; }, + pageChanged(pagination) { + this.pagination = pagination; + }, + statusChanged({ filters, status }) { + this.statusFilter = filters; + this.filteredByStatus = status; + }, }, fields: [ { key: 'name', sortable: true }, @@ -83,60 +141,113 @@ export default { }, EDIT_ROUTE_NAME, NEW_ROUTE_NAME, + statusTabs: [ + { + title: __('Active'), + status: 'ACTIVE', + filters: 'active', + }, + { + title: __('Inactive'), + status: 'INACTIVE', + filters: 'inactive', + }, + { + title: __('All'), + status: 'ALL', + filters: 'all', + }, + ], + organizationTrackViewsOptions, + emptyArray: [], }; </script> <template> <div> - <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false"> - {{ $options.i18n.errorText }} - </gl-alert> - <div - class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + <paginated-table-with-search-and-tabs + :show-items="true" + :show-error-msg="false" + :i18n="$options.i18n" + :items="organizations.list" + :page-info="organizations.pageInfo" + :items-count="organizationsCount" + :status-tabs="$options.statusTabs" + :track-views-options="$options.organizationTrackViewsOptions" + :filter-search-tokens="$options.emptyArray" + filter-search-key="organizations" + @page-changed="pageChanged" + @tabs-changed="statusChanged" + @filters-changed="filtersChanged" + @error-alert-dismissed="errorAlertDismissed" > - <h2 class="gl-font-size-h2 gl-my-0"> - {{ $options.i18n.title }} - </h2> - <div - v-if="canAdmin" - class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end" - > - <router-link :to="{ name: $options.NEW_ROUTE_NAME }"> - <gl-button variant="confirm" data-testid="new-organization-button"> + <template #header-actions> + <router-link v-if="canAdminCrmOrganization" :to="{ name: $options.NEW_ROUTE_NAME }"> + <gl-button + class="gl-my-3 gl-mr-5" + variant="confirm" + data-testid="new-organization-button" + > {{ $options.i18n.newOrganization }} </gl-button> </router-link> - </div> - </div> - <router-view /> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> - <gl-table - v-else - class="gl-mt-5" - :items="organizations" - :fields="$options.fields" - :empty-text="$options.i18n.emptyText" - show-empty - > - <template #cell(id)="{ value: id }"> - <gl-button - v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" - class="gl-mr-3" - data-testid="issues-link" - icon="issues" - :aria-label="$options.i18n.issuesButtonLabel" - :href="getIssuesPath(groupIssuesPath, id)" - /> - <router-link :to="getEditRoute(id)"> - <gl-button - v-if="canAdmin" - v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" - data-testid="edit-organization-button" - icon="pencil" - :aria-label="$options.i18n.editButtonLabel" - /> - </router-link> </template> - </gl-table> + + <template #title> + {{ $options.i18n.title }} + </template> + + <template #table> + <gl-table + :items="organizations.list" + :fields="$options.fields" + :busy="isLoading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + sort-direction="asc" + :sort-desc.sync="sortDesc" + sort-by="createdAt" + show-empty + no-local-sorting + sort-icon-left + fixed + @sort-changed="fetchSortedData" + > + <template #cell(id)="{ value: id }"> + <gl-button + v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + class="gl-mr-3" + data-testid="issues-link" + icon="issues" + :aria-label="$options.i18n.issuesButtonLabel" + :href="getIssuesPath(groupIssuesPath, id)" + /> + <router-link :to="getEditRoute(id)"> + <gl-button + v-if="canAdminCrmOrganization" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-organization-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + /> + </router-link> + </template> + + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + + <template #empty> + <span v-if="error"> + {{ $options.i18n.errorText }} + </span> + <span v-else> + {{ $options.i18n.emptyText }} + </span> + </template> + </gl-table> + </template> + </paginated-table-with-search-and-tabs> + <router-view /> </div> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index 85a40b89b77..f1fdffd4b72 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -246,9 +246,7 @@ export default { </p> <p class="gl-m-0"> <span data-testid="vsa-stage-event-build-author-and-date"> - <gl-link class="gl-text-black-normal build-date" :href="item.url">{{ - item.date - }}</gl-link> + <gl-link class="gl-text-black-normal" :href="item.url">{{ item.date }}</gl-link> {{ s__('ByAuthor|by') }} <gl-link class="gl-text-black-normal issue-author-link" diff --git a/app/assets/javascripts/cycle_analytics/components/total_time.vue b/app/assets/javascripts/cycle_analytics/components/total_time.vue index a5a90a56974..725952c3518 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time.vue +++ b/app/assets/javascripts/cycle_analytics/components/total_time.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <span class="total-time"> + <span> <template v-if="hasData"> {{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue index f686cd0db95..17decb6b448 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue @@ -57,6 +57,10 @@ export default { includeSubgroups: true, }; }, + currentDate() { + const now = new Date(); + return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + }, }, multiProjectSelect: true, maxDateRange: DATE_RANGE_LIMIT, @@ -93,6 +97,7 @@ export default { v-if="hasDateRangeFilter" :start-date="startDate" :end-date="endDate" + :max-date="currentDate" :max-date-range="$options.maxDateRange" :include-selected-date="true" class="js-daterange-picker" diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index 6fe353405d4..83068cabf0f 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -1,5 +1,5 @@ -import dateFormat from 'dateformat'; import { dateFormats } from '~/analytics/shared/constants'; +import dateFormat from '~/lib/dateformat'; import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { PAGINATION_TYPE } from '../constants'; import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index d811bb3b0bf..c9097b9384f 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -4,11 +4,11 @@ import { head, tail } from 'lodash'; import { s__, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import actionBtn from './action_btn.vue'; +import ActionBtn from './action_btn.vue'; export default { components: { - actionBtn, + ActionBtn, GlButton, GlIcon, GlLink, diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index d71f4f5507f..77ec1ef590f 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -1,9 +1,9 @@ <script> -import deployKey from './key.vue'; +import DeployKey from './key.vue'; export default { components: { - deployKey, + DeployKey, }, props: { keys: { diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js index 6e439be42ae..83601d5b2e3 100644 --- a/app/assets/javascripts/deploy_keys/index.js +++ b/app/assets/javascripts/deploy_keys/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; -import deployKeysApp from './components/app.vue'; +import DeployKeysApp from './components/app.vue'; export default () => new Vue({ el: document.getElementById('js-deploy-keys'), components: { - deployKeysApp, + DeployKeysApp, }, data() { return { diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js index f10c2d82b61..0f612989bb4 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js @@ -13,6 +13,7 @@ const renderersByType = { }, header(element, data) { element.classList.add('dropdown-header'); + // eslint-disable-next-line no-unsanitized/property element.innerHTML = data.content; return element; @@ -122,6 +123,7 @@ function assignTextToLink(el, data, options) { const text = getLinkText(data, options); if (options.icon || options.highlight) { + // eslint-disable-next-line no-unsanitized/property el.innerHTML = text; } else { el.textContent = text; diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index ac00af2ab34..124780df8a5 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -174,6 +174,7 @@ export default { this.$emit('open-form', this.discussion.id); this.isFormRendered = true; }, + toggleResolvedStatus() { this.isResolving = true; @@ -234,6 +235,7 @@ export default { :note="firstNote" :markdown-preview-path="markdownPreviewPath" :is-resolving="isResolving" + :noteable-id="noteableId" :class="{ 'gl-bg-blue-50': isDiscussionActive }" @error="$emit('update-note-error', $event)" > @@ -276,6 +278,7 @@ export default { :note="note" :markdown-preview-path="markdownPreviewPath" :is-resolving="isResolving" + :noteable-id="noteableId" :class="{ 'gl-bg-blue-50': isDiscussionActive }" @error="$emit('update-note-error', $event)" /> @@ -307,6 +310,8 @@ export default { v-model="discussionComment" :is-saving="loading" :markdown-preview-path="markdownPreviewPath" + :noteable-id="noteableId" + :discussion-id="discussion.id" @submit-form="mutate" @cancel-form="hideForm" > diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 5fb5989e11a..e629f74ba02 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -45,6 +45,10 @@ export default { required: false, default: '', }, + noteableId: { + type: String, + required: true, + }, }, data() { return { @@ -160,6 +164,7 @@ export default { :is-saving="loading" :markdown-preview-path="markdownPreviewPath" :is-new-comment="false" + :noteable-id="noteableId" class="gl-mt-5" @submit-form="mutate" @cancel-form="hideForm" diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 1b6458668f5..4faeba3983b 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -1,7 +1,11 @@ <script> import { GlButton, GlModal } from '@gitlab/ui'; +import $ from 'jquery'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import Autosave from '~/autosave'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; export default { @@ -30,10 +34,20 @@ export default { required: false, default: true, }, + noteableId: { + type: String, + required: true, + }, + discussionId: { + type: String, + required: false, + default: 'new', + }, }, data() { return { formText: this.value, + isLoggedIn: isLoggedIn(), }; }, computed: { @@ -64,13 +78,19 @@ export default { markdownDocsPath() { return helpPagePath('user/markdown'); }, + shortDiscussionId() { + return isGid(this.discussionId) ? getIdFromGraphQLId(this.discussionId) : this.discussionId; + }, }, mounted() { this.focusInput(); }, methods: { submitForm() { - if (this.hasValue) this.$emit('submit-form'); + if (this.hasValue) { + this.$emit('submit-form'); + this.autosaveDiscussion.reset(); + } }, cancelComment() { if (this.hasValue && this.formText !== this.value) { @@ -79,8 +99,22 @@ export default { this.$emit('cancel-form'); } }, + confirmCancelCommentModal() { + this.$emit('cancel-form'); + this.autosaveDiscussion.reset(); + }, focusInput() { this.$refs.textarea.focus(); + this.initAutosaveComment(); + }, + initAutosaveComment() { + if (this.isLoggedIn) { + this.autosaveDiscussion = new Autosave($(this.$refs.textarea), [ + s__('DesignManagement|Discussion'), + getIdFromGraphQLId(this.noteableId), + this.shortDiscussionId, + ]); + } }, }, }; @@ -124,7 +158,7 @@ export default { type="submit" data-track-action="click_button" data-qa-selector="save_comment_button" - @click="$emit('submit-form')" + @click="submitForm" > {{ buttonText }} </gl-button> @@ -144,7 +178,7 @@ export default { :ok-title="modalSettings.okTitle" :cancel-title="modalSettings.cancelTitle" modal-id="cancel-comment-modal" - @ok="$emit('cancel-form')" + @ok="confirmCancelCommentModal" >{{ modalSettings.content }} </gl-modal> </form> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 3092b8554ac..1e36aa686a4 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -128,7 +128,7 @@ export default { params: { id: filename }, query: $route.query, }" - class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0" > <div class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index 816d7ac7abf..f10545faea6 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -73,8 +73,8 @@ export default { <gl-dropdown-item v-for="(version, index) in allVersions" :key="version.id" - :is-check-item="true" - :is-check-centered="true" + is-check-item + is-check-centered :is-checked="findVersionId(version.id) === currentVersionId" :avatar-url="getAvatarUrl(version)" @click="routeToVersion(version.id)" diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 1825ce7f092..228ad637b9e 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -418,6 +418,7 @@ export default { v-model="comment" :is-saving="loading" :markdown-preview-path="markdownPreviewPath" + :noteable-id="design.id" @submit-form="mutate" @cancel-form="closeCommentForm" /> </apollo-mutation diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 91e35ad3764..07f7a19f7d4 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -135,7 +135,7 @@ export default { designDropzoneWrapperClass() { return this.isDesignListEmpty ? 'col-12' - : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3'; + : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mt-5'; }, }, mounted() { @@ -364,15 +364,15 @@ export default { data-testid="design-toolbar-wrapper" > <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap gl-gap-3" > - <div class="gl-display-flex gl-align-items-center gl-my-2"> + <div class="gl-display-flex gl-align-items-center"> <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> <design-version-dropdown /> </div> <div v-show="hasDesigns" - class="gl-display-flex gl-align-items-center gl-my-2" + class="gl-display-flex gl-align-items-center" data-testid="design-selector-toolbar" > <gl-button @@ -413,7 +413,7 @@ export default { </div> </div> </header> - <div class="gl-mt-6"> + <div> <gl-loading-icon v-if="isLoading" size="lg" /> <gl-alert v-else-if="error" variant="danger" :dismissible="false"> {{ __('An error occurred while loading designs. Please try again.') }} @@ -449,7 +449,7 @@ export default { <li v-for="design in designs" :key="design.id" - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" + class="col-md-6 col-lg-3 gl-mt-5 gl-bg-transparent gl-shadow-none js-design-tile" > <design-dropzone :display-as-card="hasDesigns" diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 530f3a3a7f7..f5c0776ca35 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -331,6 +331,8 @@ export default { mrReviews: this.rehydratedMrReviews, }); + this.interfaceWithDOM(); + if (this.endpointCodequality) { this.setCodequalityEndpoint(this.endpointCodequality); } @@ -445,6 +447,16 @@ export default { notesEventHub.$off('refetchDiffData', this.refetchDiffData); notesEventHub.$off('fetchDiffData', this.fetchData); }, + interfaceWithDOM() { + this.diffsTab = document.querySelector('.js-diffs-tab'); + }, + updateChangesTabCount() { + const badge = this.diffsTab.querySelector('.gl-badge'); + + if (this.diffsTab && badge) { + badge.textContent = this.diffFilesLength; + } + }, navigateToDiffFileNumber(number) { this.navigateToDiffFileIndex(number - 1); }, @@ -461,7 +473,11 @@ export default { this.fetchDiffFilesMeta() .then(({ real_size }) => { this.diffFilesLength = parseInt(real_size, 10); - if (toggleTree) this.setTreeDisplay(); + if (toggleTree) { + this.setTreeDisplay(); + } + + this.updateChangesTabCount(); }) .catch(() => { createFlash({ @@ -641,6 +657,7 @@ export default { <div v-if="renderFileTree" :style="{ width: `${treeWidth}px` }" + :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }" class="diff-tree-list js-diff-tree-list gl-px-5" > <panel-resizer diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index fd219a7d00f..4501988ee4f 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -37,7 +37,7 @@ export default { :class="{ 'is-active': version.selected, }" - :is-check-item="true" + is-check-item :is-checked="version.selected" :href="version.href" > diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue index f339b108a11..8498724740f 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -5,10 +5,6 @@ import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/c export default { components: { GlButton, GlIcon }, props: { - line: { - type: Number, - required: true, - }, codeQuality: { type: Array, required: true, @@ -33,7 +29,7 @@ export default { <li v-for="finding in codeQuality" :key="finding.description" - class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100" + class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100 gl-font-regular" > <gl-icon :size="12" @@ -50,7 +46,7 @@ export default { size="small" icon="close" class="gl-absolute gl-right-2 gl-top-2" - @click="$emit('hideCodeQualityFindings', line)" + @click="$emit('hideCodeQualityFindings')" /> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 70071a3ff53..d7b63d205dc 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -11,7 +11,7 @@ import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_prev import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; import NoteForm from '~/notes/components/note_form.vue'; import eventHub from '~/notes/event_hub'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; import DiffDiscussions from './diff_discussions.vue'; @@ -28,7 +28,7 @@ export default { ImageDiffOverlay, NotDiffableViewer, NoPreviewViewer, - userAvatarLink, + UserAvatarLink, DiffFileDrafts, }, mixins: [diffLineNoteFormMixin, draftCommentsMixin], diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index b39b50c4cdc..25d3bda147b 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -2,11 +2,11 @@ import { GlIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; -import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; +import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; export default { components: { - noteableDiscussion, + NoteableDiscussion, GlIcon, DesignNotePin, }, diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 07316f9433a..705b43a222d 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -19,6 +19,7 @@ import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants'; import { DIFF_FILE_HEADER } from '../i18n'; @@ -45,7 +46,7 @@ export default { GlTooltip: GlTooltipDirective, SafeHtml: GlSafeHtmlDirective, }, - mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash })], + mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash }), glFeatureFlagsMixin()], i18n: { ...DIFF_FILE_HEADER, compareButtonLabel: __('Compare submodule commit revisions'), @@ -276,7 +277,10 @@ export default { <template> <div ref="header" - :class="{ 'gl-z-dropdown-menu!': idState.moreActionsShown }" + :class="{ + 'gl-z-dropdown-menu!': idState.moreActionsShown, + 'is-sidebar-moved': glFeatures.movedMrSidebar, + }" class="js-file-title file-title file-title-flex-parent" data-qa-selector="file_title_container" :data-qa-file-name="filePath" diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index a077c8ae3af..8553bdd3020 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -4,6 +4,7 @@ import { truncate } from '~/lib/utils/text_utility'; import { n__ } from '~/locale'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; +import { HIDE_COMMENTS } from '../i18n'; export default { components: { @@ -55,6 +56,9 @@ export default { return `${noteData.author.name}: ${note}`; }, }, + i18n: { + HIDE_COMMENTS, + }, }; </script> @@ -62,8 +66,10 @@ export default { <div class="diff-comment-avatar-holders"> <button v-if="discussionsExpanded" + v-gl-tooltip + :title="$options.i18n.HIDE_COMMENTS" type="button" - :aria-label="__('Show comments')" + :aria-label="$options.i18n.HIDE_COMMENTS" class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" @click="$emit('toggleLineDiscussions')" > diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue new file mode 100644 index 00000000000..448272549d3 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line.vue @@ -0,0 +1,35 @@ +<script> +import DiffCodeQuality from './diff_code_quality.vue'; + +export default { + components: { + DiffCodeQuality, + }, + props: { + line: { + type: Object, + required: true, + }, + }, + computed: { + parsedCodeQuality() { + return (this.line.left ?? this.line.right)?.codequality; + }, + codeQualityLineNumber() { + return this.parsedCodeQuality[0].line; + }, + }, + methods: { + hideCodeQualityFindings() { + this.$emit('hideCodeQualityFindings', this.codeQualityLineNumber); + }, + }, +}; +</script> + +<template> + <diff-code-quality + :code-quality="parsedCodeQuality" + @hideCodeQualityFindings="hideCodeQualityFindings" + /> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 467a0f8d2db..f63ab1bb067 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -7,7 +7,7 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils'; -import noteForm from '~/notes/components/note_form.vue'; +import NoteForm from '~/notes/components/note_form.vue'; import autosave from '~/notes/mixins/autosave'; import { DIFF_NOTE_TYPE, @@ -18,7 +18,7 @@ import { export default { components: { - noteForm, + NoteForm, MultilineCommentForm, }, mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()], diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 63c5aedd7ce..e5695c4390f 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -64,6 +64,11 @@ export default { type: Function, required: true, }, + codeQualityExpanded: { + type: Boolean, + required: false, + default: false, + }, }, classNameMap: memoize( (props) => { @@ -272,6 +277,7 @@ export default { <component :is="$options.CodeQualityGutterIcon" v-if="$options.showCodequalityLeft(props)" + :code-quality-expanded="props.codeQualityExpanded" :codequality="props.line.left.codequality" :file-path="props.filePath" @showCodeQualityFindings=" diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index ea94df1ad5b..91bf3283379 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -9,7 +9,7 @@ import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; import { hide } from '~/tooltips'; import { pickDirection } from '../utils/diff_line'; import DiffCommentCell from './diff_comment_cell.vue'; -import DiffCodeQuality from './diff_code_quality.vue'; +import DiffLine from './diff_line.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import DiffRow from './diff_row.vue'; import { isHighlighted } from './diff_row_utils'; @@ -18,8 +18,8 @@ export default { components: { DiffExpansionCell, DiffRow, + DiffLine, DiffCommentCell, - DiffCodeQuality, DraftNote, }, directives: { @@ -96,10 +96,6 @@ export default { } this.idState.dragStart = line; }, - parseCodeQuality(line) { - return (line.left ?? line.right)?.codequality; - }, - hideCodeQualityFindings(line) { const index = this.codeQualityExpandedLines.indexOf(line); if (index > -1) { @@ -179,7 +175,7 @@ export default { ); }, getCodeQualityLine(line) { - return this.parseCodeQuality(line)?.[0]?.line; + return (line.left ?? line.right)?.codequality?.[0]?.line; }, }, userColorScheme: window.gon.user_color_scheme, @@ -234,6 +230,7 @@ export default { :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" :inline="inline" :index="index" + :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))" :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" :coverage-loaded="coverageLoaded" @@ -248,15 +245,13 @@ export default { @startdragging="onStartDragging" @stopdragging="onStopDragging" /> - - <diff-code-quality + <diff-line v-if=" glFeatures.refactorCodeQualityInlineFindings && codeQualityExpandedLines.includes(getCodeQualityLine(line)) " :key="line.line_code" - :line="getCodeQualityLine(line)" - :code-quality="parseCodeQuality(line)" + :line="line" @hideCodeQualityFindings="hideCodeQualityFindings" /> <div diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 1cc96ef3d54..6c0c9c4e1d0 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -71,15 +71,12 @@ export const DIFF_FILE_MANUAL_COLLAPSE = 'manual'; export const STATE_IDLING = 'idle'; export const STATE_LOADING = 'loading'; export const STATE_ERRORED = 'errored'; -export const STATE_PENDING_REVIEW = 'pending_comments'; // State machine transitions export const TRANSITION_LOAD_START = 'LOAD_START'; export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR'; export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED'; export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR'; -export const TRANSITION_HAS_PENDING_REVIEW = 'PENDING_REVIEW'; -export const TRANSITION_NO_REVIEW = 'NO_REVIEW'; export const RENAMED_DIFF_TRANSITIONS = { [`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING, diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index e617890af2e..f7f4aad3ad0 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -47,3 +47,5 @@ export const CONFLICT_TEXT = { 'Conflict: This file was added both in the source and target branches, but with different contents.', ), }; + +export const HIDE_COMMENTS = __('Hide comments'); diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 1691da34c6d..b4ff5e4f250 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -3,7 +3,7 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils'; import eventHub from '../notes/event_hub'; -import diffsApp from './components/app.vue'; +import DiffsApp from './components/app.vue'; import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants'; import { getReviewsForMergeRequest } from './utils/file_reviews'; @@ -14,7 +14,7 @@ export default function initDiffsApp(store) { el: '#js-diffs-app', name: 'MergeRequestDiffs', components: { - diffsApp, + DiffsApp, }, store, data() { diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index 7a2c9a8600e..f22a0705b3d 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -20,13 +20,13 @@ import { } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { s__, n__ } from '~/locale'; -import instanceComponent from '~/vue_shared/components/deployment_instance.vue'; +import InstanceComponent from '~/vue_shared/components/deployment_instance.vue'; import { STATUS_MAP, CANARY_STATUS } from '../constants'; import CanaryIngress from './canary_ingress.vue'; export default { components: { - instanceComponent, + InstanceComponent, CanaryIngress, GlIcon, GlLoadingIcon, diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index 19284b26d51..3475b38c8c9 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -1,17 +1,17 @@ <script> import { GlBadge, - GlButton, - GlCollapse, GlIcon, GlLink, + GlLoadingIcon, GlTooltipDirective as GlTooltip, GlTruncate, } from '@gitlab/ui'; -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { __, s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import createFlash from '~/flash'; +import deploymentDetails from '../graphql/queries/deployment_details.query.graphql'; import DeploymentStatusBadge from './deployment_status_badge.vue'; import Commit from './commit.vue'; @@ -21,16 +21,16 @@ export default { Commit, DeploymentStatusBadge, GlBadge, - GlButton, - GlCollapse, GlIcon, GlLink, GlTruncate, + GlLoadingIcon, TimeAgoTooltip, }, directives: { GlTooltip, }, + inject: ['projectPath'], props: { deployment: { type: Object, @@ -41,9 +41,11 @@ export default { default: false, required: false, }, - }, - data() { - return { visible: false }; + visible: { + type: Boolean, + default: false, + required: false, + }, }, computed: { status() { @@ -52,26 +54,21 @@ export default { iid() { return this.deployment?.iid; }, + isTag() { + return this.deployment?.tag; + }, shortSha() { return this.commit?.shortId; }, createdAt() { return this.deployment?.createdAt; }, - isMobile() { - return !GlBreakpointInstance.isDesktop(); - }, - detailsButton() { - return this.visible - ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' } - : { text: this.$options.i18n.showDetails, icon: 'expand-down' }; - }, - detailsButtonClasses() { - return this.isMobile ? 'gl-sr-only' : ''; - }, commit() { return this.deployment?.commit; }, + commitPath() { + return this.commit?.commitPath; + }, user() { return this.deployment?.user; }, @@ -90,9 +87,6 @@ export default { jobPath() { return this.deployable?.buildPath; }, - refLabel() { - return this.deployment?.tag ? this.$options.i18n.tag : this.$options.i18n.branch; - }, ref() { return this.deployment?.ref; }, @@ -105,10 +99,35 @@ export default { needsApproval() { return this.deployment.pendingApprovalCount > 0; }, + hasTags() { + return this.tags?.length > 0; + }, + displayTags() { + return this.tags?.slice(0, 5); + }, }, - methods: { - toggleCollapse() { - this.visible = !this.visible; + apollo: { + tags: { + query: deploymentDetails, + variables() { + return { + projectPath: this.projectPath, + iid: this.deployment.iid, + }; + }, + update(data) { + return data?.project?.deployment?.tags; + }, + error(error) { + createFlash({ + message: this.$options.i18n.LOAD_ERROR_MESSAGE, + captureError: true, + error, + }); + }, + skip() { + return !this.visible; + }, }, }, i18n: { @@ -116,14 +135,12 @@ export default { deploymentId: s__('Deployment|Deployment ID'), copyButton: __('Copy commit SHA'), commitSha: __('Commit SHA'), - showDetails: __('Show details'), - hideDetails: __('Hide details'), triggerer: s__('Deployment|Triggerer'), needsApproval: s__('Deployment|Needs Approval'), job: __('Job'), api: __('API'), branch: __('Branch'), - tag: __('Tag'), + tags: __('Tags'), }, headerClasses: [ 'gl-display-flex', @@ -179,7 +196,9 @@ export default { class="gl-font-monospace gl-display-flex gl-align-items-center" > <gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" /> - <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span> + <gl-link v-gl-tooltip :title="$options.i18n.commitSha" :href="commitPath"> + {{ shortSha }} + </gl-link> <clipboard-button :text="shortSha" category="tertiary" @@ -195,54 +214,66 @@ export default { </time-ago-tooltip> </div> </div> - <gl-button - ref="details-toggle" - category="tertiary" - :icon="detailsButton.icon" - :button-text-classes="detailsButtonClasses" - @click="toggleCollapse" - > - {{ detailsButton.text }} - </gl-button> </div> <commit v-if="commit" :commit="commit" class="gl-mt-3" /> <div class="gl-mt-3"><slot name="approval"></slot></div> - <gl-collapse :visible="visible"> + <div + class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0" + > + <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p"> + <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span> + <gl-link :href="userPath" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="username" with-tooltip /> + </gl-link> + </div> <div - class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0" + class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" > - <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p"> - <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span> - <gl-link :href="userPath" class="gl-font-monospace gl-mt-3"> - <gl-truncate :text="username" with-tooltip /> - </gl-link> - </div> - <div - class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" - > - <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }"> - {{ $options.i18n.job }} - </span> - <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3"> - <gl-truncate :text="jobName" with-tooltip position="middle" /> - </gl-link> - <span v-else-if="jobName" class="gl-font-monospace gl-mt-3"> - <gl-truncate :text="jobName" with-tooltip position="middle" /> - </span> - <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info"> - {{ $options.i18n.api }} - </gl-badge> - </div> - <div - v-if="ref" - class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" - > - <span class="gl-text-gray-500">{{ refLabel }}</span> - <gl-link :href="refPath" class="gl-font-monospace gl-mt-3"> - <gl-truncate :text="refName" with-tooltip /> + <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }"> + {{ $options.i18n.job }} + </span> + <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="jobName" with-tooltip position="middle" /> + </gl-link> + <span v-else-if="jobName" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="jobName" with-tooltip position="middle" /> + </span> + <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info"> + {{ $options.i18n.api }} + </gl-badge> + </div> + <div + v-if="ref && !isTag" + class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" + > + <span class="gl-text-gray-500">{{ $options.i18n.branch }}</span> + <gl-link :href="refPath" class="gl-font-monospace gl-mt-3"> + <gl-truncate :text="refName" with-tooltip /> + </gl-link> + </div> + <div + v-if="hasTags || $apollo.queries.tags.loading" + class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0" + > + <span class="gl-text-gray-500">{{ $options.i18n.tags }}</span> + <gl-loading-icon + v-if="$apollo.queries.tags.loading" + class="gl-font-monospace gl-mt-3" + size="sm" + inline + /> + <div v-if="hasTags" class="gl-display-flex gl-flex-direction-row"> + <gl-link + v-for="(tag, ndx) in displayTags" + :key="tag.name" + :href="tag.path" + class="gl-font-monospace gl-mt-3 gl-mr-3" + > + {{ tag.name }}<span v-if="ndx + 1 < tags.length">, </span> </gl-link> + <div v-if="tags.length > 5" class="gl-font-monospace gl-mt-3 gl-mr-3">...</div> </div> </div> - </gl-collapse> + </div> </div> </template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 75bd473497b..9a100e0199e 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -310,6 +310,7 @@ export default { <div v-if="lastDeployment" :class="$options.deploymentClasses"> <deployment :deployment="lastDeployment" + :visible="visible" :class="{ 'gl-ml-7': inFolder }" latest class="gl-pl-4" diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 4e5fe511f8a..1a32de30de0 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import Translate from '~/vue_shared/translate'; -import environmentsFolderApp from './environments_folder_view.vue'; +import EnvironmentsFolderApp from './environments_folder_view.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -17,7 +17,7 @@ export default () => { return new Vue({ el, components: { - environmentsFolderApp, + EnvironmentsFolderApp, }, apolloProvider, provide: { diff --git a/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql new file mode 100644 index 00000000000..baed777bd07 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql @@ -0,0 +1,13 @@ +query getDeploymentDetails($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + id + deployment(iid: $iid) { + id + iid + tags { + name + path + } + } + } +} diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 645c2456c6e..93510870915 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -163,7 +163,6 @@ export default { v-gl-modal="'configure-feature-flags'" variant="confirm" category="secondary" - data-qa-selector="configure_feature_flags_button" data-testid="ff-configure-button" class="gl-mb-3" > diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index a8670caf5b2..a6781cffaec 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -81,6 +81,7 @@ export default class FilterableList { onFilterSuccess(response, queryData) { if (response.data.html) { + // eslint-disable-next-line no-unsanitized/property this.listHolderElement.innerHTML = response.data.html; } diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index 4c2f55fd174..679c8caffdb 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -69,14 +69,18 @@ export default { </script> <template> <div> - <div v-if="!isLocalStorageAvailable" ref="localStorageNote" class="dropdown-info-note"> + <div + v-if="!isLocalStorageAvailable" + data-testid="local-storage-note" + class="dropdown-info-note" + > {{ __('This feature requires local storage to be enabled') }} </div> <ul v-else-if="hasItems"> <li v-for="(item, index) in processedItems" - ref="dropdownItem" :key="`processed-items-${index}`" + data-testid="dropdown-item" > <button type="button" @@ -100,7 +104,7 @@ export default { <li class="divider"></li> <li> <button - ref="clearButton" + data-testid="clear-button" type="button" class="filtered-search-history-clear-button" @click="onRequestClearRecentSearches($event)" @@ -109,7 +113,7 @@ export default { </button> </li> </ul> - <div v-else ref="dropdownNote" class="dropdown-info-note"> + <div v-else data-testid="dropdown-note" class="dropdown-info-note"> {{ __("You don't have any recent searches") }} </div> </div> diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index 5adc074b3ce..aeea66bf51c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -75,6 +75,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown { const name = valueElement.innerText; const emojiTag = this.glEmojiTag(name); const emojiElement = dropdownItem.querySelector('gl-emoji'); + // eslint-disable-next-line no-unsanitized/property emojiElement.outerHTML = emojiTag; } }); diff --git a/app/assets/javascripts/filtered_search/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js index 398a7b26677..e7edc678773 100644 --- a/app/assets/javascripts/filtered_search/droplab/drop_down.js +++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js @@ -107,7 +107,7 @@ class DropDown { } const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; - + // eslint-disable-next-line no-unsanitized/property renderableList.innerHTML = children.join(''); const listEvent = new CustomEvent('render.dl', { @@ -121,7 +121,7 @@ class DropDown { renderChildren(data) { const html = utils.template(this.templateString, data); const template = document.createElement('div'); - + // eslint-disable-next-line no-unsanitized/property template.innerHTML = html; DropDown.setImagesSrc(template); template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block'; diff --git a/app/assets/javascripts/filtered_search/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js index c51d6167fa3..805905e7750 100644 --- a/app/assets/javascripts/filtered_search/droplab/hook_button.js +++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js @@ -42,6 +42,7 @@ class HookButton extends Hook { } restoreInitialState() { + // eslint-disable-next-line no-unsanitized/property this.list.list.innerHTML = this.list.initialState; } diff --git a/app/assets/javascripts/filtered_search/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js index c523dae347f..32dfe0372bb 100644 --- a/app/assets/javascripts/filtered_search/droplab/hook_input.js +++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js @@ -97,6 +97,7 @@ class HookInput extends Hook { } restoreInitialState() { + // eslint-disable-next-line no-unsanitized/property this.list.list.innerHTML = this.list.initialState; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 7143cb50ea6..0c01220a7be 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -122,6 +122,7 @@ export default class FilteredSearchVisualTokens { const hasOperator = Boolean(operator); if (value) { + // eslint-disable-next-line no-unsanitized/property li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ canEdit, uppercaseTokenName, @@ -138,6 +139,7 @@ export default class FilteredSearchVisualTokens { operatorHTML = '<div class="operator"></div>'; } + // eslint-disable-next-line no-unsanitized/property li.innerHTML = nameHTML + operatorHTML; } @@ -160,6 +162,8 @@ export default class FilteredSearchVisualTokens { if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) { const name = FilteredSearchVisualTokens.getLastTokenPartial(); const operator = FilteredSearchVisualTokens.getLastTokenOperator(); + + // eslint-disable-next-line no-unsanitized/property lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ hasOperator: Boolean(operator), }); @@ -293,6 +297,7 @@ export default class FilteredSearchVisualTokens { const button = lastVisualToken.querySelector('.selectable'); const valueContainer = lastVisualToken.querySelector('.value-container'); button.removeChild(valueContainer); + // eslint-disable-next-line no-unsanitized/property lastVisualToken.innerHTML = button.innerHTML; } else if (operator) { lastVisualToken.removeChild(operator); diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 707add10009..0d144398531 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -47,6 +47,7 @@ export default class VisualTokenValue { /* eslint-disable no-param-reassign */ tokenValueContainer.dataset.originalValue = tokenValue; + // eslint-disable-next-line no-unsanitized/property tokenValueElement.innerHTML = ` <img class="avatar s20" src="${user.avatar_url}" alt=""> ${escape(user.name)} @@ -152,6 +153,7 @@ export default class VisualTokenValue { } container.dataset.originalValue = value; + // eslint-disable-next-line no-unsanitized/property element.innerHTML = Emoji.glEmojiTag(value); }); } diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 5a47e76d597..edf83a33812 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -236,11 +236,13 @@ const createFlash = function createFlash({ if (!flashContainer) return null; + // eslint-disable-next-line no-unsanitized/property flashContainer.innerHTML = createFlashEl(message, type); const flashEl = flashContainer.querySelector(`.flash-${type}`); if (actionConfig) { + // eslint-disable-next-line no-unsanitized/method flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig)); if (actionConfig.clickHandler) { diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue index 1da0b88c9e9..c0bfcf9c4a9 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -58,7 +58,7 @@ export default { <template> <div class="frequent-items-list-container"> - <ul ref="frequentItemsList" class="list-unstyled"> + <ul data-testid="frequent-items-list" class="list-unstyled"> <li v-if="isListEmpty" :class="{ 'section-failure': isFetchFailed }" diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 9fb69a3cae3..33ab1d5cd7f 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -79,16 +79,19 @@ export default { :project-name="itemName" aria-hidden="true" /> - <div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container"> + <div + data-testid="frequent-items-item-metadata-container" + class="frequent-items-item-metadata-container" + > <div - ref="frequentItemsItemTitle" v-safe-html="highlightedItemName" + data-testid="frequent-items-item-title" :title="itemName" class="frequent-items-item-title" ></div> <div v-if="namespace" - ref="frequentItemsItemNamespace" + data-testid="frequent-items-item-namespace" :title="namespace" class="frequent-items-item-namespace" > diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js deleted file mode 100644 index e240a1116e8..00000000000 --- a/app/assets/javascripts/google_cloud/databases/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import Vue from 'vue'; -import Panel from './panel.vue'; - -export default (containerId = '#js-google-cloud-databases') => { - const element = document.querySelector(containerId); - const { ...attrs } = JSON.parse(element.getAttribute('data')); - return new Vue({ - el: element, - render: (createElement) => createElement(Panel, { attrs }), - }); -}; diff --git a/app/assets/javascripts/google_cloud/databases/init_index.js b/app/assets/javascripts/google_cloud/databases/init_index.js new file mode 100644 index 00000000000..931143833cb --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/init_index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default () => { + const element = document.querySelector('#js-google-cloud-databases'); + const attrs = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/databases/init_new.js b/app/assets/javascripts/google_cloud/databases/init_new.js new file mode 100644 index 00000000000..3feb2dc2f98 --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/init_new.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Form from './cloudsql/create_instance_form.vue'; + +export default () => { + const element = document.querySelector('#js-google-cloud-databases-cloudsql-form'); + const attrs = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Form, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue index e2f18c286a5..8b91c508871 100644 --- a/app/assets/javascripts/google_cloud/databases/panel.vue +++ b/app/assets/javascripts/google_cloud/databases/panel.vue @@ -1,11 +1,15 @@ <script> import GoogleCloudMenu from '../components/google_cloud_menu.vue'; import IncubationBanner from '../components/incubation_banner.vue'; +import InstanceTable from './cloudsql/instance_table.vue'; +import ServiceTable from './service_table.vue'; export default { components: { IncubationBanner, + InstanceTable, GoogleCloudMenu, + ServiceTable, }, props: { configurationUrl: { @@ -20,6 +24,26 @@ export default { type: String, required: true, }, + cloudsqlPostgresUrl: { + type: String, + required: true, + }, + cloudsqlMysqlUrl: { + type: String, + required: true, + }, + cloudsqlSqlserverUrl: { + type: String, + required: true, + }, + cloudsqlInstances: { + type: Array, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, }, }; </script> @@ -34,5 +58,19 @@ export default { :deployments-url="deploymentsUrl" :databases-url="databasesUrl" /> + + <service-table + alloydb-postgres-url="#" + :cloudsql-mysql-url="cloudsqlMysqlUrl" + :cloudsql-postgres-url="cloudsqlPostgresUrl" + :cloudsql-sqlserver-url="cloudsqlSqlserverUrl" + firestore-url="#" + memorystore-redis-url="#" + /> + + <instance-table + :cloudsql-instances="cloudsqlInstances" + :empty-illustration-url="emptyIllustrationUrl" + /> </div> </template> diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index c8204f397ff..5b0bcfa963b 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -140,17 +140,6 @@ export const trackSaasTrialGroup = () => { }); }; -export const trackSaasTrialProject = () => { - if (!isSupported()) { - return; - } - - const form = document.getElementById('new_project'); - form.addEventListener('submit', () => { - pushEvent('saasTrialProject'); - }); -}; - export const trackProjectImport = () => { if (!isSupported()) { return; @@ -290,3 +279,11 @@ export const trackCombinedGroupProjectForm = () => { pushEvent('combinedGroupProjectFormSubmit'); }); }; + +export const trackCompanyForm = (aboutYourCompanyType) => { + if (!isSupported()) { + return; + } + + pushEvent('aboutYourCompanyFormSubmit', { aboutYourCompanyType }); +}; diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql index fb771d7ec8a..45dbfb30704 100644 --- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql @@ -1,4 +1,5 @@ fragment TimelogFragment on Timelog { + __typename id timeSpent user { diff --git a/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql new file mode 100644 index 00000000000..dbe6ad9f059 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql" + +fragment IssueTimeTrackingFragment on Issue { + __typename + id + humanTotalTimeSpent + totalTimeSpent + timelogs { + nodes { + ...TimelogFragment + } + } +} diff --git a/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql new file mode 100644 index 00000000000..68d3c02cf2e --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql" + +fragment MergeRequestTimeTrackingFragment on MergeRequest { + __typename + id + humanTotalTimeSpent + totalTimeSpent + timelogs { + nodes { + ...TimelogFragment + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/graphql_shared/issuable_client.js index b70c06fddea..e86103c332b 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -1,10 +1,11 @@ import produce from 'immer'; -import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { concatPagination } from '@apollo/client/utilities'; +import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; -import { WIDGET_TYPE_LABELS } from '../constants'; -import typeDefs from './typedefs.graphql'; -import workItemQuery from './work_item.query.graphql'; +import typeDefs from '~/work_items/graphql/typedefs.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import { WIDGET_TYPE_LABELS } from '~/work_items/constants'; export const temporaryConfig = { typeDefs, @@ -13,6 +14,13 @@ export const temporaryConfig = { LocalWorkItemWidget: ['LocalWorkItemLabels'], }, typePolicies: { + Project: { + fields: { + projectMembers: { + keyArgs: ['fullPath', 'search', 'relations', 'first'], + }, + }, + }, WorkItem: { fields: { mockWidgets: { @@ -36,12 +44,24 @@ export const temporaryConfig = { }, }, }, + MemberInterfaceConnection: { + fields: { + nodes: concatPagination(), + }, + }, }, }, }; export const resolvers = { Mutation: { + updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { + const sourceData = cache.readQuery({ query: getIssueStateQuery }); + const data = produce(sourceData, (draftData) => { + draftData.issueState = { issueType, isDirty }; + }); + cache.writeQuery({ query: getIssueStateQuery, data }); + }, localUpdateWorkItem(_, { input }, { cache }) { const sourceData = cache.readQuery({ query: workItemQuery, @@ -66,12 +86,8 @@ export const resolvers = { }, }; -export function createApolloProvider() { - Vue.use(VueApollo); - - const defaultClient = createDefaultClient(resolvers, temporaryConfig); +export const defaultClient = createDefaultClient(resolvers, temporaryConfig); - return new VueApollo({ - defaultClient, - }); -} +export const apolloProvider = new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index eac325f184f..72dbf9e7b7b 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -140,6 +140,7 @@ "WorkItemWidgetAssignees", "WorkItemWidgetDescription", "WorkItemWidgetHierarchy", + "WorkItemWidgetIteration", "WorkItemWidgetLabels", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetVerificationStatus", diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql index 0c0a874d950..0c0a874d950 100644 --- a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index bb34e4032f4..f64c4276deb 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -1,10 +1,20 @@ #import "../fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user_availability.fragment.graphql" -query projectUsersSearch($search: String!, $fullPath: ID!) { +query projectUsersSearch($search: String!, $fullPath: ID!, $after: String, $first: Int) { workspace: project(fullPath: $fullPath) { id - users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { + users: projectMembers( + search: $search + relations: [DIRECT, INHERITED, INVITED_GROUPS] + first: $first + after: $after + ) { + pageInfo { + hasNextPage + endCursor + startCursor + } nodes { id user { diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index cd5521c599e..0bd7371d39b 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -17,11 +17,6 @@ export default { GlLoadingIcon, EmptyState, }, - inject: { - renderEmptyState: { - default: false, - }, - }, props: { action: { type: String, @@ -45,6 +40,11 @@ export default { type: Boolean, required: true, }, + renderEmptyState: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -224,6 +224,9 @@ export default { }, showLegacyEmptyState() { const { containerEl } = this; + + if (!containerEl) return; + const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); const emptyStateEl = containerEl.querySelector('.empty-state'); diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 2f182b86d2c..961af800971 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -16,15 +16,15 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; -import { VISIBILITY_LEVELS_ENUM } from '~/visibility_level/constants'; +import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants'; import eventHub from '../event_hub'; -import itemActions from './item_actions.vue'; -import itemCaret from './item_caret.vue'; -import itemStats from './item_stats.vue'; -import itemTypeIcon from './item_type_icon.vue'; +import ItemActions from './item_actions.vue'; +import ItemCaret from './item_caret.vue'; +import ItemStats from './item_stats.vue'; +import ItemTypeIcon from './item_type_icon.vue'; export default { directives: { @@ -41,10 +41,10 @@ export default { GlPopover, GlLink, UserAccessRoleBadge, - itemCaret, - itemTypeIcon, - itemActions, - itemStats, + ItemCaret, + ItemTypeIcon, + ItemActions, + ItemStats, }, inject: ['currentGroupVisibility'], props: { @@ -111,8 +111,8 @@ export default { shouldShowVisibilityWarning() { return ( this.action === 'shared' && - VISIBILITY_LEVELS_ENUM[this.group.visibility] > - VISIBILITY_LEVELS_ENUM[this.currentGroupVisibility] + VISIBILITY_LEVELS_STRING_TO_INTEGER[this.group.visibility] > + VISIBILITY_LEVELS_STRING_TO_INTEGER[this.currentGroupVisibility] ); }, }, diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 2aa812250a0..a4c163b0a81 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -1,19 +1,19 @@ <script> import { GlBadge } from '@gitlab/ui'; import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE, } from '../constants'; -import itemStatsValue from './item_stats_value.vue'; +import ItemStatsValue from './item_stats_value.vue'; export default { components: { - timeAgoTooltip, - itemStatsValue, + TimeAgoTooltip, + ItemStatsValue, GlBadge, }, mixins: [isProjectPendingRemoval], diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue new file mode 100644 index 00000000000..325e42af0f8 --- /dev/null +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -0,0 +1,103 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import { isString } from 'lodash'; +import { __ } from '~/locale'; +import GroupsStore from '../store/groups_store'; +import GroupsService from '../service/groups_service'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, +} from '../constants'; +import GroupsApp from './app.vue'; + +export default { + components: { GlTabs, GlTab, GroupsApp }, + inject: ['endpoints'], + data() { + return { + tabs: [ + { + title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], + key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + renderEmptyState: true, + lazy: false, + service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + store: new GroupsStore({ showSchemaMarkup: true }), + }, + { + title: this.$options.i18n[ACTIVE_TAB_SHARED], + key: ACTIVE_TAB_SHARED, + renderEmptyState: false, + lazy: true, + service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), + store: new GroupsStore(), + }, + { + title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], + key: ACTIVE_TAB_ARCHIVED, + renderEmptyState: false, + lazy: true, + service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), + store: new GroupsStore(), + }, + ], + activeTabIndex: 0, + }; + }, + mounted() { + const activeTabIndex = this.tabs.findIndex((tab) => tab.key === this.$route.name); + + if (activeTabIndex === -1) { + return; + } + + this.activeTabIndex = activeTabIndex; + }, + methods: { + handleTabInput(tabIndex) { + if (tabIndex === this.activeTabIndex) { + return; + } + + this.activeTabIndex = tabIndex; + + const tab = this.tabs[tabIndex]; + tab.lazy = false; + + // Vue router will convert `/` to `%2F` if you pass a string as a param + // If you pass an array as a param it will concatenate them with a `/` + // This makes sure we are always passing an array for the group param + const groupParam = isString(this.$route.params.group) + ? this.$route.params.group.split('/') + : this.$route.params.group; + + this.$router.push({ name: tab.key, params: { group: groupParam } }); + }, + }, + i18n: { + [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: __('Subgroups and projects'), + [ACTIVE_TAB_SHARED]: __('Shared projects'), + [ACTIVE_TAB_ARCHIVED]: __('Archived projects'), + }, +}; +</script> + +<template> + <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput"> + <gl-tab + v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs" + :key="key" + :title="title" + :lazy="lazy" + > + <groups-app + :action="key" + :service="service" + :store="store" + :hide-projects="false" + :render-empty-state="renderEmptyState" + /> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue deleted file mode 100644 index 0933045fc38..00000000000 --- a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; - -export default { - components: { - GlDropdown, - GlDropdownItem, - }, - props: { - visibilityLevelOptions: { - type: Array, - required: true, - }, - defaultLevel: { - type: Number, - required: true, - }, - }, - data() { - return { - selectedOption: this.getDefaultOption(), - }; - }, - methods: { - getDefaultOption() { - return this.visibilityLevelOptions.find((option) => option.level === this.defaultLevel); - }, - onClick(option) { - this.selectedOption = option; - }, - }, -}; -</script> -<template> - <div> - <input type="hidden" name="group[visibility_level]" :value="selectedOption.level" /> - <gl-dropdown :text="selectedOption.label" class="gl-w-full" menu-class="gl-w-full! gl-mb-0"> - <gl-dropdown-item - v-for="option in visibilityLevelOptions" - :key="option.level" - :secondary-text="option.description" - @click="onClick(option)" - > - <div class="gl-font-weight-bold gl-mb-1">{{ option.label }}</div> - </gl-dropdown-item> - </gl-dropdown> - </div> -</template> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 0d09ad9442b..223c2975c11 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -1,8 +1,8 @@ import { __, s__ } from '~/locale'; import { - VISIBILITY_LEVEL_PRIVATE, - VISIBILITY_LEVEL_INTERNAL, - VISIBILITY_LEVEL_PUBLIC, + VISIBILITY_LEVEL_PRIVATE_STRING, + VISIBILITY_LEVEL_INTERNAL_STRING, + VISIBILITY_LEVEL_PUBLIC_STRING, } from '~/visibility_level/constants'; export const MAX_CHILDREN_COUNT = 20; @@ -34,29 +34,31 @@ export const ITEM_TYPE = { }; export const GROUP_VISIBILITY_TYPE = { - [VISIBILITY_LEVEL_PUBLIC]: __( + [VISIBILITY_LEVEL_PUBLIC_STRING]: __( 'Public - The group and any public projects can be viewed without any authentication.', ), - [VISIBILITY_LEVEL_INTERNAL]: __( + [VISIBILITY_LEVEL_INTERNAL_STRING]: __( 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', ), - [VISIBILITY_LEVEL_PRIVATE]: __( + [VISIBILITY_LEVEL_PRIVATE_STRING]: __( 'Private - The group and its projects can only be viewed by members.', ), }; export const PROJECT_VISIBILITY_TYPE = { - [VISIBILITY_LEVEL_PUBLIC]: __('Public - The project can be accessed without any authentication.'), - [VISIBILITY_LEVEL_INTERNAL]: __( + [VISIBILITY_LEVEL_PUBLIC_STRING]: __( + 'Public - The project can be accessed without any authentication.', + ), + [VISIBILITY_LEVEL_INTERNAL_STRING]: __( 'Internal - The project can be accessed by any logged in user except external users.', ), - [VISIBILITY_LEVEL_PRIVATE]: __( + [VISIBILITY_LEVEL_PRIVATE_STRING]: __( 'Private - 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.', ), }; export const VISIBILITY_TYPE_ICON = { - [VISIBILITY_LEVEL_PUBLIC]: 'earth', - [VISIBILITY_LEVEL_INTERNAL]: 'shield', - [VISIBILITY_LEVEL_PRIVATE]: 'lock', + [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth', + [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield', + [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock', }; diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index a502fcd31ad..c3bf3f28509 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -4,9 +4,9 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; import Translate from '../vue_shared/translate'; -import groupsApp from './components/app.vue'; -import groupFolderComponent from './components/group_folder.vue'; -import groupItemComponent from './components/group_item.vue'; +import GroupsApp from './components/app.vue'; +import GroupFolderComponent from './components/group_folder.vue'; +import GroupItemComponent from './components/group_item.vue'; import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants'; import GroupFilterableList from './groups_filterable_list'; import GroupsService from './service/groups_service'; @@ -33,8 +33,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); } - Vue.component('GroupFolder', groupFolderComponent); - Vue.component('GroupItem', groupItemComponent); + Vue.component('GroupFolder', GroupFolderComponent); + Vue.component('GroupItem', GroupItemComponent); Vue.use(GlToast); @@ -42,7 +42,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { new Vue({ el, components: { - groupsApp, + GroupsApp, }, provide() { const { @@ -52,7 +52,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newSubgroupIllustration, newProjectIllustration, emptySubgroupIllustration, - renderEmptyState, canCreateSubgroups, canCreateProjects, currentGroupVisibility, @@ -65,7 +64,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newSubgroupIllustration, newProjectIllustration, emptySubgroupIllustration, - renderEmptyState: parseBoolean(renderEmptyState), canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), currentGroupVisibility, @@ -75,6 +73,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { const { dataset } = dataEl || this.$options.el; const hideProjects = parseBoolean(dataset.hideProjects); const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); + const renderEmptyState = parseBoolean(dataset.renderEmptyState); const service = new GroupsService(endpoint || dataset.endpoint); const store = new GroupsStore({ hideProjects, showSchemaMarkup }); @@ -83,6 +82,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { store, service, hideProjects, + renderEmptyState, loading: true, containerId, }; @@ -119,6 +119,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { store: this.store, service: this.service, hideProjects: this.hideProjects, + renderEmptyState: this.renderEmptyState, containerId: this.containerId, }, }); diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js new file mode 100644 index 00000000000..4fa3682c729 --- /dev/null +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { GlToast } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import GroupFolder from './components/group_folder.vue'; +import GroupItem from './components/group_item.vue'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, +} from './constants'; +import OverviewTabs from './components/overview_tabs.vue'; + +export const createRouter = () => { + const routes = [ + { name: ACTIVE_TAB_SHARED, path: '/groups/:group*/-/shared' }, + { name: ACTIVE_TAB_ARCHIVED, path: '/groups/:group*/-/archived' }, + { name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, path: '/:group*' }, + ]; + + const router = new VueRouter({ + routes, + mode: 'history', + base: '/', + }); + + return router; +}; + +export const initGroupOverviewTabs = () => { + const el = document.getElementById('js-group-overview-tabs'); + + if (!el) return false; + + Vue.component('GroupFolder', GroupFolder); + Vue.component('GroupItem', GroupItem); + Vue.use(GlToast); + Vue.use(VueRouter); + + const router = createRouter(); + + const { + newSubgroupPath, + newProjectPath, + newSubgroupIllustration, + newProjectIllustration, + emptySubgroupIllustration, + canCreateSubgroups, + canCreateProjects, + currentGroupVisibility, + subgroupsAndProjectsEndpoint, + sharedProjectsEndpoint, + archivedProjectsEndpoint, + } = el.dataset; + + return new Vue({ + el, + router, + provide: { + newSubgroupPath, + newProjectPath, + newSubgroupIllustration, + newProjectIllustration, + emptySubgroupIllustration, + canCreateSubgroups: parseBoolean(canCreateSubgroups), + canCreateProjects: parseBoolean(canCreateProjects), + currentGroupVisibility, + endpoints: { + [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: subgroupsAndProjectsEndpoint, + [ACTIVE_TAB_SHARED]: sharedProjectsEndpoint, + [ACTIVE_TAB_ARCHIVED]: archivedProjectsEndpoint, + }, + }, + render(createElement) { + return createElement(OverviewTabs); + }, + }); +}; diff --git a/app/assets/javascripts/groups/visibility_level.js b/app/assets/javascripts/groups/visibility_level.js deleted file mode 100644 index d570b5e65ac..00000000000 --- a/app/assets/javascripts/groups/visibility_level.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue'; -import VisibilityLevelDropdown from './components/visibility_level_dropdown.vue'; - -export default () => { - const el = document.querySelector('.js-visibility-level-dropdown'); - - if (!el) { - return null; - } - - const { visibilityLevelOptions, defaultLevel } = el.dataset; - - return new Vue({ - el, - render(createElement) { - return createElement(VisibilityLevelDropdown, { - props: { - visibilityLevelOptions: JSON.parse(visibilityLevelOptions), - defaultLevel: Number(defaultLevel), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 3a20fb0216d..332ccee510f 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -26,11 +26,17 @@ export const GROUPS_CATEGORY = s__('GlobalSearch|Groups'); export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects'); -export const ISSUES_CATEGORY = 'Recent issues'; +export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues'); -export const MERGE_REQUEST_CATEGORY = 'Recent merge requests'; +export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests'); -export const RECENT_EPICS_CATEGORY = 'Recent epics'; +export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics'); + +export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project'); + +export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings'); + +export const HELP_CATEGORY = s__('GlobalSearch|Help'); export const LARGE_AVATAR_PX = 32; @@ -55,3 +61,16 @@ export const HEADER_INIT_EVENTS = ['input', 'focus']; export const IS_SEARCHING = 'is-searching'; export const IS_FOCUSED = 'is-focused'; export const IS_NOT_FOCUSED = 'is-not-focused'; + +export const DROPDOWN_ORDER = [ + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + IN_THIS_PROJECT_CATEGORY, + SETTINGS_CATEGORY, + HELP_CATEGORY, +]; + +export const FETCH_TYPES = ['generic', 'search']; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index 3a86dcca409..a0f9e594506 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -1,10 +1,26 @@ +import { omitBy, isNil } from 'lodash'; +import { objectToQuery } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; +import { FETCH_TYPES } from '../constants'; import * as types from './mutation_types'; -export const fetchAutocompleteOptions = ({ commit, getters }) => { - commit(types.REQUEST_AUTOCOMPLETE); +export const autocompleteQuery = ({ state, fetchType }) => { + const query = omitBy( + { + term: state.search, + project_id: state.searchContext?.project?.id, + project_ref: state.searchContext?.ref, + filter: fetchType, + }, + isNil, + ); + + return `${state.autocompletePath}?${objectToQuery(query)}`; +}; + +const doFetch = ({ commit, state, fetchType }) => { return axios - .get(getters.autocompleteQuery) + .get(autocompleteQuery({ state, fetchType })) .then(({ data }) => { commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data); }) @@ -13,6 +29,13 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => { }); }; +export const fetchAutocompleteOptions = ({ commit, state }) => { + commit(types.REQUEST_AUTOCOMPLETE); + const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType })); + + return Promise.all(promises); +}; + export const clearAutocomplete = ({ commit }) => { commit(types.CLEAR_AUTOCOMPLETE); }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index da7bccd35c0..3da9d2cd961 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -14,6 +14,7 @@ import { PROJECTS_CATEGORY, GROUPS_CATEGORY, SEARCH_SHORTCUTS_MIN_CHARACTERS, + DROPDOWN_ORDER, } from '../constants'; export const searchQuery = (state) => { @@ -34,19 +35,6 @@ export const searchQuery = (state) => { return `${state.searchPath}?${objectToQuery(query)}`; }; -export const autocompleteQuery = (state) => { - const query = omitBy( - { - term: state.search, - project_id: state.searchContext?.project?.id, - project_ref: state.searchContext?.ref, - }, - isNil, - ); - - return `${state.autocompletePath}?${objectToQuery(query)}`; -}; - export const scopedIssuesPath = (state) => { return ( state.searchContext?.project_metadata?.issues_path || @@ -197,7 +185,9 @@ export const autocompleteGroupedSearchOptions = (state) => { } }); - return results; + return results.sort( + (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category), + ); }; export const searchOptions = (state, getters) => { diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js index 92948bec515..19b4d4ec330 100644 --- a/app/assets/javascripts/header_search/store/mutations.js +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -8,9 +8,11 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = data.map((d, i) => { - return { html_id: `autocomplete-${d.category}-${i}`, ...d }; - }); + state.autocompleteOptions = [...state.autocompleteOptions].concat( + data.map((d, i) => { + return { html_id: `autocomplete-${d.category}-${i}`, ...d }; + }), + ); state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 52593aabfea..d40aab8ee4f 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -50,7 +50,7 @@ export default { <gl-dropdown-item v-for="mode in modeDropdownItems" :key="mode.viewerType" - :is-check-item="true" + is-check-item :is-checked="viewer === mode.viewerType" @click="changeMode(mode.viewerType)" > diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index e0b7ac9b1e1..8962bb76926 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -4,7 +4,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import { rightSidebarViews } from '../constants'; import IdeStatusList from './ide_status_list.vue'; import IdeStatusMr from './ide_status_mr.vue'; @@ -12,7 +12,7 @@ import IdeStatusMr from './ide_status_mr.vue'; export default { components: { GlIcon, - userAvatarImage, + UserAvatarImage, CiIcon, IdeStatusList, IdeStatusMr, diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 87b60eca73c..9a529bdcee1 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -4,12 +4,12 @@ import { mapActions } from 'vuex'; import { modalTypes } from '../../constants'; import ItemButton from './button.vue'; import NewModal from './modal.vue'; -import upload from './upload.vue'; +import Upload from './upload.vue'; export default { components: { GlIcon, - upload, + Upload, ItemButton, NewModal, }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index d6207d4a557..9684bf8f18c 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -176,7 +176,11 @@ export default { :placeholder="placeholder" /> </form> - <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list"> + <ul + v-if="isCreatingNewFile" + class="file-templates gl-mt-3 list-inline" + data-qa-selector="template_list_content" + > <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <gl-button variant="dashed" diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index df643675357..10e9f6a9488 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -8,6 +8,7 @@ import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import ide from './components/ide.vue'; import { createRouter } from './ide_router'; +import { initGitlabWebIDE } from './init_gitlab_web_ide'; import { DEFAULT_THEME } from './lib/themes'; import { createStore } from './stores'; @@ -34,7 +35,7 @@ Vue.use(PerformancePlugin, { * @param {extendStoreCallback} options.extendStore - * Function that receives the default store and returns an extended one. */ -export const initIde = (el, options = {}) => { +export const initLegacyWebIDE = (el, options = {}) => { if (!el) return null; const { rootComponent = ide, extendStore = identity } = options; @@ -93,8 +94,15 @@ export const initIde = (el, options = {}) => { */ export function startIde(options) { const ideElement = document.getElementById('ide'); - if (ideElement) { + + if (!ideElement) { + return; + } + + if (gon.features?.vscodeWebIde) { + initGitlabWebIDE(ideElement); + } else { resetServiceWorkersPublicPath(); - initIde(ideElement, options); + initLegacyWebIDE(ideElement, options); } } diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js new file mode 100644 index 00000000000..a061da38d4f --- /dev/null +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -0,0 +1,30 @@ +import { cleanTrailingSlash } from './stores/utils'; + +export const initGitlabWebIDE = async (el) => { + const { start } = await import('@gitlab/web-ide'); + + const { gitlab_url: gitlabUrl } = window.gon; + const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); + + // what: Pull what we need from the element. We will replace it soon. + const { path_with_namespace: projectPath } = JSON.parse(el.dataset.project); + const { cspNonce: nonce, branchName: ref } = el.dataset; + + // what: Clean up the element, but preserve id. + // why: This way we don't inherit any `ide-loading` side-effects. This + // mirrors the behavior of Vue when it mounts to an element. + const newEl = document.createElement(el.tagName); + newEl.id = el.id; + newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full'); + + el.replaceWith(newEl); + + // what: Trigger start on our new mounting element + await start(newEl, { + baseUrl: cleanTrailingSlash(baseUrl.href), + projectPath, + gitlabUrl, + ref, + nonce, + }); +}; diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js index 5ff00394e3b..35d8ec32bdf 100644 --- a/app/assets/javascripts/image_diff/helpers/badge_helper.js +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -30,6 +30,7 @@ export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { export function addImageCommentBadge(containerEl, { coordinate, noteId }) { const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']); + // eslint-disable-next-line no-unsanitized/property buttonEl.innerHTML = spriteIcon('image-comment-dark'); containerEl.appendChild(buttonEl); diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js index deaef686f59..2b5cb70737f 100644 --- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -8,6 +8,7 @@ export function addCommentIndicator(containerEl, { x, y }) { buttonEl.style.left = `${x}px`; buttonEl.style.top = `${y}px`; + // eslint-disable-next-line no-unsanitized/property buttonEl.innerHTML = spriteIcon('image-comment-dark'); containerEl.appendChild(buttonEl); diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index 87f1ed31a7f..a334f5e4bf7 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -118,6 +118,7 @@ export default { selectedAccessLevel: undefined, errorsLimit: 2, isErrorsSectionExpanded: false, + emptyInvitesError: false, }; }, computed: { @@ -133,8 +134,8 @@ export default { labelIntroText() { return this.$options.labels[this.inviteTo][this.mode].introText; }, - inviteDisabled() { - return this.newUsersToInvite.length === 0; + isEmptyInvites() { + return Boolean(this.newUsersToInvite.length); }, hasInvalidMembers() { return !isEmpty(this.invalidMembers); @@ -219,6 +220,18 @@ export default { }); }, }, + watch: { + isEmptyInvites: { + handler(updatedValue) { + // nothing to do if the invites are **still** empty and the emptyInvites were never set from submit + if (!updatedValue && !this.emptyInvitesError) { + return; + } + + this.clearEmptyInviteError(); + }, + }, + }, mounted() { eventHub.$on('openModal', (options) => { this.openModal(options); @@ -260,10 +273,19 @@ export default { const tracking = new ExperimentTracking(experimentName); tracking.event(eventName); }, + showEmptyInvitesError() { + this.invalidFeedbackMessage = this.$options.labels.emptyInvitesErrorText; + this.emptyInvitesError = true; + }, sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; this.clearValidation(); + if (!this.isEmptyInvites) { + this.showEmptyInvitesError(); + return; + } + const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const apiAddByInvite = this.isProject @@ -338,6 +360,10 @@ export default { this.invalidFeedbackMessage = ''; this.invalidMembers = {}; }, + clearEmptyInviteError() { + this.invalidFeedbackMessage = ''; + this.emptyInvitesError = false; + }, removeToken(token) { delete this.invalidMembers[memberName(token)]; this.invalidMembers = { ...this.invalidMembers }; @@ -360,7 +386,6 @@ export default { :label-intro-text="labelIntroText" :label-search-field="$options.labels.searchField" :form-group-description="formGroupDescription" - :submit-disabled="inviteDisabled" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" :new-users-to-invite="newUsersToInvite" diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue index 6c9b1f8e6d0..c3d9d959ef6 100644 --- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue +++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue @@ -8,8 +8,6 @@ import { REACHED_LIMIT_MESSAGE, REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, CLOSE_TO_LIMIT_MESSAGE, - CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE, - DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, } from '../constants'; export default { @@ -52,13 +50,6 @@ export default { }); }, dangerAlertTitle() { - if (this.usersLimitDataset.userNamespace) { - return sprintf(DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, { - count: this.freeUsersLimit, - members: this.pluralMembers(this.freeUsersLimit), - }); - } - return sprintf(DANGER_ALERT_TITLE, { count: this.freeUsersLimit, members: this.pluralMembers(this.freeUsersLimit), @@ -71,20 +62,9 @@ export default { title() { return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle; }, - reachedLimitMessage() { - if (this.usersLimitDataset.userNamespace) { - return this.$options.i18n.reachedLimitMessage; - } - - return this.$options.i18n.reachedLimitUpgradeSuggestionMessage; - }, message() { if (this.reachedLimit) { - return this.reachedLimitMessage; - } - - if (this.usersLimitDataset.userNamespace) { - return this.$options.i18n.closeToLimitMessagePersonalNamespace; + return this.$options.i18n.reachedLimitUpgradeSuggestionMessage; } return this.$options.i18n.closeToLimitMessage; @@ -99,7 +79,6 @@ export default { reachedLimitMessage: REACHED_LIMIT_MESSAGE, reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE, - closeToLimitMessagePersonalNamespace: CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE, }, }; </script> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 1ceb63e2146..f502e1ea369 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -81,6 +81,9 @@ export const MEMBER_ERROR_LIST_TEXT = s__( ); export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})'); export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less'); +export const EMPTY_INVITES_ERROR_TEXT = s__( + 'InviteMembersModal|Please select members or type email addresses to invite', +); export const MEMBER_MODAL_LABELS = { modal: { @@ -119,6 +122,7 @@ export const MEMBER_MODAL_LABELS = { memberErrorListText: MEMBER_ERROR_LIST_TEXT, collapsedErrors: COLLAPSED_ERRORS, expandedErrors: EXPANDED_ERRORS, + emptyInvitesErrorText: EMPTY_INVITES_ERROR_TEXT, }; export const GROUP_MODAL_LABELS = { @@ -146,10 +150,6 @@ export const DANGER_ALERT_TITLE = s__( "InviteMembersModal|You've reached your %{count} %{members} limit for %{name}", ); -export const DANGER_ALERT_TITLE_PERSONAL_NAMESPACE = s__( - "InviteMembersModal|You've reached your %{count} %{members} limit for your personal projects", -); - export const REACHED_LIMIT_MESSAGE = s__( 'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.', ); @@ -163,6 +163,3 @@ export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.co export const CLOSE_TO_LIMIT_MESSAGE = s__( 'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', ); -export const CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE = s__( - 'InviteMembersModal|To make more space, you can remove members who no longer need access.', -); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 6e2c0ecb5bb..a4be3f205a3 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -20,8 +20,6 @@ export default (function initInviteMembersModal() { return false; } - const usersLimitDataset = JSON.parse(el.dataset.usersLimitDataset || '{}'); - inviteMembersModal = new Vue({ el, name: 'InviteMembersModalRoot', @@ -40,10 +38,9 @@ export default (function initInviteMembersModal() { projects: JSON.parse(el.dataset.projects || '[]'), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), - usersLimitDataset: convertObjectPropsToCamelCase({ - ...usersLimitDataset, - user_namespace: parseBoolean(usersLimitDataset.user_namespace), - }), + usersLimitDataset: convertObjectPropsToCamelCase( + JSON.parse(el.dataset.usersLimitDataset || '{}'), + ), }, }), }); diff --git a/app/assets/javascripts/issuable/components/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue index 5955f31fc70..21f35690f6d 100644 --- a/app/assets/javascripts/issuable/components/issue_assignees.vue +++ b/app/assets/javascripts/issuable/components/issue_assignees.vue @@ -91,7 +91,7 @@ export default { data-qa-selector="assignee_link" > <span class="js-assignee-tooltip"> - <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }} + <span class="bold d-block">{{ s__('Label|Assignee') }}</span> {{ assignee.name }} <span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span> </span> </user-avatar-link> diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 667c712d3be..8894e8f63b8 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -11,6 +11,7 @@ import { import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -80,6 +81,9 @@ export default { methods: { handleTitleClick(event) { if (this.workItemType === 'TASK') { + if (isMetaKey(event)) { + return; + } event.preventDefault(); this.$refs.modal.show(); this.updateWorkItemIdUrlQuery(this.idKey); diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index d72ee5c6757..6c4ffc44444 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -65,7 +65,7 @@ export default { data() { if (!this.iid) return { state: this.initialState }; - if (this.initialState) { + if (this.initialState && !badgeState.state) { badgeState.state = this.initialState; } diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index cc2608b5c62..81bf7ca6ccc 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -39,12 +39,26 @@ function format(searchTerm, isFallbackKey = false) { return formattedQuery; } +function getSearchTerm(newIssuePath) { + const { search, pathname } = document.location; + return newIssuePath === pathname ? '' : format(search); +} + function getFallbackKey() { const searchTerm = format(document.location.search, true); return ['autosave', document.location.pathname, searchTerm].join('/'); } export default class IssuableForm { + static addAutosave(map, id, $input, searchTerm, fallbackKey) { + if ($input.length) { + map.set( + id, + new Autosave($input, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`), + ); + } + } + constructor(form) { if (form.length === 0) { return; @@ -72,14 +86,15 @@ export default class IssuableForm { this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search'); this.zenMode = new ZenMode(); - this.newIssuePath = form[0].getAttribute(DATA_ISSUES_NEW_PATH); + this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH)); + this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); this.descriptionField = this.form.find('textarea[name*="[description]"]'); if (!(this.titleField.length && this.descriptionField.length)) { return; } - this.initAutosave(); + this.autosaves = this.initAutosave(); this.form.on('submit', this.handleSubmit); this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave); this.form.find('.js-unwrap-on-load').unwrap(); @@ -95,7 +110,10 @@ export default class IssuableForm { container: $issuableDueDate.parent().get(0), parse: (dateString) => parsePikadayDate(dateString), toString: (date) => pikadayToString(date), - onSelect: (dateText) => $issuableDueDate.val(calendar.toString(dateText)), + onSelect: (dateText) => { + $issuableDueDate.val(calendar.toString(dateText)); + if (this.autosaves.has('due_date')) this.autosaves.get('due_date').save(); + }, firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($issuableDueDate.val())); @@ -109,21 +127,37 @@ export default class IssuableForm { } initAutosave() { - const { search, pathname } = document.location; - const searchTerm = this.newIssuePath === pathname ? '' : format(search); - const fallbackKey = getFallbackKey(); - - this.autosave = new Autosave( - this.titleField, - [document.location.pathname, searchTerm, 'title'], - `${fallbackKey}=title`, + const autosaveMap = new Map(); + IssuableForm.addAutosave( + autosaveMap, + 'title', + this.form.find('input[name*="[title]"]'), + this.searchTerm, + this.fallbackKey, ); - - return new Autosave( - this.descriptionField, - [document.location.pathname, searchTerm, 'description'], - `${fallbackKey}=description`, + IssuableForm.addAutosave( + autosaveMap, + 'description', + this.form.find('textarea[name*="[description]"]'), + this.searchTerm, + this.fallbackKey, + ); + IssuableForm.addAutosave( + autosaveMap, + 'confidential', + this.form.find('input:checkbox[name*="[confidential]"]'), + this.searchTerm, + this.fallbackKey, ); + IssuableForm.addAutosave( + autosaveMap, + 'due_date', + this.form.find('input[name*="[due_date]"]'), + this.searchTerm, + this.fallbackKey, + ); + + return autosaveMap; } handleSubmit() { @@ -131,8 +165,9 @@ export default class IssuableForm { } resetAutosave() { - this.titleField.data('autosave').reset(); - return this.descriptionField.data('autosave').reset(); + this.autosaves.forEach((autosaveItem) => { + autosaveItem?.reset(); + }); } initWip() { diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 11911adb401..0b424d105b9 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -24,6 +24,7 @@ import axios from '~/lib/utils/axios_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { DEFAULT_NONE_ANY, OPERATOR_IS_ONLY, @@ -37,6 +38,7 @@ import { TOKEN_TITLE_ORGANIZATION, TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, + FILTERED_SEARCH_TERM, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; @@ -462,6 +464,12 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, + issuesHelpPagePath() { + return helpPagePath('user/project/issues/index'); + }, + shouldDisableSomeFilters() { + return this.isAnonymousSearchDisabled && !this.isSignedIn; + }, }, watch: { $route(newValue, oldValue) { @@ -578,13 +586,9 @@ export default { this.issuesError = null; }, handleFilter(filter) { - if (this.isAnonymousSearchDisabled && !this.isSignedIn) { - this.showAnonymousSearchingMessage(); - return; - } + this.setFilterTokens(filter); this.pageParams = getInitialPageParams(this.pageSize); - this.filterTokens = filter; this.$router.push({ query: this.urlParams }); }, @@ -674,6 +678,28 @@ export default { Sentry.captureException(error); }); }, + setFilterTokens(filtersArg) { + const filters = this.removeDisabledSearchTerms(filtersArg); + + this.filterTokens = filters; + + // If we filtered something out, let's show a warning message + if (filters.length < filtersArg.length) { + this.showAnonymousSearchingMessage(); + } + }, + removeDisabledSearchTerms(filters) { + // If we shouldn't disable anything, let's return the same thing + if (!this.shouldDisableSomeFilters) { + return filters; + } + + const filtersWithoutSearchTerms = filters.filter( + (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data), + ); + + return filtersWithoutSearchTerms; + }, showAnonymousSearchingMessage() { createFlash({ message: this.$options.i18n.anonymousSearchingMessage, @@ -720,17 +746,9 @@ export default { sortKey = defaultSortKey; } - const isSearchDisabled = - this.isAnonymousSearchDisabled && - !this.isSignedIn && - window.location.search.includes('search='); - - if (isSearchDisabled) { - this.showAnonymousSearchingMessage(); - } - this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); - this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search); + this.setFilterTokens(getFilterTokens(window.location.search)); + this.pageParams = getInitialPageParams( this.pageSize, isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, @@ -899,7 +917,9 @@ export default { <template v-else-if="isSignedIn"> <gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath"> <template #description> - <p>{{ $options.i18n.noIssuesSignedInDescription }}</p> + <gl-link :href="issuesHelpPagePath" target="_blank">{{ + $options.i18n.noIssuesSignedInDescription + }}</gl-link> <p v-if="canCreateProjects"> <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> </p> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 38fe4c33792..27738d7a3e6 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -41,12 +41,8 @@ export const i18n = { ), noOpenIssuesDescription: __('To keep this project going, create a new issue'), noOpenIssuesTitle: __('There are no open issues'), - noIssuesSignedInDescription: __( - 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', - ), - noIssuesSignedInTitle: __( - 'The Issue Tracker is the place to add things that need to be improved or solved in a project', - ), + noIssuesSignedInDescription: __('Learn more about issues.'), + noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), noIssuesSignedOutDescription: __( 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', @@ -151,6 +147,7 @@ export const TOKEN_TYPE_EPIC = 'epic_id'; export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_CONTACT = 'crm_contact'; export const TOKEN_TYPE_ORGANIZATION = 'crm_organization'; +export const TOKEN_TYPE_HEALTH = 'health_status'; export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' }; @@ -327,6 +324,16 @@ export const filters = { }, }, }, + [TOKEN_TYPE_HEALTH]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'healthStatus', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'health_status', + }, + }, + }, [TOKEN_TYPE_CONTACT]: { [API_PARAM]: { [NORMAL_FILTER]: 'crmContactId', diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index a5cba3daafa..149049247fb 100644 --- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -65,7 +65,7 @@ export default { <template> <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> - <div class="card card-slim gl-mt-5"> + <div class="card card-slim gl-mt-5 gl-mb-0"> <div class="card-header gl-bg-gray-10"> <div class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0" @@ -112,7 +112,7 @@ export default { </div> <div v-if="hasClosingMergeRequest && !isFetchingMergeRequests" - class="issue-closed-by-widget second-block" + class="issue-closed-by-widget second-block gl-mt-3" > {{ closingMergeRequestsText }} </div> diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index c664135f30e..0daf77e03dc 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -17,11 +17,11 @@ import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; import Store from '../stores'; -import descriptionComponent from './description.vue'; -import editedComponent from './edited.vue'; -import formComponent from './form.vue'; +import DescriptionComponent from './description.vue'; +import EditedComponent from './edited.vue'; +import FormComponent from './form.vue'; import PinnedLinks from './pinned_links.vue'; -import titleComponent from './title.vue'; +import TitleComponent from './title.vue'; export default { WorkspaceType, @@ -29,9 +29,9 @@ export default { GlIcon, GlBadge, GlIntersectionObserver, - titleComponent, - editedComponent, - formComponent, + TitleComponent, + EditedComponent, + FormComponent, PinnedLinks, ConfidentialityBadge, }, @@ -51,20 +51,11 @@ export default { required: true, type: Boolean, }, - canDestroy: { - required: true, - type: Boolean, - }, showInlineEditButton: { type: Boolean, required: false, default: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, enableAutocomplete: { type: Boolean, required: false, @@ -181,7 +172,7 @@ export default { type: Object, required: false, default: () => { - return descriptionComponent; + return DescriptionComponent; }, }, showTitleBorder: { @@ -494,14 +485,12 @@ export default { :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" - :can-destroy="canDestroy" :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" :project-id="projectId" :project-namespace="projectNamespace" - :show-delete-button="showDeleteButton" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue index 47b09bd6aa0..f86ee11e64b 100644 --- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -13,7 +13,8 @@ export default { props: { issuePath: { type: String, - required: true, + required: false, + default: '', }, issueType: { type: String, diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index a6747d67611..5c2a154362f 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -7,6 +7,7 @@ import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import createFlash from '~/flash'; import { IssuableType } from '~/issues/constants'; +import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; @@ -20,6 +21,8 @@ import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_ite import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_CREATING, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME, WIDGET_TYPE_DESCRIPTION, @@ -226,6 +229,7 @@ export default { }, createDragIconElement() { const container = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true"> <use href="${gon.sprite_icons}#drag-vertical"></use> </svg>`; @@ -330,6 +334,9 @@ export default { this.addHoverListeners(taskLink, workItemId); taskLink.classList.add('gl-link'); taskLink.addEventListener('click', (e) => { + if (isMetaKey(e)) { + return; + } e.preventDefault(); this.openWorkItemDetailModal(taskLink); this.workItemId = workItemId; @@ -358,6 +365,7 @@ export default { ); button.id = `js-task-button-${index}`; this.taskButtons.push(button.id); + // eslint-disable-next-line no-unsanitized/property button.innerHTML = ` <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"> <use href="${gon.sprite_icons}#doc-new"></use> @@ -460,7 +468,7 @@ export default { this.openWorkItemDetailModal(el); } catch (error) { createFlash({ - message: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), error, captureError: true, }); diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 358b53bd131..120034b8d67 100644 --- a/app/assets/javascripts/issues/show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -1,12 +1,10 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { __, sprintf } from '~/locale'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import Tracking from '~/tracking'; import eventHub from '../event_hub'; import updateMixin from '../mixins/update'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; -import DeleteIssueModal from './delete_issue_modal.vue'; const issuableTypes = { issue: __('Issue'), @@ -18,18 +16,10 @@ const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); export default { components: { - DeleteIssueModal, GlButton, }, - directives: { - GlModal: GlModalDirective, - }, mixins: [trackingMixin, updateMixin], props: { - canDestroy: { - type: Boolean, - required: true, - }, endpoint: { required: true, type: String, @@ -38,11 +28,6 @@ export default { type: Object, required: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, issuableType: { type: String, required: true, @@ -53,7 +38,6 @@ export default { deleteLoading: false, skipApollo: false, issueState: {}, - modalId: uniqueId('delete-issuable-modal-'), }; }, apollo: { @@ -68,17 +52,9 @@ export default { }, }, computed: { - deleteIssuableButtonText() { - return sprintf(__('Delete %{issuableType}'), { - issuableType: this.typeToShow.toLowerCase(), - }); - }, isSubmitEnabled() { return this.formState.title.trim() !== ''; }, - shouldShowDeleteButton() { - return this.canDestroy && this.showDeleteButton && this.typeToShow; - }, typeToShow() { const { issueState, issuableType } = this; const type = issueState.issueType ?? issuableType; @@ -89,52 +65,26 @@ export default { closeForm() { eventHub.$emit('close.form'); }, - deleteIssuable() { - this.deleteLoading = true; - eventHub.$emit('delete.issuable'); - }, }, }; </script> <template> - <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between"> - <div> - <gl-button - :loading="formState.updateLoading" - :disabled="formState.updateLoading || !isSubmitEnabled" - category="primary" - variant="confirm" - class="gl-mr-3" - data-testid="issuable-save-button" - type="submit" - @click.prevent="updateIssuable" - > - {{ __('Save changes') }} - </gl-button> - <gl-button data-testid="issuable-cancel-button" @click="closeForm"> - {{ __('Cancel') }} - </gl-button> - </div> - <div v-if="shouldShowDeleteButton"> - <gl-button - v-gl-modal="modalId" - :loading="deleteLoading" - :disabled="deleteLoading" - category="secondary" - variant="danger" - data-testid="issuable-delete-button" - @click="track('click_button')" - > - {{ deleteIssuableButtonText }} - </gl-button> - <delete-issue-modal - :issue-path="endpoint" - :issue-type="typeToShow" - :modal-id="modalId" - :title="deleteIssuableButtonText" - @delete="deleteIssuable" - /> - </div> + <div class="gl-mt-3 gl-mb-3 gl-display-flex"> + <gl-button + :loading="formState.updateLoading" + :disabled="formState.updateLoading || !isSubmitEnabled" + category="primary" + variant="confirm" + class="gl-mr-3" + data-testid="issuable-save-button" + type="submit" + @click.prevent="updateIssuable" + > + {{ __('Save changes') }} + </gl-button> + <gl-button data-testid="issuable-cancel-button" @click="closeForm"> + {{ __('Cancel') }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 41cc3964055..4c5f783cd66 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,10 +1,10 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { - timeAgoTooltip, + TimeAgoTooltip, }, props: { updatedAt: { diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index f45af47374a..c2ab7c4f298 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,11 +1,11 @@ <script> -import markdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import updateMixin from '../../mixins/update'; export default { components: { - markdownField, + MarkdownField, }, mixins: [updateMixin], props: { diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index e2c12edf46d..f479c8ae78d 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -22,10 +22,6 @@ export default { LockedWarning, }, props: { - canDestroy: { - type: Boolean, - required: true, - }, endpoint: { type: String, required: true, @@ -63,11 +59,6 @@ export default { type: String, required: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, canAttachFile: { type: Boolean, required: false, @@ -231,12 +222,6 @@ export default { :enable-autocomplete="enableAutocomplete" /> - <edit-actions - :endpoint="endpoint" - :form-state="formState" - :can-destroy="canDestroy" - :show-delete-button="showDeleteButton" - :issuable-type="issuableType" - /> + <edit-actions :endpoint="endpoint" :form-state="formState" :issuable-type="issuableType" /> </form> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 77d13fe085a..aa7b9805b5f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -26,4 +26,15 @@ export const timelineListI18n = Object.freeze({ 'Incident|Something went wrong while deleting the incident timeline event.', ), deleteModal: s__('Incident|Are you sure you want to delete this event?'), + editError: s__('Incident|Error updating incident timeline event: %{error}'), + editErrorGeneric: s__( + 'Incident|Something went wrong while updating the incident timeline event.', + ), +}); + +export const timelineItemI18n = Object.freeze({ + delete: __('Delete'), + edit: __('Edit'), + moreActions: __('More actions'), + timeUTC: __('%{time} UTC'), }); diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index c902895702e..6bb72e82778 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -1,6 +1,7 @@ <script> import { produce } from 'immer'; import { sortBy } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -16,6 +17,7 @@ export default { i18n: timelineFormI18n, components: { TimelineEventsForm, + GlIcon, }, inject: ['fullPath', 'issuableId'], props: { @@ -31,9 +33,6 @@ export default { clearForm() { this.$refs.eventForm.clear(); }, - focusDate() { - this.$refs.eventForm.focusDate(); - }, updateCache(store, { data }) { const { timelineEvent: event, errors } = data?.timelineEventCreate || {}; @@ -107,11 +106,23 @@ export default { </script> <template> - <timeline-events-form - ref="eventForm" - :is-event-processed="createTimelineEventActive" - :has-timeline-events="hasTimelineEvents" - @save-event="createIncidentTimelineEvent" - @cancel="$emit('hide-new-timeline-events-form')" - /> + <div + class="create-timeline-event gl-relative gl-display-flex gl-align-items-start" + :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" + > + <div + v-if="hasTimelineEvents" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + > + <gl-icon name="comment" class="note-icon" /> + </div> + <timeline-events-form + ref="eventForm" + :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }" + :is-event-processed="createTimelineEventActive" + show-save-and-add + @save-event="createIncidentTimelineEvent" + @cancel="$emit('hide-new-timeline-events-form')" + /> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue new file mode 100644 index 00000000000..60fa8cb949b --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -0,0 +1,47 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import TimelineEventsForm from './timeline_events_form.vue'; + +export default { + name: 'EditTimelineEvent', + components: { + TimelineEventsForm, + GlIcon, + }, + props: { + event: { + type: Object, + required: true, + validator: (item) => ['occurredAt', 'note'].every((key) => item[key]), + }, + editTimelineEventActive: { + type: Boolean, + required: true, + }, + }, + methods: { + saveEvent(eventDetails) { + this.$emit('handle-save-edit', { ...eventDetails, id: this.event.id }, false); + }, + }, +}; +</script> + +<template> + <div class="gl-relative gl-display-flex gl-align-items-center"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + > + <gl-icon name="comment" class="note-icon" /> + </div> + <timeline-events-form + ref="eventForm" + class="timeline-event-border" + :is-event-processed="editTimelineEventActive" + :previous-occurred-at="event.occurredAt" + :previous-note="event.note" + @save-event="saveEvent" + @cancel="$emit('hide-edit')" + /> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql new file mode 100644 index 00000000000..54f036268cc --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql @@ -0,0 +1,13 @@ +mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) { + timelineEventUpdate(input: $input) { + timelineEvent { + id + note + noteHtml + action + occurredAt + createdAt + } + errors + } +} diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 0d84fabb1be..b7ae18372ab 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,9 +1,9 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { timelineFormI18n } from './constants'; -import { getUtcShiftedDateNow } from './utils'; +import { getUtcShiftedDate } from './utils'; export default { name: 'TimelineEventsForm', @@ -15,6 +15,7 @@ export default { 'task-list', 'collapsible-section', 'table', + 'attach-file', 'full-screen', ], components: { @@ -23,175 +24,168 @@ export default { GlFormInput, GlFormGroup, GlButton, - GlIcon, }, i18n: timelineFormI18n, directives: { autofocusonshow, }, props: { - hasTimelineEvents: { + showSaveAndAdd: { type: Boolean, - required: true, + required: false, + default: false, }, isEventProcessed: { type: Boolean, required: true, }, + previousOccurredAt: { + type: String, + required: false, + default: null, + }, + previousNote: { + type: String, + required: false, + default: '', + }, }, data() { - // if occurredAt is undefined, returns "now" in UTC - const placeholderDate = getUtcShiftedDateNow(); + // if occurredAt is null, returns "now" in UTC + const placeholderDate = getUtcShiftedDate(this.previousOccurredAt); return { - timelineText: '', + timelineText: this.previousNote, placeholderDate, hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), - datepickerTextInput: null, + datePickerInput: placeholderDate, }; }, computed: { - occurredAt() { - const [years, months, days] = this.datepickerTextInput.split('-'); + occurredAtString() { + const year = this.datePickerInput.getFullYear(); + const month = this.datePickerInput.getMonth(); + const day = this.datePickerInput.getDate(); + const utcDate = new Date( - Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput), + Date.UTC(year, month, day, this.hourPickerInput, this.minutePickerInput), ); return utcDate.toISOString(); }, }, + mounted() { + this.focusDate(); + }, methods: { clear() { - const utcShiftedDateNow = getUtcShiftedDateNow(); - this.placeholderDate = utcShiftedDateNow; - this.hourPickerInput = utcShiftedDateNow.getHours(); - this.minutePickerInput = utcShiftedDateNow.getMinutes(); + const newPlaceholderDate = getUtcShiftedDate(); + this.datePickerInput = newPlaceholderDate; + this.hourPickerInput = newPlaceholderDate.getHours(); + this.minutePickerInput = newPlaceholderDate.getMinutes(); this.timelineText = ''; }, focusDate() { - this.$refs.datepicker.$el.focus(); + this.$refs.datepicker.$el.querySelector('input').focus(); }, handleSave(addAnotherEvent) { - const eventDetails = { + const event = { note: this.timelineText, - occurredAt: this.occurredAt, + occurredAt: this.occurredAtString, }; - this.$emit('save-event', eventDetails, addAnotherEvent); + this.$emit('save-event', event, addAnotherEvent); }, }, }; </script> <template> - <div - class="gl-relative gl-display-flex gl-align-items-center" - :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" - > - <div - v-if="hasTimelineEvents" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" - > - <gl-icon name="comment" class="note-icon" /> - </div> - <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }"> - <div - class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker" - > - <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> - <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate"> + <form class="gl-flex-grow-1 gl-border-gray-50"> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row"> + <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> + <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" /> + </gl-form-group> + <div class="gl-display-flex gl-mt-5"> + <gl-form-group :label="__('Time')"> + <div class="gl-display-flex"> + <label label-for="timeline-input-hours" class="sr-only"></label> <gl-form-input - id="incident-date" - ref="datepicker" - v-model="datepickerTextInput" - data-testid="input-datepicker" - class="gl-datepicker-input gl-pr-7!" - :value="formattedDate" - :placeholder="__('YYYY-MM-DD')" - @keydown.enter="onKeydown" + id="timeline-input-hours" + v-model="hourPickerInput" + data-testid="input-hours" + size="xs" + type="number" + min="00" + max="23" /> - </gl-datepicker> - </gl-form-group> - <div class="gl-display-flex gl-mt-5"> - <gl-form-group :label="__('Time')"> - <div class="gl-display-flex"> - <label label-for="timeline-input-hours" class="sr-only"></label> - <gl-form-input - id="timeline-input-hours" - v-model="hourPickerInput" - data-testid="input-hours" - size="xs" - type="number" - min="00" - max="23" - /> - <label label-for="timeline-input-minutes" class="sr-only"></label> - <gl-form-input - id="timeline-input-minutes" - v-model="minutePickerInput" - class="gl-ml-3" - data-testid="input-minutes" - size="xs" - type="number" - min="00" - max="59" - /> - </div> - </gl-form-group> - <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> - </div> - </div> - <div class="common-note-form"> - <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> - <markdown-field - :can-attach-file="false" - :add-spacing-classes="false" - :show-comment-tool-bar="false" - :textarea-value="timelineText" - :restricted-tool-bar-items="$options.restrictedToolBarItems" - markdown-docs-path="" - :enable-preview="false" - class="bordered-box gl-mt-0" - > - <template #textarea> - <textarea - v-model="timelineText" - class="note-textarea js-gfm-input js-autosize markdown-area" - data-testid="input-note" - dir="auto" - data-supports-quick-actions="false" - :aria-label="$options.i18n.description" - :placeholder="$options.i18n.areaPlaceholder" - > - </textarea> - </template> - </markdown-field> + <label label-for="timeline-input-minutes" class="sr-only"></label> + <gl-form-input + id="timeline-input-minutes" + v-model="minutePickerInput" + class="gl-ml-3" + data-testid="input-minutes" + size="xs" + type="number" + min="00" + max="59" + /> + </div> </gl-form-group> + <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> - <gl-form-group class="gl-mb-0"> - <gl-button - variant="confirm" - category="primary" - class="gl-mr-3" - :loading="isEventProcessed" - @click="handleSave(false)" - > - {{ $options.i18n.save }} - </gl-button> - <gl-button - variant="confirm" - category="secondary" - class="gl-mr-3 gl-ml-n2" - :loading="isEventProcessed" - @click="handleSave(true)" + </div> + <div class="common-note-form"> + <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> + <markdown-field + :can-attach-file="false" + :add-spacing-classes="false" + :show-comment-tool-bar="false" + :textarea-value="timelineText" + :restricted-tool-bar-items="$options.restrictedToolBarItems" + markdown-docs-path="" + :enable-preview="false" + class="bordered-box gl-mt-0" > - {{ $options.i18n.saveAndAdd }} - </gl-button> - <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> - {{ $options.i18n.cancel }} - </gl-button> - <div class="gl-border-b gl-pt-5"></div> + <template #textarea> + <textarea + v-model="timelineText" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-testid="input-note" + dir="auto" + data-supports-quick-actions="false" + :aria-label="$options.i18n.description" + :placeholder="$options.i18n.areaPlaceholder" + > + </textarea> + </template> + </markdown-field> </gl-form-group> - </form> - </div> + </div> + <gl-form-group class="gl-mb-0"> + <gl-button + variant="confirm" + category="primary" + class="gl-mr-3" + :loading="isEventProcessed" + @click="handleSave(false)" + > + {{ $options.i18n.save }} + </gl-button> + <gl-button + v-if="showSaveAndAdd" + variant="confirm" + category="secondary" + class="gl-mr-3 gl-ml-n2" + :loading="isEventProcessed" + @click="handleSave(true)" + > + {{ $options.i18n.saveAndAdd }} + </gl-button> + <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} + </gl-button> + <div class="timeline-event-bottom-border"></div> + </gl-form-group> + </form> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index 6175c9969ec..cbf3c387fa3 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,25 +1,13 @@ <script> -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlIcon, - GlSafeHtmlDirective, - GlSprintf, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; +import { timelineItemI18n } from './constants'; import { getEventIcon } from './utils'; export default { name: 'IncidentTimelineEventListItem', - i18n: { - delete: __('Delete'), - moreActions: __('More actions'), - timeUTC: __('%{time} UTC'), - }, + i18n: timelineItemI18n, components: { - GlButton, GlDropdown, GlDropdownItem, GlIcon, @@ -28,12 +16,8 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, - inject: ['canUpdate'], + inject: ['canUpdateTimelineEvent'], props: { - isLastItem: { - type: Boolean, - required: true, - }, occurredAt: { type: String, required: true, @@ -58,43 +42,41 @@ export default { }; </script> <template> - <li - class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!" - > - <div class="gl-display-flex gl-align-items-center"> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" - > - <gl-icon :name="getEventIcon(action)" class="note-icon" /> + <div class="gl-display-flex gl-align-items-start"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" + > + <gl-icon :name="getEventIcon(action)" class="note-icon" /> + </div> + <div + class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row" + data-testid="event-text-container" + > + <div> + <strong class="gl-font-lg" data-testid="event-time"> + <gl-sprintf :message="$options.i18n.timeUTC"> + <template #time>{{ time }}</template> + </gl-sprintf> + </strong> + <div v-safe-html="noteHtml"></div> </div> - <div - class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row" - :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }" - data-testid="event-text-container" + <gl-dropdown + v-if="canUpdateTimelineEvent" + right + class="event-note-actions gl-ml-auto gl-align-self-start" + icon="ellipsis_v" + text-sr-only + :text="$options.i18n.moreActions" + category="tertiary" + no-caret > - <div> - <strong class="gl-font-lg" data-testid="event-time"> - <gl-sprintf :message="$options.i18n.timeUTC"> - <template #time>{{ time }}</template> - </gl-sprintf> - </strong> - <div v-safe-html="noteHtml"></div> - </div> - <gl-dropdown - v-if="canUpdate" - right - class="event-note-actions gl-ml-auto gl-align-self-center" - icon="ellipsis_v" - text-sr-only - :text="$options.i18n.moreActions" - category="tertiary" - no-caret - > - <gl-dropdown-item @click="$emit('delete')"> - <gl-button>{{ $options.i18n.delete }}</gl-button> - </gl-dropdown-item> - </gl-dropdown> - </div> + <gl-dropdown-item @click="$emit('edit')"> + {{ $options.i18n.edit }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('delete')"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> </div> - </li> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index 80ac1c372cd..321b7ccc14a 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -5,7 +5,9 @@ import { sprintf } from '~/locale'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import IncidentTimelineEventItem from './timeline_events_item.vue'; +import EditTimelineEvent from './edit_timeline_event.vue'; import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql'; +import editTimelineEvent from './graphql/queries/edit_timeline_event.mutation.graphql'; import { timelineListI18n } from './constants'; export default { @@ -13,6 +15,7 @@ export default { i18n: timelineListI18n, components: { IncidentTimelineEventItem, + EditTimelineEvent, }, props: { timelineEventLoading: { @@ -26,6 +29,9 @@ export default { default: () => [], }, }, + data() { + return { eventToEdit: null, editTimelineEventActive: false }; + }, computed: { dateGroupedEvents() { const groupedEvents = new Map(); @@ -44,11 +50,12 @@ export default { }, }, methods: { - isLastItem(groups, groupIndex, events, eventIndex) { - if (groupIndex < groups.size - 1) { - return false; - } - return eventIndex === events.length - 1; + handleEditSelection(event) { + this.eventToEdit = event.id; + this.$emit('hide-new-incident-timeline-event-form'); + }, + hideEdit() { + this.eventToEdit = null; }, handleDelete: ignoreWhilePending(async function handleDelete(event) { const msg = this.$options.i18n.deleteModal; @@ -85,6 +92,38 @@ export default { createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error }); } }), + handleSaveEdit(eventDetails) { + this.editTimelineEventActive = true; + return this.$apollo + .mutate({ + mutation: editTimelineEvent, + variables: { + input: { + id: eventDetails.id, + note: eventDetails.note, + occurredAt: eventDetails.occurredAt, + }, + }, + }) + .then(({ data }) => { + this.editTimelineEventActive = false; + const errors = data.timelineEventUpdate?.errors; + if (errors.length) { + createAlert({ + message: sprintf(this.$options.i18n.editError, { error: errors.join('. ') }, false), + }); + } else { + this.hideEdit(); + } + }) + .catch((error) => { + createAlert({ + message: this.$options.i18n.editErrorGeneric, + captureError: true, + error, + }); + }); + }, }, }; </script> @@ -92,9 +131,10 @@ export default { <template> <div class="issuable-discussion incident-timeline-events"> <div - v-for="([eventDate, events], groupIndex) in dateGroupedEvents" + v-for="[eventDate, events] in dateGroupedEvents" :key="eventDate" data-testid="timeline-group" + class="timeline-group" > <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid"> <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong> @@ -103,15 +143,25 @@ export default { <li v-for="(event, eventIndex) in events" :key="eventIndex" - class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!" + class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!" > + <edit-timeline-event + v-if="eventToEdit === event.id" + :key="`edit-${event.id}`" + ref="eventForm" + :event="event" + :edit-timeline-event-active="editTimelineEventActive" + @handle-save-edit="handleSaveEdit" + @hide-edit="hideEdit()" + /> <incident-timeline-event-item + v-else :key="event.id" :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" - :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)" @delete="handleDelete(event)" + @edit="handleEditSelection(event)" /> </li> </ul> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 7c2a7878c58..5f70d9acac9 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -3,10 +3,10 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; +import notesEventHub from '~/notes/event_hub'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; import { displayAndLogError } from './utils'; import { timelineTabI18n } from './constants'; - import CreateTimelineEvent from './create_timeline_event.vue'; import IncidentTimelineEventsList from './timeline_events_list.vue'; @@ -20,7 +20,7 @@ export default { IncidentTimelineEventsList, }, i18n: timelineTabI18n, - inject: ['canUpdate', 'fullPath', 'issuableId'], + inject: ['canUpdateTimelineEvent', 'fullPath', 'issuableId'], data() { return { isEventFormVisible: false, @@ -56,15 +56,21 @@ export default { return !this.timelineEventLoading && !this.hasTimelineEvents; }, }, + mounted() { + notesEventHub.$on('comment-promoted-to-timeline-event', this.refreshTimelineEvents); + }, + destroyed() { + notesEventHub.$off('comment-promoted-to-timeline-event', this.refreshTimelineEvents); + }, methods: { + refreshTimelineEvents() { + this.$apollo.queries.timelineEvents.refetch(); + }, hideEventForm() { this.isEventFormVisible = false; }, - async showEventForm() { - this.$refs.createEventForm.clearForm(); + showEventForm() { this.isEventFormVisible = true; - await this.$nextTick(); - this.$refs.createEventForm.focusDate(); }, }, }; @@ -85,14 +91,19 @@ export default { @hide-new-timeline-events-form="hideEventForm" /> <create-timeline-event - v-show="isEventFormVisible" + v-if="isEventFormVisible" ref="createEventForm" :has-timeline-events="hasTimelineEvents" class="timeline-event-note timeline-event-note-form" :class="{ 'gl-pl-0': !hasTimelineEvents }" @hide-new-timeline-events-form="hideEventForm" /> - <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm"> + <gl-button + v-if="canUpdateTimelineEvent" + variant="default" + class="gl-mb-3 gl-mt-7" + @click="showEventForm" + > {{ $options.i18n.addEventButton }} </gl-button> </gl-tab> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index cf790a11b67..5a009debd75 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -21,13 +21,14 @@ export const getEventIcon = (actionName) => { }; /** - * Returns a date shifted by the current timezone offset. Allows - * date.getHours() and similar to return UTC values. - * + * Returns a date shifted by the current timezone offset set to now + * by default but can accept an existing date as an ISO date string + * @param {string} ISOString * @returns {Date} */ -export const getUtcShiftedDateNow = () => { - const date = new Date(); +export const getUtcShiftedDate = (ISOString = null) => { + const date = ISOString ? new Date(ISOString) : new Date(); date.setMinutes(date.getMinutes() + date.getTimezoneOffset()); + return date; }; diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js index 5b8630f7d63..deee034f9d1 100644 --- a/app/assets/javascripts/issues/show/graphql.js +++ b/app/assets/javascripts/issues/show/graphql.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { defaultClient } from '~/sidebar/graphql'; +import { defaultClient } from '~/graphql_shared/issuable_client'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 459a3804837..e5eed9f6b79 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -32,6 +32,7 @@ export function initIncidentApp(issueData = {}) { const { canCreateIncident, canUpdate, + canUpdateTimelineEvent, iid, issuableId, projectNamespace, @@ -51,6 +52,7 @@ export function initIncidentApp(issueData = {}) { provide: { issueType: INCIDENT_TYPE, canCreateIncident, + canUpdateTimelineEvent, canUpdate, fullPath, iid, diff --git a/app/assets/javascripts/issues/show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js index c5811290e61..aeb547b9194 100644 --- a/app/assets/javascripts/issues/show/utils/update_description.js +++ b/app/assets/javascripts/issues/show/utils/update_description.js @@ -13,6 +13,7 @@ const updateDescription = (descriptionHtml = '', details) => { } const placeholder = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property placeholder.innerHTML = descriptionHtml; const newDetails = placeholder.getElementsByTagName('details'); diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js index de67703356f..c79d7002111 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/api.js +++ b/app/assets/javascripts/jira_connect/subscriptions/api.js @@ -1,10 +1,25 @@ import axios from 'axios'; +import { buildApiUrl } from '~/api/api_utils'; + +import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants'; import { getJwt } from './utils'; +const CURRENT_USER_PATH = '/api/:version/user'; +const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions'; +const JIRA_CONNECT_INSTALLATIONS_PATH = '/-/jira_connect/installations'; +const JIRA_CONNECT_OAUTH_APPLICATION_ID_PATH = '/-/jira_connect/oauth_application_id'; + +// This export is only used for testing purposes +export const axiosInstance = axios.create(); + +export const setApiBaseURL = (baseURL = null) => { + axiosInstance.defaults.baseURL = baseURL; +}; + export const addSubscription = async (addPath, namespace) => { const jwt = await getJwt(); - return axios.post(addPath, { + return axiosInstance.post(addPath, { jwt, namespace_path: namespace, }); @@ -13,7 +28,7 @@ export const addSubscription = async (addPath, namespace) => { export const removeSubscription = async (removePath) => { const jwt = await getJwt(); - return axios.delete(removePath, { + return axiosInstance.delete(removePath, { params: { jwt, }, @@ -21,7 +36,7 @@ export const removeSubscription = async (removePath) => { }; export const fetchGroups = async (groupsPath, { page, perPage, search }) => { - return axios.get(groupsPath, { + return axiosInstance.get(groupsPath, { params: { page, per_page: perPage, @@ -33,9 +48,50 @@ export const fetchGroups = async (groupsPath, { page, perPage, search }) => { export const fetchSubscriptions = async (subscriptionsPath) => { const jwt = await getJwt(); - return axios.get(subscriptionsPath, { + return axiosInstance.get(subscriptionsPath, { params: { jwt, }, }); }; + +export const getCurrentUser = (options) => { + const url = buildApiUrl(CURRENT_USER_PATH); + return axiosInstance.get(url, { ...options }); +}; + +export const addJiraConnectSubscription = (namespacePath, { jwt, accessToken }) => { + const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH); + + return axiosInstance.post( + url, + { + jwt, + namespace_path: namespacePath, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); +}; + +export const updateInstallation = async (instanceUrl) => { + const jwt = await getJwt(); + + return axiosInstance.put(JIRA_CONNECT_INSTALLATIONS_PATH, { + jwt, + installation: { + instance_url: instanceUrl === GITLAB_COM_BASE_PATH ? null : instanceUrl, + }, + }); +}; + +export const fetchOAuthApplicationId = () => { + return axiosInstance.get(JIRA_CONNECT_OAUTH_APPLICATION_ID_PATH); +}; + +export const fetchOAuthToken = (oauthTokenPath, data = {}) => { + return axiosInstance.post(oauthTokenPath, data); +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 66aea60c5b5..22a6c0751f4 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -83,7 +83,7 @@ export default { * if the jiraConnectOauth flag is enabled. */ fetchSubscriptionsOauth() { - if (!this.isOauthEnabled) return; + if (!this.isOauthEnabled || !this.userSignedIn) return; this.fetchSubscriptions(this.subscriptionsPath); }, @@ -146,12 +146,12 @@ export default { <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> <sign-in-page - v-if="!userSignedIn" + v-show="!userSignedIn" :has-subscriptions="hasSubscriptions" @sign-in-oauth="onSignInOauth" @error="onSignInError" /> - <subscriptions-page v-else :has-subscriptions="hasSubscriptions" /> + <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" /> </div> </div> </main> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue index c5b56535247..9b50681515e 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue @@ -3,6 +3,7 @@ import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed'; @@ -14,6 +15,7 @@ export default { GlLink, LocalStorageSync, }, + mixins: [glFeatureFlagMixin()], data() { return { alertDismissed: false, @@ -23,6 +25,14 @@ export default { shouldShowAlert() { return !this.alertDismissed; }, + isOauthSelfManagedEnabled() { + return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged; + }, + alertBody() { + return this.isOauthSelfManagedEnabled + ? this.$options.i18n.body + : this.$options.i18n.bodyDotCom; + }, }, methods: { dismissAlert() { @@ -32,6 +42,9 @@ export default { i18n: { title: s__('Integrations|Known limitations'), body: s__( + 'Integrations|Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.', + ), + bodyDotCom: s__( 'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.', ), }, @@ -50,7 +63,7 @@ export default { :title="$options.i18n.title" @dismiss="dismissAlert" > - <gl-sprintf :message="$options.i18n.body"> + <gl-sprintf :message="alertBody"> <template #link="{ content }"> <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index ad3e70bcb5f..4cf3a1a0279 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -1,30 +1,53 @@ <script> import { mapActions, mapMutations } from 'vuex'; import { GlButton } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; +import { sprintf } from '~/locale'; + import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, + I18N_CUSTOM_SIGN_IN_BUTTON_TEXT, + I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE, + I18N_OAUTH_FAILED_TITLE, + I18N_OAUTH_FAILED_MESSAGE, + OAUTH_SELF_MANAGED_DOC_LINK, OAUTH_WINDOW_OPTIONS, PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM, } from '~/jira_connect/subscriptions/constants'; +import { fetchOAuthApplicationId, fetchOAuthToken } from '~/jira_connect/subscriptions/api'; import { setUrlParams } from '~/lib/utils/url_utility'; import AccessorUtilities from '~/lib/utils/accessor'; import { createCodeVerifier, createCodeChallenge } from '../pkce'; -import { SET_ACCESS_TOKEN } from '../store/mutation_types'; +import { SET_ACCESS_TOKEN, SET_ALERT } from '../store/mutation_types'; export default { components: { GlButton, }, inject: ['oauthMetadata'], + props: { + gitlabBasePath: { + type: String, + required: false, + default: undefined, + }, + }, data() { return { - token: null, loading: false, codeVerifier: null, + clientId: null, canUseCrypto: AccessorUtilities.canUseCrypto(), }; }, + computed: { + buttonText() { + if (!this.gitlabBasePath) { + return I18N_DEFAULT_SIGN_IN_BUTTON_TEXT; + } + + return sprintf(I18N_CUSTOM_SIGN_IN_BUTTON_TEXT, { url: this.gitlabBasePath }); + }, + }, created() { window.addEventListener('message', this.handleWindowMessage); }, @@ -35,30 +58,72 @@ export default { ...mapActions(['loadCurrentUser']), ...mapMutations({ setAccessToken: SET_ACCESS_TOKEN, + setAlert: SET_ALERT, }), - async startOAuthFlow() { - this.loading = true; - + async fetchOauthClientId() { + const { + data: { application_id: clientId }, + } = await fetchOAuthApplicationId(); + return clientId; + }, + async getOauthAuthorizeURL() { // Generate state necessary for PKCE OAuth flow this.codeVerifier = createCodeVerifier(); const codeChallenge = await createCodeChallenge(this.codeVerifier); + try { + this.clientId = this.gitlabBasePath + ? await this.fetchOauthClientId() + : this.oauthMetadata?.oauth_token_payload?.client_id; + } catch { + throw new Error(I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE); + } // Build the initial OAuth authorization URL const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata; - - const oauthAuthorizeURLWithChallenge = setUrlParams( - { - code_challenge: codeChallenge, - code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short, - }, - oauthAuthorizeURL, + const oauthAuthorizeURLWithChallenge = new URL( + setUrlParams( + { + code_challenge: codeChallenge, + code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short, + client_id: this.clientId, + }, + oauthAuthorizeURL, + ), ); - window.open( - oauthAuthorizeURLWithChallenge, - this.$options.i18n.defaultButtonText, - OAUTH_WINDOW_OPTIONS, - ); + // Rebase URL on the specified GitLab base path (if specified). + if (this.gitlabBasePath) { + const gitlabBasePathURL = new URL(this.gitlabBasePath); + oauthAuthorizeURLWithChallenge.hostname = gitlabBasePathURL.hostname; + oauthAuthorizeURLWithChallenge.pathname = `${ + gitlabBasePathURL.pathname === '/' ? '' : gitlabBasePathURL.pathname + }${oauthAuthorizeURLWithChallenge.pathname}`; + } + + return oauthAuthorizeURLWithChallenge.toString(); + }, + async startOAuthFlow() { + try { + this.loading = true; + const oauthAuthorizeURL = await this.getOauthAuthorizeURL(); + + window.open(oauthAuthorizeURL, I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, OAUTH_WINDOW_OPTIONS); + } catch (e) { + if (e.message) { + this.setAlert({ + message: e.message, + variant: 'danger', + }); + } else { + this.setAlert({ + linkUrl: OAUTH_SELF_MANAGED_DOC_LINK, + title: I18N_OAUTH_FAILED_TITLE, + message: this.gitlabBasePath ? I18N_OAUTH_FAILED_MESSAGE : '', + variant: 'danger', + }); + } + this.loading = false; + } }, async handleWindowMessage(event) { if (window.origin !== event.origin) { @@ -94,20 +159,18 @@ export default { async getOAuthToken(code) { const { oauth_token_payload: oauthTokenPayload, - oauth_token_url: oauthTokenURL, + oauth_token_path: oauthTokenPath, } = this.oauthMetadata; - const { data } = await axios.post(oauthTokenURL, { + const { data } = await fetchOAuthToken(oauthTokenPath, { ...oauthTokenPayload, code, code_verifier: this.codeVerifier, + client_id: this.clientId, }); return data.access_token; }, }, - i18n: { - defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, - }, }; </script> <template> @@ -119,7 +182,7 @@ export default { @click="startOAuthFlow" > <slot> - {{ $options.i18n.defaultButtonText }} + {{ buttonText }} </slot> </gl-button> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 8faafb1b0d0..fc365746b54 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -3,11 +3,13 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export const DEFAULT_GROUPS_PER_PAGE = 10; export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; +export const BASE_URL_LOCALSTORAGE_KEY = 'gitlab_base_url'; export const MINIMUM_SEARCH_TERM_LENGTH = 3; export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal'; export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab'); +export const I18N_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to %{url}'); export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.'); export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__( 'Integrations|Failed to load subscriptions.', @@ -18,13 +20,28 @@ export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE = s__( export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE = s__( 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', ); -export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira_development_panel', { - anchor: 'use-the-integration', -}); - export const I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE = s__( 'Integrations|Failed to link namespace. Please try again.', ); +export const I18N_UPDATE_INSTALLATION_ERROR_MESSAGE = s__( + 'Integrations|Failed to update GitLab version. Please try again.', +); +export const I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE = s__( + 'Integrations|Failed to load Jira Connect Application ID. Please try again.', +); +export const I18N_OAUTH_FAILED_TITLE = s__('Integrations|Failed to sign in to GitLab.'); +export const I18N_OAUTH_FAILED_MESSAGE = s__( + 'Integrations|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.', +); + +export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', { + anchor: 'use-the-integration', +}); +export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { + anchor: 'install-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', +}); + +export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; const OAUTH_WINDOW_SIZE = 800; export const OAUTH_WINDOW_OPTIONS = [ diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue index 4f5aa4c255c..5ff75e19425 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue @@ -1,6 +1,13 @@ <script> +import { mapMutations } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; + +import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils'; +import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api'; +import { I18N_UPDATE_INSTALLATION_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; +import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; + import SignInOauthButton from '../../../components/sign_in_oauth_button.vue'; import VersionSelectForm from './version_select_form.vue'; @@ -14,6 +21,7 @@ export default { data() { return { gitlabBasePath: null, + loadingVersionSelect: false, }; }, computed: { @@ -26,12 +34,32 @@ export default { : this.$options.i18n.versionSelectSubtitle; }, }, + mounted() { + this.gitlabBasePath = retrieveBaseUrl(); + setApiBaseURL(this.gitlabBasePath); + }, methods: { + ...mapMutations({ + setAlert: SET_ALERT, + }), resetGitlabBasePath() { this.gitlabBasePath = null; + setApiBaseURL(); }, onVersionSelect(gitlabBasePath) { - this.gitlabBasePath = gitlabBasePath; + this.loadingVersionSelect = true; + updateInstallation(gitlabBasePath) + .then(() => { + persistBaseUrl(gitlabBasePath); + reloadPage(); + }) + .catch(() => { + this.setAlert({ + message: I18N_UPDATE_INSTALLATION_ERROR_MESSAGE, + variant: 'danger', + }); + this.loadingVersionSelect = false; + }); }, onSignInError() { this.$emit('error'); @@ -53,11 +81,17 @@ export default { <p data-testid="subtitle">{{ subtitle }}</p> </div> - <version-select-form v-if="!hasSelectedVersion" class="gl-mt-7" @submit="onVersionSelect" /> + <version-select-form + v-if="!hasSelectedVersion" + class="gl-mt-7" + :loading="loadingVersionSelect" + @submit="onVersionSelect" + /> <div v-else class="gl-text-center"> <sign-in-oauth-button class="gl-mb-5" + :gitlab-base-path="gitlabBasePath" @sign-in="$emit('sign-in-oauth', $event)" @error="onSignInError" /> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue index 0fa745ed7e3..6b32225ed11 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue @@ -9,13 +9,14 @@ import { } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants'; + const RADIO_OPTIONS = { saas: 'saas', selfManaged: 'selfManaged', }; const DEFAULT_RADIO_OPTION = RADIO_OPTIONS.saas; -const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; export default { name: 'VersionSelectForm', @@ -27,6 +28,13 @@ export default { GlFormRadio, GlButton, }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + }, data() { return { selected: DEFAULT_RADIO_OPTION, @@ -82,7 +90,7 @@ export default { </gl-form-group> <div class="gl-display-flex gl-justify-content-end"> - <gl-button variant="confirm" type="submit">{{ __('Save') }}</gl-button> + <gl-button variant="confirm" type="submit" :loading="loading">{{ __('Save') }}</gl-button> </div> </gl-form> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js index 4a83ee8671d..fff34e1d75d 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js @@ -1,6 +1,8 @@ -import { fetchSubscriptions as fetchSubscriptionsREST } from '~/jira_connect/subscriptions/api'; -import { getCurrentUser } from '~/rest_api'; -import { addJiraConnectSubscription } from '~/api/integrations_api'; +import { + fetchSubscriptions as fetchSubscriptionsREST, + getCurrentUser, + addJiraConnectSubscription, +} from '~/jira_connect/subscriptions/api'; import { I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE, I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE, diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js index 03a83f18b4c..82a8517b511 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js @@ -1,4 +1,8 @@ -export default function createState({ subscriptions = [], subscriptionsLoading = false } = {}) { +export default function createState({ + subscriptions = [], + subscriptionsLoading = false, + currentUser = null, +} = {}) { return { alert: undefined, @@ -9,7 +13,7 @@ export default function createState({ subscriptions = [], subscriptionsLoading = addSubscriptionLoading: false, addSubscriptionError: false, - currentUser: null, + currentUser, currentUserError: null, accessToken: null, diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js index b2d03a1fbba..6db8b62d692 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/utils.js +++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js @@ -1,32 +1,45 @@ import AccessorUtilities from '~/lib/utils/accessor'; import { objectToQuery } from '~/lib/utils/url_utility'; -import { ALERT_LOCALSTORAGE_KEY } from './constants'; +import { ALERT_LOCALSTORAGE_KEY, BASE_URL_LOCALSTORAGE_KEY } from './constants'; const isFunction = (fn) => typeof fn === 'function'; +const { canUseLocalStorage } = AccessorUtilities; + +const persistToStorage = (key, payload) => { + localStorage.setItem(key, payload); +}; + +const retrieveFromStorage = (key) => { + return localStorage.getItem(key); +}; + +const removeFromStorage = (key) => { + localStorage.removeItem(key); +}; /** * Persist alert data to localStorage. */ export const persistAlert = ({ title, message, linkUrl, variant } = {}) => { - if (!AccessorUtilities.canUseLocalStorage()) { + if (!canUseLocalStorage()) { return; } const payload = JSON.stringify({ title, message, linkUrl, variant }); - localStorage.setItem(ALERT_LOCALSTORAGE_KEY, payload); + persistToStorage(ALERT_LOCALSTORAGE_KEY, payload); }; /** * Return alert data from localStorage. */ export const retrieveAlert = () => { - if (!AccessorUtilities.canUseLocalStorage()) { + if (!canUseLocalStorage()) { return null; } - const initialAlertJSON = localStorage.getItem(ALERT_LOCALSTORAGE_KEY); + const initialAlertJSON = retrieveFromStorage(ALERT_LOCALSTORAGE_KEY); // immediately clean up - localStorage.removeItem(ALERT_LOCALSTORAGE_KEY); + removeFromStorage(ALERT_LOCALSTORAGE_KEY); if (!initialAlertJSON) { return null; @@ -35,6 +48,22 @@ export const retrieveAlert = () => { return JSON.parse(initialAlertJSON); }; +export const persistBaseUrl = (baseUrl) => { + if (!canUseLocalStorage()) { + return; + } + + persistToStorage(BASE_URL_LOCALSTORAGE_KEY, baseUrl); +}; + +export const retrieveBaseUrl = () => { + if (!canUseLocalStorage()) { + return null; + } + + return retrieveFromStorage(BASE_URL_LOCALSTORAGE_KEY); +}; + export const getJwt = () => { return new Promise((resolve) => { if (isFunction(AP?.context?.getToken)) { diff --git a/app/assets/javascripts/jobs/components/filtered_search/constants.js b/app/assets/javascripts/jobs/components/filtered_search/constants.js new file mode 100644 index 00000000000..0daba892375 --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/constants.js @@ -0,0 +1,13 @@ +export const jobStatusValues = [ + 'CANCELED', + 'CREATED', + 'FAILED', + 'MANUAL', + 'SUCCESS', + 'PENDING', + 'PREPARING', + 'RUNNING', + 'SCHEDULED', + 'SKIPPED', + 'WAITING_FOR_RESOURCE', +]; diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue index fe7b7428c6e..e498a735898 100644 --- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue +++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue @@ -11,6 +11,13 @@ export default { components: { GlFilteredSearch, }, + props: { + queryString: { + type: Object, + required: false, + default: null, + }, + }, computed: { tokens() { return [ @@ -24,6 +31,20 @@ export default { }, ]; }, + filteredSearchValue() { + if (this.queryString?.statuses) { + return [ + { + type: 'status', + value: { + data: this.queryString?.statuses, + operator: '=', + }, + }, + ]; + } + return []; + }, }, methods: { onSubmit(filters) { @@ -37,6 +58,7 @@ export default { <gl-filtered-search :placeholder="s__('Jobs|Filter jobs')" :available-tokens="tokens" + :value="filteredSearchValue" @submit="onSubmit" /> </template> diff --git a/app/assets/javascripts/jobs/components/filtered_search/utils.js b/app/assets/javascripts/jobs/components/filtered_search/utils.js new file mode 100644 index 00000000000..696cd8d4706 --- /dev/null +++ b/app/assets/javascripts/jobs/components/filtered_search/utils.js @@ -0,0 +1,27 @@ +import { jobStatusValues } from './constants'; + +// validates query string used for filtered search +// on jobs table to ensure GraphQL query is called correctly +export const validateQueryString = (queryStringObj) => { + // currently only one token is supported `statuses` + // this code will need to be expanded as more tokens + // are introduced + + const filters = Object.keys(queryStringObj); + + if (filters.includes('statuses')) { + const queryStringStatus = { + statuses: queryStringObj.statuses.toUpperCase(), + }; + + const found = jobStatusValues.find((status) => status === queryStringStatus.statuses); + + if (found) { + return queryStringStatus; + } + + return null; + } + + return null; +}; diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/job/empty_state.vue index e31c13f40b0..65b9600e664 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/job/empty_state.vue @@ -1,12 +1,16 @@ <script> import { GlLink } from '@gitlab/ui'; -import ManualVariablesForm from './manual_variables_form.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import LegacyManualVariablesForm from '~/jobs/components/job/legacy_manual_variables_form.vue'; +import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; export default { components: { GlLink, + LegacyManualVariablesForm, ManualVariablesForm, }, + mixins: [glFeatureFlagsMixin()], props: { illustrationPath: { type: String, @@ -50,6 +54,9 @@ export default { }, }, computed: { + isGraphQL() { + return this.glFeatures?.graphqlJobApp; + }, shouldRenderManualVariables() { return this.playable && !this.scheduled; }, @@ -70,7 +77,12 @@ export default { <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> - <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> + <template v-if="isGraphQL"> + <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> + </template> + <template v-else> + <legacy-manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> + </template> <div class="text-content"> <div v-if="action && !shouldRenderManualVariables" class="text-center"> <gl-link diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/job/environments_block.vue index 4046e1ade82..4046e1ade82 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/job/environments_block.vue diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/job/erased_block.vue index a815689659e..a815689659e 100644 --- a/app/assets/javascripts/jobs/components/erased_block.vue +++ b/app/assets/javascripts/jobs/components/job/erased_block.vue diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index d5ee3423d70..81b65d175a7 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -6,15 +6,15 @@ import { mapGetters, mapState, mapActions } from 'vuex'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { __, sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import delayedJobMixin from '../mixins/delayed_job_mixin'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import Log from '~/jobs/components/log/log.vue'; import EmptyState from './empty_state.vue'; import EnvironmentsBlock from './environments_block.vue'; import ErasedBlock from './erased_block.vue'; import LogTopBar from './job_log_controllers.vue'; -import Log from './log/log.vue'; -import Sidebar from './sidebar.vue'; import StuckBlock from './stuck_block.vue'; import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; +import Sidebar from './sidebar/sidebar.vue'; export default { name: 'JobPageApp', @@ -197,7 +197,7 @@ export default { </script> <template> <div> - <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation gl-mt-6" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-6" /> <template v-else-if="shouldRenderContent"> <div class="build-page" data-testid="job-content"> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue index e9809ac661b..e9809ac661b 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue index 07ef4f054b4..1898e02c94e 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue @@ -77,9 +77,6 @@ export default { }, methods: { ...mapActions(['triggerManualJob']), - canRemove(index) { - return index < this.variables.length - 1; - }, addEmptyVariable() { const lastVar = this.variables[this.variables.length - 1]; @@ -93,12 +90,18 @@ export default { id: uniqueId(), }); }, + canRemove(index) { + return index < this.variables.length - 1; + }, deleteVariable(id) { this.variables.splice( this.variables.findIndex((el) => el.id === id), 1, ); }, + inputRef(type, id) { + return `${this.$options.inputTypes[type]}-${id}`; + }, trigger() { this.triggerBtnDisabled = true; @@ -125,7 +128,7 @@ export default { </gl-input-group-text> </template> <gl-form-input - :ref="`${$options.inputTypes.key}-${variable.id}`" + :ref="inputRef('key', variable.id)" v-model="variable.key" :placeholder="$options.i18n.keyPlaceholder" data-testid="ci-variable-key" @@ -140,20 +143,13 @@ export default { </gl-input-group-text> </template> <gl-form-input - :ref="`${$options.inputTypes.value}-${variable.id}`" + :ref="inputRef('value', variable.id)" v-model="variable.secretValue" :placeholder="$options.i18n.valuePlaceholder" data-testid="ci-variable-value" /> </gl-form-input-group> - <!-- delete variable button placeholder to not break flex layout --> - <div - v-if="!canRemove(index)" - class="gl-w-7 gl-mr-3" - data-testid="delete-variable-btn-placeholder" - ></div> - <gl-button v-if="canRemove(index)" class="gl-flex-grow-0 gl-flex-basis-0" @@ -164,6 +160,9 @@ export default { data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> + + <!-- delete variable button placeholder to not break flex layout --> + <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div> </div> <div class="gl-text-center gl-mt-5"> diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue new file mode 100644 index 00000000000..2f97301979c --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -0,0 +1,195 @@ +<script> +import { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { mapActions } from 'vuex'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +// This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue +// It is meant to fetch the job information via GraphQL instead of REST API. + +export default { + name: 'ManualVariablesForm', + components: { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlSprintf, + }, + props: { + action: { + type: Object, + required: false, + default: null, + validator(value) { + return ( + value === null || + (Object.prototype.hasOwnProperty.call(value, 'path') && + Object.prototype.hasOwnProperty.call(value, 'method') && + Object.prototype.hasOwnProperty.call(value, 'button_title')) + ); + }, + }, + }, + inputTypes: { + key: 'key', + value: 'value', + }, + i18n: { + header: s__('CiVariables|Variables'), + keyLabel: s__('CiVariables|Key'), + valueLabel: s__('CiVariables|Value'), + keyPlaceholder: s__('CiVariables|Input variable key'), + valuePlaceholder: s__('CiVariables|Input variable value'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), + }, + data() { + return { + variables: [ + { + key: '', + secretValue: '', + id: uniqueId(), + }, + ], + triggerBtnDisabled: false, + }; + }, + computed: { + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); + }, + preparedVariables() { + // we need to ensure no empty variables are passed to the API + // and secretValue should be snake_case when passed to the API + return this.variables + .filter((variable) => variable.key !== '') + .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); + }, + }, + methods: { + ...mapActions(['triggerManualJob']), + addEmptyVariable() { + const lastVar = this.variables[this.variables.length - 1]; + + if (lastVar.key === '') { + return; + } + + this.variables.push({ + key: '', + secret_value: '', + id: uniqueId(), + }); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + deleteVariable(id) { + this.variables.splice( + this.variables.findIndex((el) => el.id === id), + 1, + ); + }, + inputRef(type, id) { + return `${this.$options.inputTypes[type]}-${id}`; + }, + trigger() { + this.triggerBtnDisabled = true; + + this.triggerManualJob(this.preparedVariables); + }, + }, +}; +</script> +<template> + <div class="row gl-justify-content-center"> + <div class="col-10" data-testid="manual-vars-form"> + <label>{{ $options.i18n.header }}</label> + + <div + v-for="(variable, index) in variables" + :key="variable.id" + class="gl-display-flex gl-align-items-center gl-mb-4" + data-testid="ci-variable-row" + > + <gl-form-input-group class="gl-mr-4 gl-flex-grow-1"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.keyLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="inputRef('key', variable.id)" + v-model="variable.key" + :placeholder="$options.i18n.keyPlaceholder" + data-testid="ci-variable-key" + @change="addEmptyVariable" + /> + </gl-form-input-group> + + <gl-form-input-group class="gl-flex-grow-2"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.valueLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="inputRef('value', variable.id)" + v-model="variable.secretValue" + :placeholder="$options.i18n.valuePlaceholder" + data-testid="ci-variable-value" + /> + </gl-form-input-group> + + <gl-button + v-if="canRemove(index)" + class="gl-flex-grow-0 gl-flex-basis-0" + category="tertiary" + variant="danger" + icon="clear" + :aria-label="__('Delete variable')" + data-testid="delete-variable-btn" + @click="deleteVariable(variable.id)" + /> + + <!-- delete variable button placeholder to not break flex layout --> + <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div> + </div> + + <div class="gl-text-center gl-mt-5"> + <gl-sprintf :message="$options.i18n.formHelpText"> + <template #link="{ content }"> + <gl-link :href="variableSettings" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + class="gl-mt-5" + variant="confirm" + category="primary" + :aria-label="__('Trigger manual job')" + :disabled="triggerBtnDisabled" + data-testid="trigger-manual-job-btn" + @click="trigger" + > + {{ action.button_title }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue index 2018942a7e8..2018942a7e8 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue index 7f25ca8a94d..7f25ca8a94d 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue index 097ab3b4cf6..097ab3b4cf6 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue diff --git a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue index e83ed6c6332..913924cc7b1 100644 --- a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlModal } from '@gitlab/ui'; -import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '../constants'; +import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants'; export default { name: 'JobRetryForwardDeploymentModal', diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue index a7bf365d35c..dd620977f0c 100644 --- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -1,12 +1,12 @@ <script> import { GlButton, GlModalDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { JOB_SIDEBAR } from '../constants'; +import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; export default { name: 'JobSidebarRetryButton', i18n: { - retryLabel: JOB_SIDEBAR.retry, + retryLabel: JOB_SIDEBAR_COPY.retry, }, components: { GlButton, diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue index df64b6422c7..df64b6422c7 100644 --- a/app/assets/javascripts/jobs/components/jobs_container.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue new file mode 100644 index 00000000000..263b2d141c9 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue @@ -0,0 +1,99 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; + +export default { + name: 'LegacySidebarHeader', + i18n: { + ...JOB_SIDEBAR_COPY, + }, + forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + JobSidebarRetryButton, + TooltipOnTruncate, + }, + props: { + job: { + type: Object, + required: true, + default: () => ({}), + }, + erasePath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + retryButtonCategory() { + return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + }, + }, + methods: { + ...mapActions(['toggleSidebar']), + }, +}; +</script> + +<template> + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="job.name" truncate-target="child" + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> + {{ job.name }} + </h4> + </tooltip-on-truncate> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-button + v-if="erasePath" + v-gl-tooltip.left + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="erasePath" + :data-confirm="$options.i18n.eraseLogConfirmText" + class="gl-mr-2" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <job-sidebar-retry-button + v-if="job.retry_path" + v-gl-tooltip.left + :title="$options.i18n.retryJobButtonLabel" + :aria-label="$options.i18n.retryJobButtonLabel" + :category="retryButtonCategory" + :href="job.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-qa-selector="retry_button" + data-testid="retry-button" + /> + <gl-button + v-if="job.cancel_path" + v-gl-tooltip.left + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="job.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue index a42e45ee7e4..b0db48df01f 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue @@ -1,48 +1,40 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR } from '../constants'; -import ArtifactsBlock from './artifacts_block.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; import CommitBlock from './commit_block.vue'; -import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; -import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; import JobsContainer from './jobs_container.vue'; +import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; +import ArtifactsBlock from './artifacts_block.vue'; +import LegacySidebarHeader from './legacy_sidebar_header.vue'; +import SidebarHeader from './sidebar_header.vue'; import StagesDropdown from './stages_dropdown.vue'; import TriggerBlock from './trigger_block.vue'; -export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; - export default { name: 'JobSidebar', i18n: { - eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), - eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), - cancelJobButtonLabel: s__('Job|Cancel'), - retryJobButtonLabel: s__('Job|Retry'), - ...JOB_SIDEBAR, + ...JOB_SIDEBAR_COPY, }, borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], forwardDeploymentFailureModalId, - directives: { - GlTooltip: GlTooltipDirective, - }, components: { ArtifactsBlock, CommitBlock, GlButton, GlIcon, JobsContainer, - JobSidebarRetryButton, JobRetryForwardDeploymentModal, JobSidebarDetailsContainer, + LegacySidebarHeader, + SidebarHeader, StagesDropdown, - TooltipOnTruncate, TriggerBlock, }, + mixins: [glFeatureFlagsMixin()], props: { artifactHelpUrl: { type: String, @@ -58,9 +50,6 @@ export default { computed: { ...mapGetters(['hasForwardDeploymentFailure']), ...mapState(['job', 'stages', 'jobs', 'selectedStage']), - retryButtonCategory() { - return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; - }, hasArtifact() { // the artifact object will always have a locked property return Object.keys(this.job.artifact).length > 1; @@ -68,8 +57,8 @@ export default { hasTriggers() { return !isEmpty(this.job.trigger); }, - hasStages() { - return this.job?.pipeline?.stages?.length > 0; + isGraphQL() { + return this.glFeatures?.graphqlJobApp; }, commit() { return this.job?.pipeline?.commit || {}; @@ -79,7 +68,7 @@ export default { }, }, methods: { - ...mapActions(['fetchJobsForStage', 'toggleSidebar']), + ...mapActions(['fetchJobsForStage']), }, }; </script> @@ -87,61 +76,8 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <div class="gl-py-5 gl-display-flex gl-align-items-center"> - <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> - </tooltip-on-truncate> - <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> - <gl-button - v-if="erasePath" - v-gl-tooltip.left - :title="$options.i18n.eraseLogButtonLabel" - :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" - :data-confirm="$options.i18n.eraseLogConfirmText" - class="gl-mr-2" - data-testid="job-log-erase-link" - data-confirm-btn-variant="danger" - data-method="post" - icon="remove" - /> - <job-sidebar-retry-button - v-if="job.retry_path" - v-gl-tooltip.left - :title="$options.i18n.retryJobButtonLabel" - :aria-label="$options.i18n.retryJobButtonLabel" - :category="retryButtonCategory" - :href="job.retry_path" - :modal-id="$options.forwardDeploymentFailureModalId" - variant="confirm" - data-qa-selector="retry_button" - data-testid="retry-button" - /> - <gl-button - v-if="job.cancel_path" - v-gl-tooltip.left - :title="$options.i18n.cancelJobButtonLabel" - :aria-label="$options.i18n.cancelJobButtonLabel" - :href="job.cancel_path" - variant="danger" - icon="cancel" - data-method="post" - data-testid="cancel-button" - rel="nofollow" - /> - </div> - - <gl-button - :aria-label="$options.i18n.toggleSidebar" - category="tertiary" - class="gl-md-display-none gl-ml-2" - icon="chevron-double-lg-right" - @click="toggleSidebar" - /> - </div> - + <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" /> + <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" /> <div v-if="job.terminal_path || job.new_issue_path" class="gl-py-5" diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue index 05567328660..05567328660 100644 --- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue new file mode 100644 index 00000000000..523710598bf --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -0,0 +1,102 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; + +// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue +// It is meant to fetch the job information via GraphQL instead of REST API. + +export default { + name: 'SidebarHeader', + i18n: { + ...JOB_SIDEBAR_COPY, + }, + forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + JobSidebarRetryButton, + TooltipOnTruncate, + }, + props: { + job: { + type: Object, + required: true, + default: () => ({}), + }, + erasePath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + retryButtonCategory() { + return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + }, + }, + methods: { + ...mapActions(['toggleSidebar']), + }, +}; +</script> + +<template> + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="job.name" truncate-target="child" + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> + {{ job.name }} + </h4> + </tooltip-on-truncate> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-button + v-if="erasePath" + v-gl-tooltip.left + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="erasePath" + :data-confirm="$options.i18n.eraseLogConfirmText" + class="gl-mr-2" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <job-sidebar-retry-button + v-if="job.retry_path" + v-gl-tooltip.left + :title="$options.i18n.retryJobButtonLabel" + :aria-label="$options.i18n.retryJobButtonLabel" + :category="retryButtonCategory" + :href="job.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-qa-selector="retry_button" + data-testid="retry-button" + /> + <gl-button + v-if="job.cancel_path" + v-gl-tooltip.left + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="job.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue index 3b1509e5be5..3b1509e5be5 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue index 7c4811b2d6f..e3afe9b7c67 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue @@ -4,14 +4,14 @@ import { isEmpty } from 'lodash'; import Mousetrap from 'mousetrap'; import { s__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings'; export default { components: { CiIcon, - clipboardButton, + ClipboardButton, GlDropdown, GlDropdownItem, GlLink, diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue index 1afc1c9a595..1afc1c9a595 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/job/stuck_block.vue index d7a26d22406..d7a26d22406 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/job/stuck_block.vue diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue index c9747ca9f02..c9747ca9f02 100644 --- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue +++ b/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index 98b51e8c2c4..851be211b25 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -11,6 +11,7 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo } nodes { artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { downloadPath fileType diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index c2f460cb647..0a4757d11a8 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -2,7 +2,9 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import createFlash from '~/flash'; +import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; +import { validateQueryString } from '../filtered_search/utils'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; @@ -37,6 +39,7 @@ export default { variables() { return { fullPath: this.fullPath, + ...this.validatedQueryString, }; }, update(data) { @@ -95,6 +98,11 @@ export default { jobsCount() { return this.jobs.count; }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, }, watch: { // this watcher ensures that the count on the all tab @@ -133,6 +141,10 @@ export default { } if (filter.type === 'status') { + updateHistory({ + url: setUrlParams({ statuses: filter.value.data }, window.location.href, true), + }); + this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); } }); @@ -171,12 +183,12 @@ export default { :loading="loading" @fetchJobsByStatus="fetchJobsByStatus" /> - - <jobs-filtered-search - v-if="showFilteredSearch" - :class="$options.filterSearchBoxStyles" - @filterJobsBySearch="filterJobsBySearch" - /> + <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles"> + <jobs-filtered-search + :query-string="validatedQueryString" + @filterJobsBySearch="filterJobsBySearch" + /> + </div> <div v-if="showSkeletonLoader" class="gl-mt-5"> <gl-skeleton-loader :width="1248" :height="73"> diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index 3040d4e2379..50ee7bd20dd 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -3,11 +3,17 @@ import { __, s__ } from '~/locale'; const cancel = __('Cancel'); const moreInfo = __('More information'); -export const JOB_SIDEBAR = { +export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; + +export const JOB_SIDEBAR_COPY = { cancel, + cancelJobButtonLabel: s__('Job|Cancel'), debug: __('Debug'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), + eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), newIssue: __('New issue'), retry: __('Retry'), + retryJobButtonLabel: s__('Job|Retry'), toggleSidebar: __('Toggle Sidebar'), }; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 5c63ad96ad0..9dd47f4046c 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,6 +1,6 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; -import JobApp from './components/job_app.vue'; +import JobApp from './components/job/job_app.vue'; import createStore from './store'; Vue.use(GlToast); diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 3e5396c5bd8..51fedac339b 100644 --- a/app/assets/javascripts/labels/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -247,6 +247,7 @@ export default class LabelsSelect { } linkEl.className = selectedClass.join(' '); + // eslint-disable-next-line no-unsanitized/property linkEl.innerHTML = `${colorEl} ${escape(label.title)}`; const listItemEl = document.createElement('li'); diff --git a/app/assets/javascripts/lib/dateformat.js b/app/assets/javascripts/lib/dateformat.js new file mode 100644 index 00000000000..1fd95dd03ab --- /dev/null +++ b/app/assets/javascripts/lib/dateformat.js @@ -0,0 +1,60 @@ +import dateFormat, { i18n, masks } from 'dateformat'; +import { s__, __ } from '~/locale'; + +i18n.dayNames = [ + __('Sun'), + __('Mon'), + __('Tue'), + __('Wed'), + __('Thu'), + __('Fri'), + __('Sat'), + __('Sunday'), + __('Monday'), + __('Tuesday'), + __('Wednesday'), + __('Thursday'), + __('Friday'), + __('Saturday'), +]; + +i18n.monthNames = [ + __('Jan'), + __('Feb'), + __('Mar'), + __('Apr'), + __('May'), + __('Jun'), + __('Jul'), + __('Aug'), + __('Sep'), + __('Oct'), + __('Nov'), + __('Dec'), + __('January'), + __('February'), + __('March'), + __('April'), + __('May'), + __('June'), + __('July'), + __('August'), + __('September'), + __('October'), + __('November'), + __('December'), +]; + +i18n.timeNames = [ + s__('Time|a'), + s__('Time|p'), + s__('Time|am'), + s__('Time|pm'), + s__('Time|A'), + s__('Time|P'), + s__('Time|AM'), + s__('Time|PM'), +]; + +export { masks }; +export default dateFormat; diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 3e28ca2a0f7..6f24590f9e7 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -1,6 +1,8 @@ -import { sanitize as dompurifySanitize, addHook } from 'dompurify'; +import DOMPurify from 'dompurify'; import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility'; +const { sanitize: dompurifySanitize, addHook, isValidAttribute } = DOMPurify; + const defaultConfig = { // Safely allow SVG <use> tags ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], @@ -94,4 +96,4 @@ addHook('afterSanitizeAttributes', (node) => { export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config }); -export { isValidAttribute } from 'dompurify'; +export { isValidAttribute }; diff --git a/app/assets/javascripts/lib/gfm/constants.js b/app/assets/javascripts/lib/gfm/constants.js new file mode 100644 index 00000000000..eaabeb2a767 --- /dev/null +++ b/app/assets/javascripts/lib/gfm/constants.js @@ -0,0 +1,10 @@ +export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN = '[['; +export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN = 'TOC'; +export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN = ']]'; +export const TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN = '[TOC]'; + +export const MDAST_TEXT_NODE = 'text'; +export const MDAST_EMPHASIS_NODE = 'emphasis'; +export const MDAST_PARAGRAPH_NODE = 'paragraph'; + +export const GLFM_TABLE_OF_CONTENTS_NODE = 'tableOfContents'; diff --git a/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js new file mode 100644 index 00000000000..4d2484a657a --- /dev/null +++ b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js @@ -0,0 +1,85 @@ +import { first, last } from 'lodash'; +import { u } from 'unist-builder'; +import { visitParents, SKIP, CONTINUE } from 'unist-util-visit-parents'; +import { + TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN, + TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN, + TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN, + TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN, + MDAST_TEXT_NODE, + MDAST_EMPHASIS_NODE, + MDAST_PARAGRAPH_NODE, + GLFM_TABLE_OF_CONTENTS_NODE, +} from '../constants'; + +const isTOCTextNode = ({ type, value }) => + type === MDAST_TEXT_NODE && value === TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN; + +const isTOCEmphasisNode = ({ type, children }) => + type === MDAST_EMPHASIS_NODE && children.length === 1 && isTOCTextNode(first(children)); + +const isTOCDoubleSquareBracketOpenTokenTextNode = ({ type, value }) => + type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN; + +const isTOCDoubleSquareBracketCloseTokenTextNode = ({ type, value }) => + type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN; + +/* + * Detects table of contents declaration with syntax [[_TOC_]] + */ +const isTableOfContentsDoubleSquareBracketSyntax = ({ children }) => { + if (children.length !== 3) { + return false; + } + + const [firstChild, middleChild, lastChild] = children; + + return ( + isTOCDoubleSquareBracketOpenTokenTextNode(firstChild) && + isTOCEmphasisNode(middleChild) && + isTOCDoubleSquareBracketCloseTokenTextNode(lastChild) + ); +}; + +/* + * Detects table of contents declaration with syntax [TOC] + */ +const isTableOfContentsSingleSquareBracketSyntax = ({ children }) => { + if (children.length !== 1) { + return false; + } + + const [firstChild] = children; + const { type, value } = firstChild; + + return type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN; +}; + +const isTableOfContentsNode = (node) => + node.type === MDAST_PARAGRAPH_NODE && + (isTableOfContentsDoubleSquareBracketSyntax(node) || + isTableOfContentsSingleSquareBracketSyntax(node)); + +export default () => { + return (tree) => { + visitParents(tree, (node, ancestors) => { + const parent = last(ancestors); + + if (!parent) { + return CONTINUE; + } + + if (isTableOfContentsNode(node)) { + const index = parent.children.indexOf(node); + + parent.children[index] = u(GLFM_TABLE_OF_CONTENTS_NODE, { + position: node.position, + }); + } + + return SKIP; + }); + + return tree; + }; +}; diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js index eaf653e9924..fad73f93c1a 100644 --- a/app/assets/javascripts/lib/gfm/index.js +++ b/app/assets/javascripts/lib/gfm/index.js @@ -6,6 +6,8 @@ import remarkFrontmatter from 'remark-frontmatter'; import remarkGfm from 'remark-gfm'; import remarkRehype, { all } from 'remark-rehype'; import rehypeRaw from 'rehype-raw'; +import glfmTableOfContents from './glfm_extensions/table_of_contents'; +import * as glfmMdastToHastHandlers from './mdast_to_hast_handlers/glfm_mdast_to_hast_handlers'; const skipFrontmatterHandler = (language) => (h, node) => h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]); @@ -65,19 +67,22 @@ const skipRenderingHandlers = { all(h, node), ); }, + tableOfContents: (h, node) => h(node.position, 'tableOfContents'), toml: skipFrontmatterHandler('toml'), yaml: skipFrontmatterHandler('yaml'), json: skipFrontmatterHandler('json'), }; -const createParser = ({ skipRendering = [] }) => { +const createParser = ({ skipRendering }) => { return unified() .use(remarkParse) .use(remarkGfm) .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }]) + .use(glfmTableOfContents) .use(remarkRehype, { allowDangerousHtml: true, handlers: { + ...glfmMdastToHastHandlers, ...pick(skipRenderingHandlers, skipRendering), }, }) @@ -99,13 +104,13 @@ const compilerFactory = (renderer) => * tree in any desired representation * * @param {String} params.markdown Markdown to parse - * @param {(tree: MDast -> any)} params.renderer A function that accepts mdast + * @param {Function} params.renderer A function that accepts mdast * AST tree and returns an object of any type that represents the result of * rendering the tree. See the references below to for more information * about MDast. * * MDastTree documentation https://github.com/syntax-tree/mdast - * @returns {Promise<any>} Returns a promise with the result of rendering + * @returns {Promise} Returns a promise with the result of rendering * the MDast tree */ export const render = async ({ markdown, renderer, skipRendering = [] }) => { diff --git a/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js new file mode 100644 index 00000000000..91b09e69405 --- /dev/null +++ b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js @@ -0,0 +1 @@ +export const tableOfContents = (h, node) => h(node.position, 'nav'); diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js index d621c9ddf9e..c72561ce69d 100644 --- a/app/assets/javascripts/lib/mermaid.js +++ b/app/assets/javascripts/lib/mermaid.js @@ -9,6 +9,7 @@ const setIframeRenderedSize = (h, w) => { const drawDiagram = (source) => { const element = document.getElementById('app'); const insertSvg = (svgCode) => { + // eslint-disable-next-line no-unsanitized/property element.innerHTML = svgCode; const height = parseInt(element.firstElementChild.getAttribute('height'), 10); diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 4e7086e62c5..6c5d4ecc901 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -142,9 +142,16 @@ export const dayInQuarter = (date, quarter) => { export const millisecondsPerDay = 1000 * 60 * 60 * 24; -export const getDayDifference = (a, b) => { - const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); - const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); +/** + * Calculates the number of days between 2 specified dates, excluding the current date + * + * @param {Date} startDate the earlier date that we will substract from the end date + * @param {Date} endDate the last date in the range + * @return {Number} number of days in between + */ +export const getDayDifference = (startDate, endDate) => { + const date1 = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + const date2 = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()); return Math.floor((date2 - date1) / millisecondsPerDay); }; @@ -208,6 +215,19 @@ export const newDateAsLocaleTime = (date) => { return new Date(`${date}${suffix}`); }; +/** + * Takes a Date object (where timezone could be GMT or EST) and + * returns a Date object with the same date but in UTC. + * + * @param {Date} date A Date object + * @returns {Date|null} A Date object with the same date but in UTC + */ +export const getDateWithUTC = (date) => { + return date instanceof Date + ? new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) + : null; +}; + export const beginOfDayTime = 'T00:00:00Z'; export const endOfDayTime = 'T23:59:59Z'; diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index 830f4604382..d07abb72210 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -1,5 +1,5 @@ -import dateFormat from 'dateformat'; import { isString, mapValues, reduce, isDate, unescape } from 'lodash'; +import dateFormat from '~/lib/dateformat'; import { roundToNearestHalf } from '~/lib/utils/common_utils'; import { sanitize } from '~/lib/dompurify'; import { s__, n__, __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js index 840cc4600fe..548f5a438df 100644 --- a/app/assets/javascripts/lib/utils/datetime_range.js +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -1,5 +1,5 @@ -import dateformat from 'dateformat'; import { pick, omit, isEqual, isEmpty } from 'lodash'; +import dateformat from '~/lib/dateformat'; import { DATETIME_RANGE_TYPES } from './constants'; import { secondsToMilliseconds } from './datetime_utility'; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 9f4e12a3010..48be8af3ff6 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -263,10 +263,12 @@ export function insertMarkdownText({ if (tag === LINK_TAG_PATTERN) { if (URL) { try { - new URL(selected); // eslint-disable-line no-new - // valid url - tag = '[text]({text})'; - select = 'text'; + const url = new URL(selected); + + if (url.origin !== 'null' || url.origin === null) { + tag = '[text]({text})'; + select = 'text'; + } } catch (e) { // ignore - no valid url } diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 7b00995b2e5..59645d50e29 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -119,6 +119,7 @@ const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) { div.style.left = -1000; div.style.top = -1000; + // eslint-disable-next-line no-unsanitized/property div.innerHTML = chars; document.body.appendChild(div); diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js index 6d799d30b4b..f1d3026c2f1 100644 --- a/app/assets/javascripts/linked_resources/index.js +++ b/app/assets/javascripts/linked_resources/index.js @@ -22,7 +22,7 @@ export default function initLinkedResources() { name: 'LinkedResourcesRoot', apolloProvider, components: { - resourceLinksBlock: ResourceLinksBlock, + ResourceLinksBlock, }, render: (createElement) => createElement('resource-links-block', { diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index e1749331d90..c8c6b51f374 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -14,6 +14,8 @@ import { escape } from 'lodash'; export default (input, parameters, escapeParameters = true) => { let output = input; + output = output.replace(/%+/g, '%'); + if (parameters) { const mappedParameters = new Map(Object.entries(parameters)); diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index b82fb0030ff..1bb1f90302c 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -88,7 +88,8 @@ export default { :action-primary="actionPrimary" :title="actionText" :visible="removeMemberModalVisible" - data-qa-selector="remove_member_modal_content" + data-qa-selector="remove_member_modal" + data-testid="remove-member-modal-content" @primary="submitForm" @hide="hideRemoveMemberModal" > diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 93d113d1afe..3135ec602be 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -196,3 +196,8 @@ export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level'; export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link'; export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access'; + +export const I18N_USER_YOU = __("It's you"); +export const I18N_USER_BLOCKED = __('Blocked'); +export const I18N_USER_BOT = __('Bot'); +export const I188N_USER_2FA = __('2FA'); diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 7ec083646e9..0da44b7d468 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -1,28 +1,36 @@ import { isUndefined } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { getParameterByName, setUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import { FIELDS, DEFAULT_SORT, GROUP_LINK_BASE_PROPERTY_NAME, GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, + I18N_USER_YOU, + I18N_USER_BLOCKED, + I18N_USER_BOT, + I188N_USER_2FA, } from './constants'; export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [ { show: isCurrentUser, - text: __("It's you"), + text: I18N_USER_YOU, variant: 'success', }, { show: member.user?.blocked, - text: __('Blocked'), + text: I18N_USER_BLOCKED, variant: 'danger', }, { + show: member.user?.isBot, + text: I18N_USER_BOT, + variant: 'muted', + }, + { show: member.user?.twoFactorEnabled && (canManageMembers || isCurrentUser), - text: __('2FA'), + text: I188N_USER_2FA, variant: 'info', }, ]; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index ed2e6a5af58..0b53a8ede64 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -434,6 +434,7 @@ export default class MergeRequestTabs { .get(`${source}.json`) .then(({ data }) => { const commitsDiv = document.querySelector('div#commits'); + // eslint-disable-next-line no-unsanitized/property commitsDiv.innerHTML = data.html; localTimeAgo(commitsDiv.querySelectorAll('.js-timeago')); this.commitsLoaded = true; diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue new file mode 100644 index 00000000000..f067982fce1 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -0,0 +1,180 @@ +<script> +import { + GlIntersectionObserver, + GlLink, + GlSprintf, + GlBadge, + GlSafeHtmlDirective, +} from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import StatusBox from '~/issuable/components/status_box.vue'; +import DiscussionCounter from '~/notes/components/discussion_counter.vue'; +import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + components: { + GlIntersectionObserver, + GlLink, + GlSprintf, + GlBadge, + StatusBox, + DiscussionCounter, + TodoWidget, + ClipboardButton, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + projectPath: { default: null }, + title: { default: '' }, + tabs: { default: () => [] }, + isFluidLayout: { default: false }, + }, + data() { + return { + isStickyHeaderVisible: false, + discussionCounter: 0, + }; + }, + computed: { + ...mapGetters(['getNoteableData', 'discussionTabCounter']), + ...mapState({ + activeTab: (state) => state.page.activeTab, + doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions, + }), + issuableId() { + return convertToGraphQLId(TYPE_MERGE_REQUEST, this.getNoteableData.id); + }, + issuableIid() { + return `${this.getNoteableData.iid}`; + }, + isSignedIn() { + return isLoggedIn(); + }, + }, + watch: { + discussionTabCounter(val) { + if (this.glFeatures.paginatedMrDiscussions) { + if (this.doneFetchingBatchDiscussions) { + this.discussionCounter = val; + } + } else { + this.discussionCounter = val; + } + }, + }, + methods: { + setStickyHeaderVisible(val) { + this.isStickyHeaderVisible = val; + }, + visitTab(e) { + window.mrTabs?.clickTab(e); + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, +}; +</script> + +<template> + <gl-intersection-observer + @appear="setStickyHeaderVisible(false)" + @disappear="setStickyHeaderVisible(true)" + > + <div + v-if="isStickyHeaderVisible" + class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block" + > + <div + class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5" + :class="{ 'gl-max-w-container-xl': !isFluidLayout }" + > + <div class="gl-w-full gl-display-flex gl-align-items-center"> + <status-box :initial-state="getNoteableData.state" issuable-type="merge_request" /> + <p + v-safe-html:[$options.safeHtmlConfig]="title" + class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4" + ></p> + <div class="gl-display-flex gl-align-items-center"> + <gl-sprintf :message="__('%{source} %{copyButton} into %{target}')"> + <template #copyButton> + <clipboard-button + :text="getNoteableData.source_branch" + :title="__('Copy branch name')" + size="small" + category="tertiary" + tooltip-placement="bottom" + class="gl-m-0! gl-mx-1! js-source-branch-copy" + /> + </template> + <template #source> + <gl-link + :title="getNoteableData.source_branch" + :href="getNoteableData.source_branch_path" + class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26" + > + {{ getNoteableData.source_branch }} + </gl-link> + </template> + <template #target> + <gl-link + :title="getNoteableData.target_branch" + :href="getNoteableData.target_branch_path" + class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26 gl-ml-2" + > + {{ getNoteableData.target_branch }} + </gl-link> + </template> + </gl-sprintf> + </div> + </div> + <div class="gl-w-full gl-display-flex"> + <ul + class="merge-request-tabs nav-tabs nav nav-links gl-display-flex gl-flex-nowrap gl-m-0 gl-p-0 gl-border-b-0" + > + <li + v-for="(tab, index) in tabs" + :key="tab[0]" + :class="{ active: activeTab === tab[0] }" + > + <gl-link + :href="tab[2]" + :data-action="tab[0]" + class="gl-outline-0! gl-py-4!" + @click="visitTab" + > + {{ tab[1] }} + <gl-badge variant="muted" size="sm"> + <template v-if="index === 0 && discussionCounter !== 0"> + {{ discussionCounter }} + </template> + <template v-else> + {{ tab[3] }} + </template> + </gl-badge> + </gl-link> + </li> + </ul> + <div class="gl-display-none gl-lg-display-flex gl-align-items-center gl-ml-auto"> + <discussion-counter blocks-merge hide-options /> + <todo-widget + v-if="isSignedIn" + :issuable-id="issuableId" + :issuable-iid="issuableIid" + :full-path="projectPath" + issuable-type="merge_request" + /> + </div> + </div> + </div> + </div> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index 59d2a2b29b3..5c3b969655b 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -243,7 +243,7 @@ export default { v-for="(item, idx) in extraLinks" :key="idx" :href="item.url" - :is-check-item="true" + is-check-item data-testid="milestone-combobox-extra-links" > {{ item.text }} diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 250d4b3c55f..e3fcdf716d4 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -391,7 +391,11 @@ export default { }; </script> <template> - <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> + <div + class="prometheus-graphs" + data-qa-selector="prometheus_graphs_content" + data-testid="prometheus-graphs" + > <div> <gl-alert v-if="!isDeprecationNoticeDismissed" diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 3338635bf96..90d2498ac19 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -189,6 +189,7 @@ export default { ref="monitorEnvironmentsDropdown" class="flex-grow-1" data-qa-selector="environments_dropdown" + data-testid="environments-dropdown" toggle-class="dropdown-menu-toggle" menu-class="monitor-environment-dropdown-menu" :text="environmentDropdownText" @@ -202,7 +203,7 @@ export default { <gl-dropdown-item v-for="environment in filteredEnvironments" :key="environment.id" - :is-check-item="true" + is-check-item :is-checked="environment.name === currentEnvironmentName" :href="getEnvironmentPath(environment.id)" > diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 568c66cf152..7fae684315c 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -86,7 +86,7 @@ export default { <gl-dropdown-item v-for="dashboard in starredDashboards" :key="dashboard.path" - :is-check-item="true" + is-check-item :is-checked="dashboard.path === selectedDashboardPath" @click="selectDashboard(dashboard)" > @@ -105,7 +105,7 @@ export default { <gl-dropdown-item v-for="dashboard in nonStarredDashboards" :key="dashboard.path" - :is-check-item="true" + is-check-item :is-checked="dashboard.path === selectedDashboardPath" @click="selectDashboard(dashboard)" > diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue index 0b80043a92c..544fe10f26e 100644 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -163,7 +163,7 @@ export default { :text="dropdownText" > <gl-dropdown-item - :is-check-item="true" + is-check-item :is-checked="refreshInterval === null" @click="removeRefreshInterval()" >{{ __('Off') }}</gl-dropdown-item @@ -172,7 +172,7 @@ export default { <gl-dropdown-item v-for="(option, i) in $options.refreshIntervals" :key="i" - :is-check-item="true" + is-check-item :is-checked="isChecked(option)" @click="setRefreshInterval(option)" >{{ option.label }}</gl-dropdown-item diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js index c7bc626eb11..f20fea48084 100644 --- a/app/assets/javascripts/monitoring/format_date.js +++ b/app/assets/javascripts/monitoring/format_date.js @@ -1,4 +1,4 @@ -import dateFormat from 'dateformat'; +import dateFormat from '~/lib/dateformat'; export const timezones = { /** diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 7424c011052..297420bf94d 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -5,9 +5,8 @@ import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal'; import initDiffsApp from '../diffs'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import MergeRequest from '../merge_request'; -import discussionCounter from '../notes/components/discussion_counter.vue'; +import DiscussionCounter from '../notes/components/discussion_counter.vue'; import initDiscussionFilters from '../notes/discussion_filters'; -import initSortDiscussions from '../notes/sort_discussions'; import initNotesApp from './init_notes'; export default function initMrNotes() { @@ -38,7 +37,7 @@ export default function initMrNotes() { el, name: 'DiscussionCounter', components: { - discussionCounter, + DiscussionCounter, }, store, render(createElement) { @@ -52,6 +51,5 @@ export default function initMrNotes() { } initDiscussionFilters(store); - initSortDiscussions(store); }); } diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index cf24d18c7b6..e4a7a7bd9fc 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -4,7 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import store from '~/mr_notes/stores'; import discussionNavigator from '../notes/components/discussion_navigator.vue'; -import notesApp from '../notes/components/notes_app.vue'; +import NotesApp from '../notes/components/notes_app.vue'; import initWidget from '../vue_merge_request_widget'; export default () => { @@ -13,7 +13,7 @@ export default () => { el: '#js-vue-mr-discussions', name: 'MergeRequestDiscussions', components: { - notesApp, + NotesApp, }, store, data() { diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index 08a2c6952c8..ca6e6567f74 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -1,14 +1,18 @@ <script> -import { GlNav, GlNavItemDropdown, GlDropdownForm } from '@gitlab/ui'; +import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui'; import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; export default { components: { + GlIcon, GlNav, GlNavItemDropdown, GlDropdownForm, TopNavDropdownMenu, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { navData: { type: Object, @@ -21,15 +25,20 @@ export default { <template> <gl-nav class="navbar-sub-nav"> <gl-nav-item-dropdown - :text="navData.activeTitle" + v-gl-tooltip.bottom="navData.menuTooltip" data-qa-selector="navbar_dropdown" - :data-qa-title="navData.activeTitle" - icon="hamburger" + data-qa-title="Menu" menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto! js-top-nav-dropdown-menu" toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" no-flip no-caret > + <template #button-content> + <gl-icon name="hamburger" /> + <span v-if="navData.menuTitle" class="gl-ml-3"> + {{ navData.menuTitle }} + </span> + </template> <gl-dropdown-form> <top-nav-dropdown-menu :primary="navData.primary" diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue index b8555df53df..97e63c7324e 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue @@ -49,15 +49,26 @@ export default { :class="getMenuSectionClasses(sectionIndex)" data-testid="menu-section" > - <top-nav-menu-item - v-for="(menuItem, menuItemIndex) in menuItems" - :key="menuItem.id" - :menu-item="menuItem" - data-testid="menu-item" - class="gl-w-full" - :class="{ 'gl-mt-1': menuItemIndex > 0 }" - @click="onClick(menuItem)" - /> + <template v-for="(menuItem, menuItemIndex) in menuItems"> + <strong + v-if="menuItem.type == 'header'" + :key="menuItem.title" + class="gl-px-4 gl-py-2 gl-text-gray-900 gl-display-block" + :class="{ 'gl-pt-3!': menuItemIndex > 0 }" + data-testid="menu-header" + > + {{ menuItem.title }} + </strong> + <top-nav-menu-item + v-else + :key="menuItem.id" + :menu-item="menuItem" + data-testid="menu-item" + class="gl-w-full" + :class="{ 'gl-mt-1': menuItemIndex > 0 }" + @click="onClick(menuItem)" + /> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue index f5a6f3a9817..bc1bab62553 100644 --- a/app/assets/javascripts/notebook/cells/code.vue +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -13,11 +13,6 @@ export default { type: Object, required: true, }, - codeCssClass: { - type: String, - required: false, - default: '', - }, }, computed: { rawInputCode() { @@ -39,18 +34,12 @@ export default { <template> <div class="cell"> - <code-output - :raw-code="rawInputCode" - :count="cell.execution_count" - :code-css-class="codeCssClass" - type="input" - /> + <code-output :raw-code="rawInputCode" :count="cell.execution_count" type="input" /> <output-cell v-if="hasOutput" :count="cell.execution_count" :outputs="outputs" :metadata="cell.metadata" - :code-css-class="codeCssClass" /> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index e1ef9aa6d79..64e801a7516 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -1,10 +1,11 @@ <script> -import Prism from '../../lib/highlight'; +import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue'; import Prompt from '../prompt.vue'; export default { name: 'CodeOutput', components: { + CodeBlockHighlighted, Prompt, }, props: { @@ -13,11 +14,6 @@ export default { required: false, default: 0, }, - codeCssClass: { - type: String, - required: false, - default: '', - }, type: { type: String, required: true, @@ -41,22 +37,21 @@ export default { return type.charAt(0).toUpperCase() + type.slice(1); }, - cellCssClass() { - return { - [this.codeCssClass]: true, - 'jupyter-notebook-scrolled': this.metadata.scrolled, - }; + maxHeight() { + return this.metadata.scrolled ? '20rem' : 'initial'; }, }, - mounted() { - Prism.highlightElement(this.$refs.code); - }, }; </script> <template> <div :class="type"> <prompt :type="promptType" :count="count" /> - <pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre> + <code-block-highlighted + language="python" + :code="code" + :max-height="maxHeight" + class="gl-border" + /> </div> </template> diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 8351ae7ced6..127e046b5a9 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -137,7 +137,7 @@ marked.setOptions({ export default { components: { - prompt: Prompt, + Prompt, }, directives: { SafeHtml, diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue index 065f5def83c..da7d83539d3 100644 --- a/app/assets/javascripts/notebook/cells/output/image.vue +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -3,7 +3,7 @@ import Prompt from '../prompt.vue'; export default { components: { - prompt: Prompt, + Prompt, }, props: { count: { diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 5f7ef4a4377..88d01ffa659 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -6,11 +6,6 @@ import LatexOutput from './latex.vue'; export default { props: { - codeCssClass: { - type: String, - required: false, - default: '', - }, count: { type: Number, required: false, @@ -96,7 +91,6 @@ export default { :index="index" :raw-code="rawCode(output)" :metadata="metadata" - :code-css-class="codeCssClass" /> </div> </template> diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue index 44dc1856e49..df9694b7cd8 100644 --- a/app/assets/javascripts/notebook/index.vue +++ b/app/assets/javascripts/notebook/index.vue @@ -11,11 +11,6 @@ export default { type: Object, required: true, }, - codeCssClass: { - type: String, - required: false, - default: '', - }, }, computed: { cells() { @@ -52,7 +47,6 @@ export default { v-for="(cell, index) in cells" :key="index" :cell="cell" - :code-css-class="codeCssClass" /> </div> </template> diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js deleted file mode 100644 index 313aeecbd51..00000000000 --- a/app/assets/javascripts/notebook/lib/highlight.js +++ /dev/null @@ -1,5 +0,0 @@ -import Prism from 'prismjs'; -import 'prismjs/components/prism-python'; -import 'prismjs/themes/prism.css'; - -export default Prism; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index bd5945a951b..bf35d5c3b25 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -14,7 +14,7 @@ import { slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { sprintf } from '~/locale'; -import markdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -25,8 +25,8 @@ import { COMMENT_FORM } from '../i18n'; import issuableStateMixin from '../mixins/issuable_state'; import CommentFieldLayout from './comment_field_layout.vue'; import CommentTypeDropdown from './comment_type_dropdown.vue'; -import discussionLockedWidget from './discussion_locked_widget.vue'; -import noteSignedOutWidget from './note_signed_out_widget.vue'; +import DiscussionLockedWidget from './discussion_locked_widget.vue'; +import NoteSignedOutWidget from './note_signed_out_widget.vue'; const { UNPROCESSABLE_ENTITY } = httpStatusCodes; @@ -34,9 +34,9 @@ export default { name: 'CommentForm', i18n: COMMENT_FORM, components: { - noteSignedOutWidget, - discussionLockedWidget, - markdownField, + NoteSignedOutWidget, + DiscussionLockedWidget, + MarkdownField, GlAlert, GlButton, TimelineEntryItem, @@ -214,11 +214,7 @@ export default { note: { noteable_type: this.noteableType, noteable_id: this.getNoteableData.id, - // Internal notes were identified as `confidential` - // before we decided to treat them as _internal_ - // so now until API is updated we need to use `confidential` - // in request payload. - confidential: this.noteIsInternal, + internal: this.noteIsInternal, note: this.note, }, merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index 3cf47f42e0c..1b1923a90f7 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -4,16 +4,16 @@ import { escape } from 'lodash'; import { mapActions } from 'vuex'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; -import noteEditedText from './note_edited_text.vue'; -import noteHeader from './note_header.vue'; +import NoteEditedText from './note_edited_text.vue'; +import NoteHeader from './note_header.vue'; export default { name: 'DiffDiscussionHeader', components: { GlAvatar, GlAvatarLink, - noteEditedText, - noteHeader, + NoteEditedText, + NoteHeader, }, directives: { SafeHtml, diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 6f0745d4fb0..dcbf4a0e5d3 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -59,6 +59,7 @@ export default { <resolve-discussion-button v-if="discussion.resolvable" data-qa-selector="resolve_discussion_button" + data-testid="resolve-discussion-button" :is-resolving="isResolving" :button-title="resolveButtonTitle" @onClick="$emit('resolve')" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index eedcb0c09d4..6521b86edbb 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,7 +1,16 @@ <script> -import { GlTooltipDirective, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { + GlTooltipDirective, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, +} from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; +import { throttle } from 'lodash'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import discussionNavigation from '../mixins/discussion_navigation'; export default { @@ -11,14 +20,23 @@ export default { components: { GlButton, GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, }, - mixins: [discussionNavigation], + mixins: [glFeatureFlagsMixin(), discussionNavigation], props: { blocksMerge: { type: Boolean, required: true, }, }, + data() { + return { + jumpNext: throttle(this.jumpToNextDiscussion, 500), + jumpPrevious: throttle(this.jumpToPreviousDiscussion, 500), + }; + }, computed: { ...mapGetters([ 'getNoteableData', @@ -54,27 +72,44 @@ export default { <template> <div v-if="resolvableDiscussionsCount > 0" + id="discussionCounter" ref="discussionCounter" class="gl-display-flex discussions-counter" > <div - class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3" + class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3 gl-min-h-7" :class="{ 'gl-bg-orange-50': blocksMerge && !allResolved, 'gl-bg-gray-50': !blocksMerge || allResolved, - 'gl-pr-4': allResolved, 'gl-pr-2': !allResolved, }" data-testid="discussions-counter-text" > <template v-if="allResolved"> {{ __('All threads resolved!') }} + <gl-dropdown + size="small" + category="tertiary" + right + toggle-class="btn-icon" + class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2" + > + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + </template> + <gl-dropdown-item + data-testid="toggle-all-discussions-btn" + @click="handleExpandDiscussions" + > + {{ toggleThreadsLabel }} + </gl-dropdown-item> + </gl-dropdown> </template> <template v-else> {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} <gl-button-group class="gl-ml-3"> <gl-button - v-gl-tooltip.hover + v-gl-tooltip:discussionCounter.hover.bottom :title="__('Go to previous unresolved thread')" :aria-label="__('Go to previous unresolved thread')" class="discussion-previous-btn gl-rounded-base! gl-px-2!" @@ -83,10 +118,10 @@ export default { data-track-property="click_previous_unresolved_thread_top" icon="chevron-lg-up" category="tertiary" - @click="jumpToPreviousDiscussion" + @click="jumpPrevious" /> <gl-button - v-gl-tooltip.hover + v-gl-tooltip:discussionCounter.hover.bottom :title="__('Go to next unresolved thread')" :aria-label="__('Go to next unresolved thread')" class="discussion-next-btn gl-rounded-base! gl-px-2!" @@ -95,29 +130,33 @@ export default { data-track-property="click_next_unresolved_thread_top" icon="chevron-lg-down" category="tertiary" - @click="jumpToNextDiscussion" + @click="jumpNext" /> + <gl-dropdown + size="small" + category="tertiary" + right + toggle-class="btn-icon" + class="gl-pt-0! gl-px-2" + > + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + </template> + <gl-dropdown-item + data-testid="toggle-all-discussions-btn" + @click="handleExpandDiscussions" + > + {{ toggleThreadsLabel }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="resolveAllDiscussionsIssuePath && !allResolved" + :href="resolveAllDiscussionsIssuePath" + > + {{ __('Create issue to resolve all threads') }} + </gl-dropdown-item> + </gl-dropdown> </gl-button-group> </template> </div> - <gl-button-group> - <gl-button - v-gl-tooltip - :title="toggleThreadsLabel" - :aria-label="toggleThreadsLabel" - class="toggle-all-discussions-btn" - :icon="allExpanded ? 'collapse' : 'expand'" - @click="handleExpandDiscussions" - /> - <gl-button - v-if="resolveAllDiscussionsIssuePath && !allResolved" - v-gl-tooltip - :href="resolveAllDiscussionsIssuePath" - :title="__('Create issue to resolve all threads')" - :aria-label="__('Create issue to resolve all threads')" - class="new-issue-for-discussion discussion-create-issue-btn" - icon="issue-new" - /> - </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 15887c2738d..8a42fb6bd85 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -2,6 +2,9 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, @@ -9,15 +12,25 @@ import { DISCUSSION_TAB_LABEL, DISCUSSION_FILTER_TYPES, NOTE_UNDERSCORE, + ASC, + DESC, } from '../constants'; import notesEventHub from '../event_hub'; +const SORT_OPTIONS = [ + { key: DESC, text: __('Newest first'), cls: 'js-newest-first' }, + { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' }, +]; + export default { + SORT_OPTIONS, components: { GlDropdown, GlDropdownItem, GlDropdownDivider, + LocalStorageSync, }, + mixins: [Tracking.mixin()], props: { filters: { type: Array, @@ -39,11 +52,24 @@ export default { }; }, computed: { - ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']), + ...mapGetters([ + 'getNotesDataByProp', + 'timelineEnabled', + 'isLoading', + 'sortDirection', + 'persistSortOrder', + 'noteableType', + ]), currentFilter() { if (!this.currentValue) return this.filters[0]; return this.filters.find((filter) => filter.value === this.currentValue); }, + selectedSortOption() { + return SORT_OPTIONS.find(({ key }) => this.sortDirection === key); + }, + sortStorageKey() { + return `sort_direction_${this.noteableType.toLowerCase()}`; + }, }, created() { if (window.mrTabs) { @@ -69,6 +95,7 @@ export default { 'setCommentsDisabled', 'setTargetNoteHash', 'setTimelineView', + 'setDiscussionSortDirection', ]), selectFilter(value, persistFilter = true) { const filter = parseInt(value, 10); @@ -108,31 +135,73 @@ export default { } return DISCUSSION_FILTER_TYPES.HISTORY; }, + fetchSortedDiscussions(direction) { + if (this.isSortDropdownItemActive(direction)) { + return; + } + + this.setDiscussionSortDirection({ direction }); + this.track('change_discussion_sort_direction', { property: direction }); + }, + isSortDropdownItemActive(sortDir) { + return sortDir === this.sortDirection; + }, }, }; </script> <template> - <gl-dropdown + <div v-if="displayFilters" - id="discussion-filter-dropdown" - class="full-width-mobile discussion-filter-container js-discussion-filter-container" - data-qa-selector="discussion_filter_dropdown" - :text="currentFilter.title" - :disabled="isLoading" + id="discussion-preferences" + data-testid="discussion-preferences" + class="gl-display-inline-block gl-vertical-align-bottom full-width-mobile" > - <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> - <gl-dropdown-item - :is-check-item="true" - :is-checked="filter.value === currentValue" - :class="{ 'is-active': filter.value === currentValue }" - :data-filter-type="filterType(filter.value)" - data-qa-selector="filter_menu_item" - @click.prevent="selectFilter(filter.value)" + <local-storage-sync + :value="sortDirection" + :storage-key="sortStorageKey" + :persist="persistSortOrder" + as-string + @input="setDiscussionSortDirection({ direction: $event })" + /> + <gl-dropdown + id="discussion-preferences-dropdown" + class="full-width-mobile" + data-qa-selector="discussion_preferences_dropdown" + text="Sort or filter" + :disabled="isLoading" + right + > + <div id="discussion-sort"> + <gl-dropdown-item + v-for="{ text, key, cls } in $options.SORT_OPTIONS" + :key="text" + :class="cls" + is-check-item + :is-checked="isSortDropdownItemActive(key)" + @click="fetchSortedDiscussions(key)" + > + {{ text }} + </gl-dropdown-item> + </div> + <gl-dropdown-divider /> + <div + id="discussion-filter" + class="discussion-filter-container js-discussion-filter-container" > - {{ filter.title }} - </gl-dropdown-item> - <gl-dropdown-divider v-if="filter.value === defaultValue" /> - </div> - </gl-dropdown> + <gl-dropdown-item + v-for="filter in filters" + :key="filter.value" + is-check-item + :is-checked="filter.value === currentValue" + :class="{ 'is-active': filter.value === currentValue }" + :data-filter-type="filterType(filter.value)" + data-qa-selector="filter_menu_item" + @click.prevent="selectFilter(filter.value)" + > + {{ filter.title }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> </template> diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue index c1e39f31bbb..03bdc7a2cc6 100644 --- a/app/assets/javascripts/notes/components/discussion_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_navigator.vue @@ -1,6 +1,7 @@ <script> /* global Mousetrap */ import 'mousetrap'; +import { throttle } from 'lodash'; import { keysFor, MR_NEXT_UNRESOLVED_DISCUSSION, @@ -11,12 +12,18 @@ import discussionNavigation from '~/notes/mixins/discussion_navigation'; export default { mixins: [discussionNavigation], + data() { + return { + jumpToNext: throttle(() => this.jumpToNextDiscussion({ behavior: 'auto' }), 200), + jumpToPrevious: throttle(() => this.jumpToPreviousDiscussion({ behavior: 'auto' }), 200), + }; + }, created() { eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); }, mounted() { - Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion); - Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion); + Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNext); + Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPrevious); }, beforeDestroy() { Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION)); diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c7f293a219a..9806f8e5dc2 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import createFlash from '~/flash'; @@ -11,6 +11,7 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { splitCamelCase } from '~/lib/utils/text_utility'; import ReplyButton from './note_actions/reply_button.vue'; +import TimelineEventButton from './note_actions/timeline_event_button.vue'; export default { i18n: { @@ -23,6 +24,7 @@ export default { components: { GlIcon, ReplyButton, + TimelineEventButton, GlButton, GlDropdownItem, UserAccessRoleBadge, @@ -133,7 +135,8 @@ export default { }, }, computed: { - ...mapGetters(['getUserDataByProp', 'getNoteableData']), + ...mapState(['isPromoteCommentToTimelineEventInProgress']), + ...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']), shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -199,7 +202,7 @@ export default { }, }, methods: { - ...mapActions(['toggleAwardRequest']), + ...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']), onEdit() { this.$emit('handleEdit'); }, @@ -292,6 +295,12 @@ export default { class="line-resolve-btn note-action-button" @click="onResolve" /> + <timeline-event-button + v-if="canUserAddIncidentTimelineEvents" + :note-id="noteId" + :is-promotion-in-progress="isPromoteCommentToTimelineEventInProgress" + @click-promote-comment-to-event="promoteCommentToTimelineEvent" + /> <emoji-picker v-if="canAwardEmoji" toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" diff --git a/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue new file mode 100644 index 00000000000..4dd0c968282 --- /dev/null +++ b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue @@ -0,0 +1,49 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + buttonText: __('Add comment to incident timeline'), + addError: __('Error promoting the note to timeline event: %{error}'), + addGenericError: __('Something went wrong while promoting the note to timeline event.'), + }, + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + noteId: { + type: [String, Number], + required: true, + }, + isPromotionInProgress: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + handleButtonClick() { + this.$emit('click-promote-comment-to-event', { + noteId: this.noteId, + addError: this.$options.i18n.addError, + addGenericError: this.$options.i18n.addGenericError, + }); + }, + }, +}; +</script> +<template> + <span v-gl-tooltip :title="$options.i18n.buttonText"> + <gl-button + category="tertiary" + icon="clock" + :aria-label="$options.i18n.buttonText" + :disabled="isPromotionInProgress" + @click="handleButtonClick" + /> + </span> +</template> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index f1c41eea428..82c125b79ce 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -8,17 +8,17 @@ import { __ } from '~/locale'; import '~/behaviors/markdown/render_gfm'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import autosave from '../mixins/autosave'; -import noteAttachment from './note_attachment.vue'; -import noteAwardsList from './note_awards_list.vue'; -import noteEditedText from './note_edited_text.vue'; -import noteForm from './note_form.vue'; +import NoteAttachment from './note_attachment.vue'; +import NoteAwardsList from './note_awards_list.vue'; +import NoteEditedText from './note_edited_text.vue'; +import NoteForm from './note_form.vue'; export default { components: { - noteEditedText, - noteAwardsList, - noteAttachment, - noteForm, + NoteEditedText, + NoteAwardsList, + NoteAttachment, + NoteForm, Suggestions, }, directives: { @@ -71,7 +71,7 @@ export default { return this.note.note; }, saveButtonTitle() { - return this.note.confidential ? __('Save internal note') : __('Save comment'); + return this.note.internal ? __('Save internal note') : __('Save comment'); }, hasSuggestion() { return this.note.suggestions && this.note.suggestions.length; diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 03cbdf45ddd..e0c3ed0c67a 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -1,11 +1,11 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { name: 'EditedNoteText', components: { - timeAgoTooltip, + TimeAgoTooltip, }, props: { actionText: { diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 30579a8eb0d..b6ede10d02b 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -4,7 +4,7 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import markdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -15,7 +15,7 @@ export default { i18n: COMMENT_FORM, name: 'NoteForm', components: { - markdownField, + MarkdownField, CommentFieldLayout, GlButton, GlSprintf, @@ -136,7 +136,7 @@ export default { ); }, textareaPlaceholder() { - return this.discussionNote?.confidential + return this.discussionNote?.internal ? this.$options.i18n.bodyPlaceholderInternal : this.$options.i18n.bodyPlaceholder; }, @@ -331,7 +331,7 @@ export default { <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> <comment-field-layout :noteable-data="getNoteableData" - :is-internal-note="discussion.confidential" + :is-internal-note="discussion.internal" > <markdown-field :markdown-preview-path="markdownPreviewPath" @@ -423,7 +423,7 @@ export default { category="primary" variant="confirm" data-qa-selector="reply_comment_button" - class="gl-mr-3 js-vue-issue-save js-comment-button" + class="gl-sm-mr-3 gl-xs-mb-3 js-vue-issue-save js-comment-button" @click="handleUpdate()" > {{ saveButtonTitle }} @@ -432,7 +432,7 @@ export default { v-if="discussion.resolvable" category="secondary" variant="default" - class="gl-mr-3 js-comment-resolve-button" + class="gl-sm-mr-3 gl-xs-mb-3 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 9917249f0db..f700802d6bc 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -8,13 +8,14 @@ import { } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, s__ } from '~/locale'; -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { - timeAgoTooltip, + TimeAgoTooltip, GitlabTeamMemberBadge: () => import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), GlIcon, @@ -26,6 +27,7 @@ export default { SafeHtml, GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { author: { type: Object, @@ -183,22 +185,35 @@ export default { :data-user-id="author.id" :data-username="author.username" > - <slot name="note-header-info"></slot> + <span + v-if="glFeatures.removeUserAttributesProjects || glFeatures.removeUserAttributesGroups" + class="note-header-author-name gl-font-weight-bold" + > + {{ authorName }} + </span> <user-name-with-status + v-else :name="authorName" :availability="userAvailability(author)" container-classes="note-header-author-name gl-font-weight-bold" /> </a> <span - v-if="authorStatus" + v-if=" + authorStatus && + !glFeatures.removeUserAttributesProjects && + !glFeatures.removeUserAttributesGroups + " ref="authorStatus" v-safe-html:[$options.safeHtmlConfig]="authorStatus" v-on=" authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} " ></span> - <span class="text-nowrap author-username"> + <span + v-if="!glFeatures.removeUserAttributesProjects && !glFeatures.removeUserAttributesGroups" + class="text-nowrap author-username" + > <a ref="authorUsernameLink" class="author-username-link" @@ -207,6 +222,7 @@ export default { @mouseleave="handleUsernameMouseLeave" ><span class="note-headline-light">@{{ author.username }}</span> </a> + <slot name="note-header-info"></slot> <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" /> </span> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index c5d174ed890..afa5e39d8b0 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -10,25 +10,25 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { s__, __, sprintf } from '~/locale'; import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; -import diffDiscussionHeader from './diff_discussion_header.vue'; -import diffWithNote from './diff_with_note.vue'; +import DiffDiscussionHeader from './diff_discussion_header.vue'; +import DiffWithNote from './diff_with_note.vue'; import DiscussionActions from './discussion_actions.vue'; import DiscussionNotes from './discussion_notes.vue'; -import noteForm from './note_form.vue'; -import noteSignedOutWidget from './note_signed_out_widget.vue'; +import NoteForm from './note_form.vue'; +import NoteSignedOutWidget from './note_signed_out_widget.vue'; export default { name: 'NoteableDiscussion', components: { GlIcon, - userAvatarLink, - diffDiscussionHeader, - noteSignedOutWidget, - noteForm, + UserAvatarLink, + DiffDiscussionHeader, + NoteSignedOutWidget, + NoteForm, DraftNote, TimelineEntryItem, DiscussionNotes, @@ -96,7 +96,7 @@ export default { return isLoggedIn(); }, commentType() { - return this.discussion.confidential ? __('internal note') : __('comment'); + return this.discussion.internal ? __('internal note') : __('comment'); }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); @@ -108,7 +108,7 @@ export default { return this.discussion.notes.slice(0, 1)[0]; }, saveButtonTitle() { - return this.discussion.confidential ? __('Reply internally') : __('Reply'); + return this.discussion.internal ? __('Reply internally') : __('Reply'); }, shouldShowJumpToNextDiscussion() { return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); @@ -120,7 +120,7 @@ export default { return !this.shouldRenderDiffs; }, wrapperComponent() { - return this.shouldRenderDiffs ? diffWithNote : 'div'; + return this.shouldRenderDiffs ? DiffWithNote : 'div'; }, wrapperComponentProps() { if (this.shouldRenderDiffs) { @@ -269,6 +269,7 @@ export default { <div class="timeline-content"> <div :data-discussion-id="discussion.id" + :data-discussion-resolved="discussion.resolved" class="discussion js-discussion-container" data-qa-selector="discussion_content" > diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 875cfff74fe..e51969f95c7 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -22,16 +22,16 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; -import noteActions from './note_actions.vue'; +import NoteActions from './note_actions.vue'; import NoteBody from './note_body.vue'; -import noteHeader from './note_header.vue'; +import NoteHeader from './note_header.vue'; export default { name: 'NoteableNote', components: { GlSprintf, - noteHeader, - noteActions, + NoteHeader, + NoteActions, NoteBody, TimelineEntryItem, GlAvatarLink, @@ -109,7 +109,7 @@ export default { return this.note.author; }, commentType() { - return this.note.confidential ? __('internal note') : __('comment'); + return this.note.internal ? __('internal note') : __('comment'); }, classNameBindings() { return { @@ -259,7 +259,7 @@ export default { }); const confirmed = await confirmAction(msg, { primaryBtnVariant: 'danger', - primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'), + primaryBtnText: this.note.internal ? __('Delete internal note') : __('Delete comment'), }); if (confirmed) { @@ -406,7 +406,7 @@ export default { <template> <timeline-entry-item :id="noteAnchorId" - :class="{ ...classNameBindings, 'internal-note': note.confidential }" + :class="{ ...classNameBindings, 'internal-note': note.internal }" :data-award-url="note.toggle_award_path" :data-note-id="note.id" class="note note-wrapper" @@ -440,7 +440,7 @@ export default { </gl-avatar-link> </div> - <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2"> + <div v-else class="gl-float-left gl-pl-3 gl-md-pl-2"> <gl-avatar-link :href="author.path"> <gl-avatar :src="author.avatar_url" @@ -459,7 +459,7 @@ export default { :author="author" :created-at="note.created_at" :note-id="note.id" - :is-internal-note="note.confidential" + :is-internal-note="note.internal" :noteable-type="noteableType" > <template #note-header-info> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 754c2917182..37bc8bad305 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -6,34 +6,34 @@ import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import draftNote from '~/batch_comments/components/draft_note.vue'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; -import placeholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; -import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; -import skeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; -import systemNote from '~/vue_shared/components/notes/system_note.vue'; +import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; +import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; +import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; +import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import commentForm from './comment_form.vue'; -import discussionFilterNote from './discussion_filter_note.vue'; -import noteableDiscussion from './noteable_discussion.vue'; -import noteableNote from './noteable_note.vue'; +import CommentForm from './comment_form.vue'; +import DiscussionFilterNote from './discussion_filter_note.vue'; +import NoteableDiscussion from './noteable_discussion.vue'; +import NoteableNote from './noteable_note.vue'; import SidebarSubscription from './sidebar_subscription.vue'; export default { name: 'NotesApp', components: { - noteableNote, - noteableDiscussion, - systemNote, - commentForm, - placeholderNote, - placeholderSystemNote, - skeletonLoadingContainer, - discussionFilterNote, + NoteableNote, + NoteableDiscussion, + SystemNote, + CommentForm, + PlaceholderNote, + PlaceholderSystemNote, + SkeletonLoadingContainer, + DiscussionFilterNote, OrderedLayout, SidebarSubscription, - draftNote, + DraftNote, TimelineEntryItem, }, mixins: [glFeatureFlagsMixin()], diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 52dadc7b4c3..9fc11ff65d5 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -3,7 +3,7 @@ import { mapActions } from 'vuex'; import { IssuableType } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; import { confidentialityQueries } from '~/sidebar/constants'; -import { defaultClient as gqlClient } from '~/sidebar/graphql'; +import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client'; export default { props: { diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue deleted file mode 100644 index bcc5d12b7c8..00000000000 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ /dev/null @@ -1,76 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; -import { __ } from '~/locale'; -import Tracking from '~/tracking'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { ASC, DESC } from '../constants'; - -const SORT_OPTIONS = [ - { key: DESC, text: __('Newest first'), cls: 'js-newest-first' }, - { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' }, -]; - -export default { - SORT_OPTIONS, - components: { - GlDropdown, - GlDropdownItem, - LocalStorageSync, - }, - mixins: [Tracking.mixin()], - computed: { - ...mapGetters(['sortDirection', 'persistSortOrder', 'noteableType']), - selectedOption() { - return SORT_OPTIONS.find(({ key }) => this.sortDirection === key); - }, - dropdownText() { - return this.selectedOption.text; - }, - storageKey() { - return `sort_direction_${this.noteableType.toLowerCase()}`; - }, - }, - methods: { - ...mapActions(['setDiscussionSortDirection']), - fetchSortedDiscussions(direction) { - if (this.isDropdownItemActive(direction)) { - return; - } - - this.setDiscussionSortDirection({ direction }); - this.track('change_discussion_sort_direction', { property: direction }); - }, - isDropdownItemActive(sortDir) { - return sortDir === this.sortDirection; - }, - }, -}; -</script> - -<template> - <div - data-testid="sort-discussion-filter" - class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" - > - <local-storage-sync - :value="sortDirection" - :storage-key="storageKey" - :persist="persistSortOrder" - as-string - @input="setDiscussionSortDirection({ direction: $event })" - /> - <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile"> - <gl-dropdown-item - v-for="{ text, key, cls } in $options.SORT_OPTIONS" - :key="key" - :class="cls" - :is-check-item="true" - :is-checked="isDropdownItemActive(key)" - @click="fetchSortedDiscussions(key)" - > - {{ text }} - </gl-dropdown-item> - </gl-dropdown> - </div> -</template> diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue index e4d89f54652..8632eea5d8e 100644 --- a/app/assets/javascripts/notes/components/timeline_toggle.vue +++ b/app/assets/javascripts/notes/components/timeline_toggle.vue @@ -53,7 +53,6 @@ export default { :selected="timelineEnabled" :title="tooltip" :aria-label="tooltip" - class="gl-mr-3" @click="toggleTimeline" /> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index a5f459c8910..88f438975f6 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -13,6 +13,7 @@ export const MERGED = 'merged'; export const ISSUE_NOTEABLE_TYPE = 'Issue'; export const EPIC_NOTEABLE_TYPE = 'Epic'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; +export const INCIDENT_NOTEABLE_TYPE = 'INCIDENT'; // TODO: check if value can be converted to `Incident` export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; @@ -31,6 +32,7 @@ export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, Epic: EPIC_NOTEABLE_TYPE, + Incident: INCIDENT_NOTEABLE_TYPE, }; export const DISCUSSION_FILTER_TYPES = { diff --git a/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql new file mode 100644 index 00000000000..c9df9cfd6d3 --- /dev/null +++ b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql @@ -0,0 +1,8 @@ +mutation PromoteTimelineEvent($input: TimelineEventPromoteFromNoteInput!) { + timelineEventPromoteFromNote(input: $input) { + timelineEvent { + id + } + errors + } +} diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 19fa484d659..054a5bd36e2 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import notesApp from './components/notes_app.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NotesApp from './components/notes_app.vue'; import initDiscussionFilters from './discussion_filters'; -import initSortDiscussions from './sort_discussions'; import { store } from './stores'; import initTimelineToggle from './timeline'; @@ -16,7 +16,7 @@ export default () => { el, name: 'NotesRoot', components: { - notesApp, + NotesApp, }, store, data() { @@ -40,6 +40,7 @@ export default () => { username: parsedUserData.username, avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, path: parsedUserData.path, + can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), }; } @@ -61,6 +62,5 @@ export default () => { }); initDiscussionFilters(store); - initSortDiscussions(store); initTimelineToggle(store); }; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 45df91796fc..db5f9ebf3f0 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,5 +1,5 @@ import { mapGetters, mapActions, mapState } from 'vuex'; -import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElementWithContext, scrollToElement, contentTop } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; @@ -7,13 +7,14 @@ import eventHub from '../event_hub'; * @param {string} selector * @returns {boolean} */ -function scrollTo(selector, { withoutContext = false } = {}) { +function scrollTo(selector, { withoutContext = false, offset = 0 } = {}) { const el = document.querySelector(selector); const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext; if (el) { scrollFunction(el, { behavior: 'auto', + offset, }); return true; } @@ -67,7 +68,10 @@ function diffsJump({ expandDiscussion }, id, firstNoteId) { function discussionJump({ expandDiscussion }, id) { const selector = `div.discussion[data-discussion-id="${id}"]`; expandDiscussion({ discussionId: id }); - return scrollTo(selector, { withoutContext: true }); + return scrollTo(selector, { + withoutContext: true, + offset: window.gon?.features?.movedMrSidebar ? -28 : 0, + }); } /** @@ -94,8 +98,6 @@ function jumpToDiscussion(self, discussion) { if (activeTab === 'diffs' && isDiffDiscussion) { diffsJump(self, id, firstNoteId); - } else if (activeTab === 'show') { - discussionJump(self, id); } else { switchToDiscussionsTabAndJumpTo(self, id); } @@ -105,11 +107,10 @@ function jumpToDiscussion(self, discussion) { /** * @param {object} self Component instance with mixin applied * @param {function} fn Which function used to get the target discussion's id - * @param {string} [discussionId=this.currentDiscussionId] Current discussion id, will be null if discussions have not been traversed yet */ -function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) { +function handleDiscussionJump(self, fn) { const isDiffView = window.mrTabs.currentAction === 'diffs'; - const targetId = fn(discussionId, isDiffView); + const targetId = fn(self.currentDiscussionId, isDiffView); const discussion = self.getDiscussion(targetId); const discussionFilePath = discussion?.diff_file?.file_path; @@ -127,6 +128,70 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) }); } +function getAllDiscussionElements() { + return Array.from( + document.querySelectorAll('[data-discussion-id]:not([data-discussion-resolved])'), + ); +} + +function hasReachedPageEnd() { + return document.body.scrollHeight <= Math.ceil(window.scrollY + window.innerHeight); +} + +function findNextClosestVisibleDiscussion(discussionElements) { + const offsetHeight = contentTop(); + let isActive; + const index = discussionElements.findIndex((element) => { + const { y } = element.getBoundingClientRect(); + const visibleHorizontalOffset = Math.ceil(y) - offsetHeight; + // handle rect rounding errors + isActive = visibleHorizontalOffset < 2; + return visibleHorizontalOffset >= 0; + }); + return [discussionElements[index], index, isActive]; +} + +function getNextDiscussion() { + const discussionElements = getAllDiscussionElements(); + const firstDiscussion = discussionElements[0]; + if (hasReachedPageEnd()) { + return firstDiscussion; + } + const [nextClosestDiscussion, index, isActive] = findNextClosestVisibleDiscussion( + discussionElements, + ); + if (nextClosestDiscussion && !isActive) { + return nextClosestDiscussion; + } + const nextDiscussion = discussionElements[index + 1]; + if (!nextClosestDiscussion || !nextDiscussion) { + return firstDiscussion; + } + return nextDiscussion; +} + +function getPreviousDiscussion() { + const discussionElements = getAllDiscussionElements(); + const lastDiscussion = discussionElements[discussionElements.length - 1]; + const [, index] = findNextClosestVisibleDiscussion(discussionElements); + const previousDiscussion = discussionElements[index - 1]; + if (previousDiscussion) { + return previousDiscussion; + } + return lastDiscussion; +} + +function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) { + if (window.mrTabs.currentAction !== 'show') { + handleDiscussionJump(ctx, fn); + } else { + const discussion = getDiscussion(); + const id = discussion.dataset.discussionId; + ctx.expandDiscussion({ discussionId: id }); + scrollToElement(discussion, scrollOptions); + } +} + export default { computed: { ...mapGetters([ @@ -142,12 +207,22 @@ export default { ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']), ...mapActions('diffs', ['scrollToFile']), - jumpToNextDiscussion() { - handleDiscussionJump(this, this.nextUnresolvedDiscussionId); + jumpToNextDiscussion(scrollOptions) { + handleJumpForBothPages( + getNextDiscussion, + this, + this.nextUnresolvedDiscussionId, + scrollOptions, + ); }, - jumpToPreviousDiscussion() { - handleDiscussionJump(this, this.previousUnresolvedDiscussionId); + jumpToPreviousDiscussion(scrollOptions) { + handleJumpForBothPages( + getPreviousDiscussion, + this, + this.previousUnresolvedDiscussionId, + scrollOptions, + ); }, jumpToFirstUnresolvedDiscussion() { @@ -157,13 +232,5 @@ export default { }) .catch(() => {}); }, - - /** - * Go to the next discussion from the given discussionId - * @param {String} discussionId The id we are jumping from - */ - jumpToNextRelativeDiscussion(discussionId) { - handleDiscussionJump(this, this.nextUnresolvedDiscussionId, discussionId); - }, }, }; diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js deleted file mode 100644 index ca8df880fe4..00000000000 --- a/app/assets/javascripts/notes/sort_discussions.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import SortDiscussion from './components/sort_discussion.vue'; - -export default (store) => { - const el = document.getElementById('js-vue-sort-issue-discussions'); - - if (!el) return null; - - return new Vue({ - el, - name: 'SortDiscussionRoot', - store, - render(createElement) { - return createElement(SortDiscussion); - }, - }); -}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 82417c9134b..fcef26d720c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -6,6 +6,7 @@ import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; +import toast from '~/vue_shared/plugins/global_toast'; import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; @@ -18,6 +19,12 @@ import sidebarTimeTrackingEventHub from '~/sidebar/event_hub'; import TaskList from '~/task_list'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_NOTE } from '~/graphql_shared/constants'; +import notesEventHub from '../event_hub'; + +import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql'; + import * as constants from '../constants'; import * as types from './mutation_types'; import * as utils from './utils'; @@ -226,6 +233,54 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) }); }; +export const promoteCommentToTimelineEvent = ( + { commit }, + { noteId, addError, addGenericError }, +) => { + commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, true); // Set loading state + return utils.gqClient + .mutate({ + mutation: promoteTimelineEvent, + variables: { + input: { + noteId: convertToGraphQLId(TYPE_NOTE, noteId), + }, + }, + }) + .then(({ data = {} }) => { + const errors = data.timelineEventPromoteFromNote?.errors; + if (errors.length) { + const errorMessage = sprintf(addError, { + error: errors.join('. '), + }); + throw new Error(errorMessage); + } else { + notesEventHub.$emit('comment-promoted-to-timeline-event'); + toast(__('Comment added to the timeline.')); + } + }) + .catch((error) => { + const message = error.message || addGenericError; + + let captureError = false; + let errorObj = null; + + if (message === addGenericError) { + captureError = true; + errorObj = error; + } + + createFlash({ + message, + captureError, + error: errorObj, + }); + }) + .finally(() => { + commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false); // Revert loading state + }); +}; + export const replyToDiscussion = ( { commit, state, getters, dispatch }, { endpoint, data: reply }, diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 1fe82d96435..6876220f75c 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -93,6 +93,13 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us export const descriptionVersions = (state) => state.descriptionVersions; +export const canUserAddIncidentTimelineEvents = (state) => { + return ( + state.userData.can_add_timeline_events && + state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident + ); +}; + export const notesById = (state) => state.discussions.reduce((acc, note) => { note.notes.every((n) => Object.assign(acc, { [n.id]: n })); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index f779aad5679..7ba1f470b05 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -30,6 +30,7 @@ export default () => ({ isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, + isPromoteCommentToTimelineEventInProgress: false, // holds endpoints and permissions provided through haml notesData: { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index e28a7bc5cdd..42df6bc0980 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -57,3 +57,6 @@ export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ER export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION'; export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION'; export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR'; + +// Incidents +export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 0823eacf1b7..83c15c12eac 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -425,4 +425,7 @@ export default { [types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](state, value) { state.doneFetchingBatchDiscussions = value; }, + [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) { + state.isPromoteCommentToTimelineEventInProgress = value; + }, }; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue new file mode 100644 index 00000000000..b55204de875 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue @@ -0,0 +1,95 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import { + NO_ARTIFACTS_TITLE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/harbor_registry/constants'; +import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue'; + +export default { + name: 'TagsList', + components: { + GlEmptyState, + ArtifactsListRow, + TagsLoader, + RegistryList, + }, + inject: ['noContainersImage'], + props: { + artifacts: { + type: Array, + required: true, + }, + filter: { + type: String, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + tags: [], + tagsPageInfo: {}, + }; + }, + computed: { + hasNoTags() { + return this.artifacts.length === 0; + }, + emptyStateTitle() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE; + }, + emptyStateDescription() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : ''; + }, + }, + methods: { + fetchNextPage() { + this.$emit('next-page'); + }, + fetchPreviousPage() { + this.$emit('prev-page'); + }, + }, +}; +</script> + +<template> + <div> + <tags-loader v-if="isLoading" /> + <template v-else> + <gl-empty-state + v-if="hasNoTags" + :title="emptyStateTitle" + :svg-path="noContainersImage" + :description="emptyStateDescription" + class="gl-mx-auto gl-my-0" + /> + <template v-else> + <registry-list + :pagination="pageInfo" + :items="artifacts" + :hidden-delete="true" + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + > + <template #default="{ item }"> + <artifacts-list-row :artifact="item" /> + </template> + </registry-list> + </template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue new file mode 100644 index 00000000000..b489f126f75 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue @@ -0,0 +1,133 @@ +<script> +import { GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { + DIGEST_LABEL, + CREATED_AT_LABEL, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/packages_and_registries/harbor_registry/constants'; +import { artifactPullCommand } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'TagsListRow', + components: { + GlSprintf, + GlIcon, + ListItem, + ClipboardButton, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['repositoryUrl', 'harborIntegrationProjectName'], + props: { + artifact: { + type: Object, + required: true, + }, + }, + i18n: { + digestLabel: DIGEST_LABEL, + createdAtLabel: CREATED_AT_LABEL, + }, + computed: { + formattedSize() { + return this.artifact.size + ? numberToHumanSize(Number(this.artifact.size)) + : NOT_AVAILABLE_SIZE; + }, + tagsCountText() { + const count = this.artifact?.tags.length ? this.artifact?.tags.length : 0; + + return n__('%d tag', '%d tags', count); + }, + shortDigest() { + // remove sha256: from the string, and show only the first 7 char + const PREFIX_LENGTH = 'sha256:'.length; + const DIGEST_LENGTH = 7; + return ( + this.artifact.digest?.substring(PREFIX_LENGTH, PREFIX_LENGTH + DIGEST_LENGTH) ?? + NOT_AVAILABLE_TEXT + ); + }, + getPullCommand() { + if (this.artifact?.digest) { + const { image } = this.$route.params; + return artifactPullCommand({ + digest: this.artifact.digest, + imageName: image, + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + }); + } + + return ''; + }, + linkTo() { + const { project, image } = this.$route.params; + + return { name: 'tags', params: { project, image, digest: this.artifact.digest } }; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <router-link + class="gl-text-body gl-font-weight-bold gl-word-break-all" + data-testid="name" + :to="linkTo" + > + {{ artifact.digest }} + </router-link> + <clipboard-button + v-if="getPullCommand" + :title="getPullCommand" + :text="getPullCommand" + category="tertiary" + /> + </div> + </template> + + <template #left-secondary> + <span class="gl-mr-3" data-testid="size"> + {{ formattedSize }} + </span> + <span id="tagsCount" class="gl-display-flex gl-align-items-center" data-testid="tags-count"> + <gl-icon name="tag" class="gl-mr-2" /> + {{ tagsCountText }} + </span> + </template> + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.createdAtLabel"> + <template #timeInfo> + <time-ago-tooltip :time="artifact.pushTime" /> + </template> + </gl-sprintf> + </span> + </template> + <template #right-secondary> + <span data-testid="digest"> + <gl-sprintf :message="$options.i18n.digestLabel"> + <template #imageId>{{ shortDigest }}</template> + </gl-sprintf> + </span> + <clipboard-button + v-if="artifact.digest" + :title="artifact.digest" + :text="artifact.digest" + category="tertiary" + /> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue new file mode 100644 index 00000000000..bfb097601d5 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue @@ -0,0 +1,47 @@ +<script> +import { isEmpty } from 'lodash'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { + ROOT_IMAGE_TEXT, + EMPTY_ARTIFACTS_LABEL, + artifactsLabel, +} from '~/packages_and_registries/harbor_registry/constants/index'; + +export default { + name: 'DetailsHeader', + components: { TitleArea, MetadataItem }, + mixins: [timeagoMixin], + props: { + imagesDetail: { + type: Object, + required: true, + }, + }, + computed: { + artifactCountText() { + if (isEmpty(this.imagesDetail)) { + return EMPTY_ARTIFACTS_LABEL; + } + return artifactsLabel(this.imagesDetail.artifactCount); + }, + repositoryFullName() { + return this.imagesDetail.name || ROOT_IMAGE_TEXT; + }, + }, +}; +</script> + +<template> + <title-area> + <template #title> + <span data-testid="title"> + {{ repositoryFullName }} + </span> + </template> + <template #metadata-tags-count> + <metadata-item icon="package" :text="artifactCountText" data-testid="artifacts-count" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue new file mode 100644 index 00000000000..ac1df5cf93f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue @@ -0,0 +1,68 @@ +<script> +// Since app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue +// can only handle two levels of breadcrumbs, but we have three levels here. +// So we extended the registry_breadcrumb.vue component with harbor_registry_breadcrumb.vue to support multiple levels of breadcrumbs +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; +import { isArray, last } from 'lodash'; + +export default { + components: { + GlBreadcrumb, + GlIcon, + }, + computed: { + rootRoute() { + return this.$router.options.routes.find((r) => r.meta.root); + }, + isRootRoute() { + return this.$route.name === this.rootRoute.name; + }, + currentRoute() { + const currentName = this.$route.meta.nameGenerator(); + const currentHref = this.$route.meta.hrefGenerator(); + let routeInfoList = [ + { + text: currentName, + to: currentHref, + }, + ]; + + if (isArray(currentName) && isArray(currentHref)) { + routeInfoList = currentName.map((name, index) => { + return { + text: name, + to: currentHref[index], + }; + }); + } + + return routeInfoList; + }, + isLoaded() { + return this.isRootRoute || last(this.currentRoute).text; + }, + allCrumbs() { + let crumbs = [ + { + text: this.rootRoute.meta.nameGenerator(), + to: this.rootRoute.path, + }, + ]; + if (!this.isRootRoute) { + crumbs = crumbs.concat(this.currentRoute); + } + return crumbs; + }, + }, +}; +</script> + +<template> + <gl-breadcrumb :key="isLoaded" :items="allCrumbs"> + <template #separator> + <span class="gl-mx-n5"> + <gl-icon name="chevron-lg-right" :size="8" /> + </span> + </template> + </gl-breadcrumb> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue index 086b9c73d75..db66ebef937 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue @@ -5,6 +5,7 @@ import { HARBOR_REGISTRY_TITLE, LIST_INTRO_TEXT, imagesCountInfoText, + HARBOR_REGISTRY_HELP_PAGE_PATH, } from '~/packages_and_registries/harbor_registry/constants'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; @@ -20,11 +21,6 @@ export default { default: 0, required: false, }, - helpPagePath: { - type: String, - default: '', - required: false, - }, metadataLoading: { type: Boolean, required: false, @@ -32,7 +28,7 @@ export default { }, }, i18n: { - HARBOR_REGISTRY_TITLE, + harborRegistryTitle: HARBOR_REGISTRY_TITLE, }, computed: { imagesCountText() { @@ -40,7 +36,7 @@ export default { return sprintf(pluralisedString, { count: this.imagesCount }); }, infoMessages() { - return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + return [{ text: LIST_INTRO_TEXT, link: HARBOR_REGISTRY_HELP_PAGE_PATH }]; }, }, }; @@ -48,7 +44,7 @@ export default { <template> <title-area - :title="$options.i18n.HARBOR_REGISTRY_TITLE" + :title="$options.i18n.harborRegistryTitle" :info-messages="infoMessages" :metadata-loading="metadataLoading" > diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue index 258472fe16e..bfe0c250dd9 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue @@ -1,15 +1,14 @@ <script> -import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlSkeletonLoader } from '@gitlab/ui'; import { n__ } from '~/locale'; - import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { getNameFromParams } from '~/packages_and_registries/harbor_registry/utils'; export default { name: 'HarborListRow', components: { ClipboardButton, - GlSprintf, GlIcon, ListItem, GlSkeletonLoader, @@ -26,19 +25,18 @@ export default { }, }, computed: { - id() { - return this.item.id; + linkTo() { + const { projectName, imageName } = getNameFromParams(this.item.name); + + return { name: 'details', params: { project: projectName, image: imageName } }; }, artifactCountText() { return n__( - 'HarborRegistry|%{count} Tag', - 'HarborRegistry|%{count} Tags', + 'HarborRegistry|%d artifact', + 'HarborRegistry|%d artifacts', this.item.artifactCount, ); }, - imageName() { - return this.item.name; - }, }, }; </script> @@ -50,9 +48,9 @@ export default { class="gl-text-body gl-font-weight-bold" data-testid="details-link" data-qa-selector="registry_image_content" - :to="{ name: 'details', params: { id } }" + :to="linkTo" > - {{ imageName }} + {{ item.name }} </router-link> <clipboard-button v-if="item.location" @@ -63,13 +61,9 @@ export default { </template> <template #left-secondary> <template v-if="!metadataLoading"> - <span class="gl-display-flex gl-align-items-center" data-testid="tags-count"> - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="artifactCountText"> - <template #count> - {{ item.artifactCount }} - </template> - </gl-sprintf> + <span class="gl-display-flex gl-align-items-center" data-testid="artifacts-count"> + <gl-icon name="package" class="gl-mr-2" /> + {{ artifactCountText }} </span> </template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue new file mode 100644 index 00000000000..e7f6989c49f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue @@ -0,0 +1,54 @@ +<script> +import { isEmpty } from 'lodash'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { + EMPTY_TAG_LABEL, + tagsCountText, +} from '~/packages_and_registries/harbor_registry/constants'; + +export default { + name: 'TagsHeader', + components: { + TitleArea, + MetadataItem, + }, + mixins: [timeagoMixin], + props: { + artifactDetail: { + type: Object, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + tagsLoading: { + type: Boolean, + required: true, + }, + }, + computed: { + tagCountText() { + if (isEmpty(this.pageInfo)) { + return EMPTY_TAG_LABEL; + } + return tagsCountText(this.pageInfo.total); + }, + }, +}; +</script> + +<template> + <title-area :metadata-loading="tagsLoading"> + <template #title> + <span class="gl-word-break-all" data-testid="title"> + {{ artifactDetail.digest }} + </span> + </template> + <template #metadata-tags-count> + <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue new file mode 100644 index 00000000000..b34d3a950c0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue @@ -0,0 +1,82 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import TagsListRow from '~/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue'; +import { + NO_ARTIFACTS_TITLE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/harbor_registry/constants'; + +export default { + name: 'TagsList', + components: { + GlEmptyState, + TagsLoader, + TagsListRow, + RegistryList, + }, + inject: ['noContainersImage'], + props: { + tags: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + hasNoTags() { + return this.tags.length === 0; + }, + emptyStateTitle() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE; + }, + emptyStateDescription() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : ''; + }, + }, + methods: { + fetchNextPage() { + this.$emit('next-page'); + }, + fetchPreviousPage() { + this.$emit('prev-page'); + }, + }, +}; +</script> + +<template> + <div> + <tags-loader v-if="isLoading" /> + <gl-empty-state + v-else-if="hasNoTags" + :title="emptyStateTitle" + :svg-path="noContainersImage" + :description="emptyStateDescription" + class="gl-mx-auto gl-my-0" + /> + <registry-list + v-else + :pagination="pageInfo" + :items="tags" + hidden-delete + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + > + <template #default="{ item }"> + <tags-list-row :tag="item" /> + </template> + </registry-list> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue new file mode 100644 index 00000000000..63e046c1abc --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue @@ -0,0 +1,74 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { CREATED_AT_LABEL } from '~/packages_and_registries/harbor_registry/constants'; +import { tagPullCommand } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'TagsListRow', + components: { + GlSprintf, + ListItem, + ClipboardButton, + TimeAgoTooltip, + }, + inject: ['harborIntegrationProjectName', 'repositoryUrl'], + props: { + tag: { + type: Object, + required: true, + }, + }, + i18n: { + createdAtLabel: CREATED_AT_LABEL, + }, + methods: { + getPullCommand(tagName) { + if (tagName) { + const { image } = this.$route.params; + + return tagPullCommand({ + imageName: image, + tag: tagName, + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + }); + } + + return ''; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <div + data-testid="name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + > + {{ tag.name }} + </div> + <clipboard-button + :title="getPullCommand(tag.name)" + :text="getPullCommand(tag.name)" + category="tertiary" + /> + </div> + </template> + + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.createdAtLabel"> + <template #timeInfo> + <time-ago-tooltip :time="tag.pushTime" /> + </template> + </gl-sprintf> + </span> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js index a7891821755..7f3c3da02b0 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const ROOT_IMAGE_TEXT = s__('HarborRegistry|Root image'); export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') }; @@ -16,14 +17,8 @@ export const SORT_FIELD_MAPPING = { CREATED: CREATED_SORT_FIELD_KEY, }; -/* eslint-disable @gitlab/require-i18n-strings */ -export const dockerBuildCommand = (repositoryUrl) => { - return `docker build -t ${repositoryUrl} .`; -}; -export const dockerPushCommand = (repositoryUrl) => { - return `docker push ${repositoryUrl}`; -}; -export const dockerLoginCommand = (registryHostUrlWithPort) => { - return `docker login ${registryHostUrlWithPort}`; -}; -/* eslint-enable @gitlab/require-i18n-strings */ +export const DEFAULT_PER_PAGE = 10; + +export const HARBOR_REGISTRY_HELP_PAGE_PATH = helpPagePath( + 'user/packages/harbor_container_registry/index', +); diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js index b62c51bd208..5b4b85ec31e 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js @@ -1,22 +1,10 @@ -import { s__, __ } from '~/locale'; +import { s__, __, n__ } from '~/locale'; -export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}'); - -export const MISSING_OR_DELETED_IMAGE_TITLE = s__( - 'HarborRegistry|The image repository could not be found.', -); - -export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__( - 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.', +export const FETCH_ARTIFACT_LIST_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the artifact list.', ); -export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags'); - -export const NO_TAGS_MESSAGE = s__( - `HarborRegistry|The last tag related to this image was recently removed. -This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. -If you have any questions, contact your administrator.`, -); +export const NO_ARTIFACTS_TITLE = s__('HarborRegistry|This image has no artifacts'); export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results'); @@ -26,14 +14,24 @@ export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__( export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}'); export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}'); -export const PUBLISHED_DETAILS_ROW_TEXT = s__( - 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', -); -export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}'); -export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}'); -export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( - 'HarborRegistry|Invalid tag: missing manifest digest', -); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); + +export const TOKEN_TYPE_TAG_NAME = 'tag_name'; + +export const FETCH_TAGS_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the tags.', +); + +export const TAG_LABEL = s__('HarborRegistry|Tag'); +export const EMPTY_TAG_LABEL = s__('HarborRegistry|-- tags'); + +export const EMPTY_ARTIFACTS_LABEL = s__('HarborRegistry|-- artifacts'); +export const artifactsLabel = (count) => { + return n__('%d artifact', '%d artifacts', count); +}; + +export const tagsCountText = (count) => { + return n__('%d tag', '%d tags', count); +}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js index a6cd59918ff..33950993125 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js @@ -7,8 +7,13 @@ export const HARBOR_REGISTRY_TITLE = s__('HarborRegistry|Harbor Registry'); export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection error'); export const CONNECTION_ERROR_MESSAGE = s__( - `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`, + `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the documentation%{docLinkEnd}.`, ); + +export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the repository list.', +); + export const LIST_INTRO_TEXT = s__( `HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, ); @@ -26,6 +31,13 @@ export const EMPTY_RESULT_MESSAGE = s__( 'HarborRegistry|To widen your search, change or remove the filters above.', ); +export const EMPTY_IMAGES_TITLE = s__( + 'HarborRegistry|There are no harbor images stored for this project', +); +export const EMPTY_IMAGES_MESSAGE = s__( + 'HarborRegistry|With the Harbor Registry, every project can connect to a harbor space to store its Docker images.', +); + export const SORT_FIELDS = [ { orderBy: 'UPDATED', label: __('Updated') }, { orderBy: 'CREATED', label: __('Created') }, diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js index ecfefead61a..6185e4c7bc6 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js @@ -3,14 +3,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; -import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; +import RegistryBreadcrumb from '~/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue'; import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { - dockerBuildCommand, - dockerPushCommand, - dockerLoginCommand, -} from '~/packages_and_registries/harbor_registry/constants'; import createRouter from './router'; import HarborRegistryExplorer from './pages/index.vue'; @@ -35,13 +29,27 @@ export default (id) => { return null; } - const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset; + const { + endpoint, + connectionError, + invalidPathError, + isGroupPage, + noContainersImage, + containersErrorImage, + repositoryUrl, + harborIntegrationProjectName, + projectName, + } = el.dataset; const breadCrumbState = Vue.observable({ name: '', + href: '', updateName(value) { this.name = value; }, + updateHref(value) { + this.href = value; + }, }); const router = createRouter(endpoint, breadCrumbState); @@ -53,16 +61,15 @@ export default (id) => { provide() { return { breadCrumbState, - config: { - ...config, - connectionError: parseBoolean(connectionError), - invalidPathError: parseBoolean(invalidPathError), - isGroupPage: parseBoolean(isGroupPage), - helpPagePath: helpPagePath('user/packages/container_registry/index'), - }, - dockerBuildCommand: dockerBuildCommand(config.repositoryUrl), - dockerPushCommand: dockerPushCommand(config.repositoryUrl), - dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort), + endpoint, + connectionError: parseBoolean(connectionError), + invalidPathError: parseBoolean(invalidPathError), + isGroupPage: parseBoolean(isGroupPage), + repositoryUrl, + harborIntegrationProjectName, + projectName, + containersErrorImage, + noContainersImage, }; }, render(createElement) { diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js deleted file mode 100644 index 50c7df1483c..00000000000 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js +++ /dev/null @@ -1,200 +0,0 @@ -const mockRequestFn = (mockData) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(mockData); - }, 2000); - }); -}; -export const harborListResponse = () => { - const harborListResponseData = { - repositories: [ - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 26, - name: 'shao/flinkx1', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 27, - name: 'shao/flinkx2', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - ], - totalCount: 3, - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - }; - - return mockRequestFn(harborListResponseData); -}; - -export const getHarborRegistryImageDetail = () => { - const harborRegistryImageDetailData = { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - tagsCount: 10, - }; - - return mockRequestFn(harborRegistryImageDetailData); -}; - -export const harborTagsResponse = () => { - const harborTagsResponseData = { - tags: [ - { - digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', - shortRevision: 'f53bde3d4', - createdAt: '2022-03-02T23:59:05+00:00', - totalSize: '6623124', - }, - { - digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', - shortRevision: 'e1fe52d8b', - createdAt: '2022-02-10T01:09:56+00:00', - totalSize: '920760', - }, - { - digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', - shortRevision: 'c72770c6e', - createdAt: '2021-12-22T04:48:48+00:00', - totalSize: '48609053', - }, - { - digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', - shortRevision: '1ac2a4319', - createdAt: '2022-03-09T11:02:27+00:00', - totalSize: '35141894', - }, - { - digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', - shortRevision: 'cf8fee086', - createdAt: '2022-01-21T11:31:43+00:00', - totalSize: '48716070', - }, - { - digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', - shortRevision: '1a4b48198', - createdAt: '2022-01-21T11:31:51+00:00', - totalSize: '6623127', - }, - { - digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', - shortRevision: '03e2e2777', - createdAt: '2022-03-02T23:58:20+00:00', - totalSize: '911377', - }, - { - digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', - shortRevision: '350e78d60', - createdAt: '2022-01-19T13:49:14+00:00', - totalSize: '48710241', - }, - { - digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', - shortRevision: '76038370b', - createdAt: '2022-01-24T12:56:22+00:00', - totalSize: '280065', - }, - { - digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', - shortRevision: '3d4b49a7b', - createdAt: '2022-02-17T17:37:52+00:00', - totalSize: '48655767', - }, - ], - totalCount: 10, - pageInfo: { - hasNextPage: false, - hasPreviousPage: true, - }, - }; - - return mockRequestFn(harborTagsResponseData); -}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue index e69de29bb2d..c6ab746b9f4 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue @@ -0,0 +1,156 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { + NAME_SORT_FIELD, + ROOT_IMAGE_TEXT, + DEFAULT_PER_PAGE, + FETCH_ARTIFACT_LIST_ERROR_MESSAGE, + TOKEN_TYPE_TAG_NAME, + TAG_LABEL, +} from '~/packages_and_registries/harbor_registry/constants/index'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { createAlert } from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue'; +import { + extractSortingDetail, + parseFilter, + formatPagination, +} from '~/packages_and_registries/harbor_registry/utils'; +import { getHarborArtifacts } from '~/rest_api'; + +export default { + name: 'HarborDetailsPage', + components: { + ArtifactsList, + TagsLoader, + DetailsHeader, + PersistedSearch, + }, + inject: ['endpoint', 'breadCrumbState'], + searchConfig: { nameSortFields: [NAME_SORT_FIELD] }, + tokens: [ + { + type: TOKEN_TYPE_TAG_NAME, + icon: 'tag', + title: TAG_LABEL, + unique: true, + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + }, + ], + data() { + return { + artifactsList: [], + pageInfo: {}, + mutationLoading: false, + deleteAlertType: null, + isLoading: true, + filterString: '', + sorting: null, + }; + }, + computed: { + currentPage() { + return this.pageInfo.page || 1; + }, + imagesDetail() { + return { + name: this.fullName, + artifactCount: this.pageInfo?.total || 0, + }; + }, + fullName() { + const { project, image } = this.$route.params; + + if (project && image) { + return `${project}/${image}`; + } + return ''; + }, + }, + mounted() { + this.updateBreadcrumb(); + }, + methods: { + updateBreadcrumb() { + const name = this.fullName || ROOT_IMAGE_TEXT; + this.breadCrumbState.updateName(name); + this.breadCrumbState.updateHref(this.$route.path); + }, + handleSearchUpdate({ sort, filters }) { + this.sorting = sort; + this.filterString = parseFilter(filters, 'digest'); + + this.fetchArtifacts(1); + }, + fetchPrevPage() { + const prevPageNum = this.currentPage - 1; + this.fetchArtifacts(prevPageNum); + }, + fetchNextPage() { + const nextPageNum = this.currentPage + 1; + this.fetchArtifacts(nextPageNum); + }, + fetchArtifacts(requestPage) { + this.isLoading = true; + + const { orderBy, sort } = extractSortingDetail(this.sorting); + const sortOptions = `${orderBy} ${sort}`; + + const { image } = this.$route.params; + + const params = { + requestPath: this.endpoint, + repoName: image, + limit: DEFAULT_PER_PAGE, + page: requestPage, + sort: sortOptions, + search: this.filterString, + }; + + getHarborArtifacts(params) + .then((res) => { + this.pageInfo = formatPagination(res.headers); + + this.artifactsList = (res?.data || []).map((artifact) => { + return convertObjectPropsToCamelCase(artifact); + }); + }) + .catch(() => { + createAlert({ message: FETCH_ARTIFACT_LIST_ERROR_MESSAGE }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="gl-my-3"> + <details-header :images-detail="imagesDetail" /> + <persisted-search + class="gl-mb-5" + :sortable-fields="$options.searchConfig.nameSortFields" + :default-order="$options.searchConfig.nameSortFields[0].orderBy" + default-sort="asc" + :tokens="$options.tokens" + @update="handleSearchUpdate" + /> + <tags-loader v-if="isLoading" /> + <artifacts-list + v-else + :filter="filterString" + :is-loading="isLoading" + :artifacts="artifactsList" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue new file mode 100644 index 00000000000..1323d347d10 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue @@ -0,0 +1,103 @@ +<script> +import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue'; +import TagsList from '~/packages_and_registries/harbor_registry/components/tags/tags_list.vue'; +import { getHarborTags } from '~/rest_api'; +import { FETCH_TAGS_ERROR_MESSAGE } from '~/packages_and_registries/harbor_registry/constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { createAlert } from '~/flash'; +import { formatPagination } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'HarborTagsPage', + components: { + TagsHeader, + TagsList, + }, + inject: ['endpoint', 'breadCrumbState'], + data() { + return { + tagsLoading: false, + pageInfo: {}, + tags: [], + }; + }, + computed: { + currentPage() { + return this.pageInfo?.page || 1; + }, + artifactDetail() { + const { project, image, digest } = this.$route.params; + + return { + project, + image, + digest, + }; + }, + }, + mounted() { + this.updateBreadcrumb(); + this.fetchTagsData(); + }, + methods: { + updateBreadcrumb() { + const artifactPath = `${this.artifactDetail.project}/${this.artifactDetail.image}`; + const nameList = [artifactPath, this.artifactDetail.digest]; + const hrefList = [`/${artifactPath}`, this.$route.path]; + + this.breadCrumbState.updateName(nameList); + this.breadCrumbState.updateHref(hrefList); + }, + fetchPrevPage() { + const prevPageNum = this.currentPage - 1; + this.fetchTagsData(prevPageNum); + }, + fetchNextPage() { + const nextPageNum = this.currentPage + 1; + this.fetchTagsData(nextPageNum); + }, + fetchTagsData(requestPage) { + this.tagsLoading = true; + + const params = { + page: requestPage, + requestPath: this.endpoint, + repoName: this.artifactDetail.image, + digest: this.artifactDetail.digest, + }; + + getHarborTags(params) + .then((res) => { + this.pageInfo = formatPagination(res.headers); + + this.tags = (res?.data || []).map((tagInfo) => { + return convertObjectPropsToCamelCase(tagInfo); + }); + }) + .catch(() => { + createAlert({ message: FETCH_TAGS_ERROR_MESSAGE }); + }) + .finally(() => { + this.tagsLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <tags-header + :artifact-detail="artifactDetail" + :page-info="pageInfo" + :tags-loading="tagsLoading" + /> + <tags-list + :tags="tags" + :is-loading="tagsLoading" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue index 9c69059c968..931a99649cb 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue @@ -1,19 +1,32 @@ <script> import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; -import { escape } from 'lodash'; import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + extractSortingDetail, + formatPagination, + parseFilter, + dockerBuildCommand, + dockerPushCommand, + dockerLoginCommand, +} from '~/packages_and_registries/harbor_registry/utils'; +import { createAlert } from '~/flash'; import { SORT_FIELDS, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, + DEFAULT_PER_PAGE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, + EMPTY_IMAGES_TITLE, + EMPTY_IMAGES_MESSAGE, + HARBOR_REGISTRY_HELP_PAGE_PATH, } from '~/packages_and_registries/harbor_registry/constants'; import Tracking from '~/tracking'; -import { harborListResponse } from '../mock_api'; +import { getHarborRepositoriesList } from '~/rest_api'; export default { name: 'HarborListPage', @@ -31,19 +44,28 @@ export default { ), }, mixins: [Tracking.mixin()], - inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + inject: [ + 'endpoint', + 'repositoryUrl', + 'harborIntegrationProjectName', + 'projectName', + 'isGroupPage', + 'connectionError', + 'invalidPathError', + 'containersErrorImage', + 'noContainersImage', + ], loader: { repeat: 10, width: 1000, height: 40, }, i18n: { - CONNECTION_ERROR_TITLE, - CONNECTION_ERROR_MESSAGE, - EMPTY_RESULT_TITLE, - EMPTY_RESULT_MESSAGE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, }, searchConfig: SORT_FIELDS, + helpPagePath: HARBOR_REGISTRY_HELP_PAGE_PATH, data() { return { images: [], @@ -56,42 +78,81 @@ export default { }; }, computed: { + dockerCommand() { + return { + build: dockerBuildCommand({ + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + projectName: this.projectName, + }), + push: dockerPushCommand({ + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + projectName: this.projectName, + }), + login: dockerLoginCommand(this.repositoryUrl), + }; + }, showCommands() { - return !this.isLoading && !this.config?.isGroupPage && this.images?.length; + return !this.isLoading && !this.isGroupPage && this.images?.length; }, showConnectionError() { - return this.config.connectionError || this.config.invalidPathError; + return this.connectionError || this.invalidPathError; + }, + currentPage() { + return this.pageInfo.page || 1; + }, + emptyStateTexts() { + return { + title: this.name ? EMPTY_RESULT_TITLE : EMPTY_IMAGES_TITLE, + message: this.name ? EMPTY_RESULT_MESSAGE : EMPTY_IMAGES_MESSAGE, + }; }, }, methods: { - fetchHarborImages() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 + fetchHarborImages(requestPage) { this.isLoading = true; - harborListResponse() + const { orderBy, sort } = extractSortingDetail(this.sorting); + const sortOptions = `${orderBy} ${sort}`; + + const params = { + requestPath: this.endpoint, + limit: DEFAULT_PER_PAGE, + search: this.name, + page: requestPage, + sort: sortOptions, + }; + + getHarborRepositoriesList(params) .then((res) => { - this.images = res?.repositories || []; - this.totalCount = res?.totalCount || 0; - this.pageInfo = res?.pageInfo || {}; + this.images = (res?.data || []).map((item) => { + return convertObjectPropsToCamelCase(item); + }); + const pagination = formatPagination(res.headers); + + this.totalCount = pagination?.total || 0; + this.pageInfo = pagination; + this.isLoading = false; }) - .catch(() => {}); + .catch(() => { + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }); }, handleSearchUpdate({ sort, filters }) { this.sorting = sort; + this.name = parseFilter(filters, 'name'); - const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM); - this.name = escape(search?.value?.data); - - this.fetchHarborImages(); + this.fetchHarborImages(1); }, fetchPrevPage() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 - this.fetchHarborImages(); + const prevPageNum = this.currentPage - 1; + this.fetchHarborImages(prevPageNum); }, fetchNextPage() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 - this.fetchHarborImages(); + const nextPageNum = this.currentPage + 1; + this.fetchHarborImages(nextPageNum); }, }, }; @@ -101,14 +162,14 @@ export default { <div> <gl-empty-state v-if="showConnectionError" - :title="$options.i18n.CONNECTION_ERROR_TITLE" - :svg-path="config.containersErrorImage" + :title="$options.i18n.connectionErrorTitle" + :svg-path="containersErrorImage" > <template #description> <p> - <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE"> + <gl-sprintf :message="$options.i18n.connectionErrorMessage"> <template #docLink="{ content }"> - <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> + <gl-link :href="$options.helpPagePath" target="_blank"> {{ content }} </gl-link> </template> @@ -117,17 +178,13 @@ export default { </template> </gl-empty-state> <template v-else> - <harbor-list-header - :metadata-loading="isLoading" - :images-count="totalCount" - :help-page-path="config.helpPagePath" - > + <harbor-list-header :metadata-loading="isLoading" :images-count="totalCount"> <template #commands> <cli-commands v-if="showCommands" - :docker-build-command="dockerBuildCommand" - :docker-push-command="dockerPushCommand" - :docker-login-command="dockerLoginCommand" + :docker-build-command="dockerCommand.build" + :docker-push-command="dockerCommand.push" + :docker-login-command="dockerCommand.login" /> </template> </harbor-list-header> @@ -152,26 +209,24 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <template v-if="images.length > 0 || name"> - <harbor-list - v-if="images.length" - :images="images" - :meta-data-loading="isLoading" - :page-info="pageInfo" - @prev-page="fetchPrevPage" - @next-page="fetchNextPage" - /> - <gl-empty-state - v-else - :svg-path="config.noContainersImage" - data-testid="emptySearch" - :title="$options.i18n.EMPTY_RESULT_TITLE" - > - <template #description> - {{ $options.i18n.EMPTY_RESULT_MESSAGE }} - </template> - </gl-empty-state> - </template> + <harbor-list + v-if="images.length" + :images="images" + :metadata-loading="isLoading" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + <gl-empty-state + v-else + :svg-path="noContainersImage" + data-testid="emptySearch" + :title="emptyStateTexts.title" + > + <template #description> + {{ emptyStateTexts.message }} + </template> + </gl-empty-state> </template> </template> </div> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js index 572dd382be3..5a792e30c62 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router'; import { HARBOR_REGISTRY_TITLE } from './constants/index'; import List from './pages/list.vue'; import Details from './pages/details.vue'; +import HarborTags from './pages/harbor_tags.vue'; Vue.use(VueRouter); @@ -22,10 +23,20 @@ export default function createRouter(base, breadCrumbState) { }, { name: 'details', - path: '/:id', + path: '/:project/:image', component: Details, meta: { nameGenerator: () => breadCrumbState.name, + hrefGenerator: () => breadCrumbState.href, + }, + }, + { + name: 'tags', + path: '/:project/:image/:digest', + component: HarborTags, + meta: { + nameGenerator: () => breadCrumbState.name, + hrefGenerator: () => breadCrumbState.href, }, }, ], diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js new file mode 100644 index 00000000000..13df303cffe --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js @@ -0,0 +1,84 @@ +import { isFinite } from 'lodash'; +import { + SORT_FIELD_MAPPING, + TOKEN_TYPE_TAG_NAME, +} from '~/packages_and_registries/harbor_registry/constants'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; + +export const extractSortingDetail = (parsedSorting = '') => { + const [orderBy, sortOrder] = parsedSorting.split('_'); + if (orderBy && sortOrder) { + return { + orderBy: SORT_FIELD_MAPPING[orderBy], + sort: sortOrder.toLowerCase(), + }; + } + + return { + orderBy: '', + sort: '', + }; +}; + +export const parseFilter = (filters = [], defaultPrefix = '') => { + /* eslint-disable @gitlab/require-i18n-strings */ + const prefixMap = { + [FILTERED_SEARCH_TERM]: `${defaultPrefix}=`, + [TOKEN_TYPE_TAG_NAME]: 'tags=', + }; + /* eslint-enable @gitlab/require-i18n-strings */ + const filterList = []; + filters.forEach((i) => { + if (i.value?.data) { + const filterVal = i.value?.data; + const prefix = prefixMap[i.type]; + const filterString = `${prefix}${filterVal}`; + + filterList.push(filterString); + } + }); + + return filterList.join(','); +}; + +export const getNameFromParams = (fullName) => { + const names = fullName.split('/'); + return { + projectName: names[0] || '', + imageName: names[1] || '', + }; +}; + +export const formatPagination = (headers) => { + const pagination = parseIntPagination(normalizeHeaders(headers)) || {}; + + if (pagination.nextPage || pagination.previousPage) { + pagination.hasNextPage = isFinite(pagination.nextPage); + pagination.hasPreviousPage = isFinite(pagination.previousPage); + } + + return pagination; +}; + +/* eslint-disable @gitlab/require-i18n-strings */ +export const dockerBuildCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => { + return `docker build -t ${repositoryUrl}/${harborProjectName}/${projectName} .`; +}; + +export const dockerPushCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => { + return `docker push ${repositoryUrl}/${harborProjectName}/${projectName}`; +}; + +export const dockerLoginCommand = (repositoryUrl) => { + return `docker login ${repositoryUrl}`; +}; + +export const artifactPullCommand = ({ repositoryUrl, harborProjectName, imageName, digest }) => { + return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}@${digest}`; +}; + +export const tagPullCommand = ({ repositoryUrl, harborProjectName, imageName, tag }) => { + return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}:${tag}`; +}; +/* eslint-enable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index 425fb4596fd..fd099ee4e69 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -114,7 +114,7 @@ export default { deleteModalContent: s__( `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, ), - deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`), + deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`), deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, ), diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue index 28bfb82093c..e45b88bc6d5 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue @@ -84,14 +84,14 @@ export default { }, }, i18n: { - deleteFile: __('Delete file'), + deleteFile: __('Delete asset'), }, }; </script> <template> <div> - <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3> <gl-table :fields="filesTableHeaderFields" :items="filesTableRows" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue index a465fea0b74..dab4a051d0c 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -98,7 +98,7 @@ export default { </div> <template v-else> - <div data-qa-selector="packages-table"> + <div data-testid="packages-table"> <packages-list-row v-for="packageEntity in list" :key="packageEntity.id" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue index 3c6b8344c34..cc52235eaf3 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue @@ -78,7 +78,7 @@ export default { </script> <template> - <list-item data-qa-selector="package_row" :disabled="disabledRow"> + <list-item data-testid="package-row" :disabled="disabledRow"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <gl-link diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue index 122d444e859..f581469b12b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue @@ -14,16 +14,17 @@ import NpmInstallation from './npm_installation.vue'; import NugetInstallation from './nuget_installation.vue'; import PypiInstallation from './pypi_installation.vue'; +const components = { + [PACKAGE_TYPE_CONAN]: ConanInstallation, + [PACKAGE_TYPE_MAVEN]: MavenInstallation, + [PACKAGE_TYPE_NPM]: NpmInstallation, + [PACKAGE_TYPE_NUGET]: NugetInstallation, + [PACKAGE_TYPE_PYPI]: PypiInstallation, + [PACKAGE_TYPE_COMPOSER]: ComposerInstallation, +}; + export default { name: 'InstallationCommands', - components: { - [PACKAGE_TYPE_CONAN]: ConanInstallation, - [PACKAGE_TYPE_MAVEN]: MavenInstallation, - [PACKAGE_TYPE_NPM]: NpmInstallation, - [PACKAGE_TYPE_NUGET]: NugetInstallation, - [PACKAGE_TYPE_PYPI]: PypiInstallation, - [PACKAGE_TYPE_COMPOSER]: ComposerInstallation, - }, props: { packageEntity: { type: Object, @@ -32,7 +33,7 @@ export default { }, computed: { installationComponent() { - return this.$options.components[this.packageEntity.packageType]; + return components[this.packageEntity.packageType]; }, }, }; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index b872294d2cf..8eb8654cddd 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -139,7 +139,7 @@ export default { }, }, i18n: { - deleteFile: __('Delete file'), + deleteFile: __('Delete asset'), deleteSelected: s__('PackageRegistry|Delete selected'), moreActionsText: __('More actions'), }, @@ -149,7 +149,7 @@ export default { <template> <div class="gl-pt-6"> <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> - <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3> <gl-button v-if="canDelete" :disabled="isLoading || !areFilesSelected" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 04faff1a75b..7a000aca0f2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <list-item data-qa-selector="package_row"> + <list-item data-testid="package-row"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <router-link diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index a6ac2eb1b2b..e84f181e9b2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -151,7 +151,7 @@ export default { @primaryAction="showConfirmationModal" >{{ $options.i18n.errorMessageBodyAlert }}</gl-alert > - <div data-qa-selector="packages-table"> + <div data-testid="packages-table"> <packages-list-row v-for="packageEntity in list" :key="packageEntity.id" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 5b2a347a4ee..06a04ee248a 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -79,10 +79,10 @@ export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history'; export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package file.', + 'PackageRegistry|Something went wrong while deleting the package asset.', ); export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( - 'PackageRegistry|Package file deleted successfully', + 'PackageRegistry|Package asset deleted successfully', ); export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package assets.', diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index e83962bb608..c10fc914d56 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -256,7 +256,7 @@ export default { deleteModalContent: s__( `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, ), - deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`), + deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`), deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, ), diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue deleted file mode 100644 index 51a97aead49..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue +++ /dev/null @@ -1,103 +0,0 @@ -<script> -import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { isEqual } from 'lodash'; - -import { - DUPLICATES_TOGGLE_LABEL, - DUPLICATES_SETTING_EXCEPTION_TITLE, - DUPLICATES_SETTINGS_EXCEPTION_LEGEND, -} from '~/packages_and_registries/settings/group/constants'; - -export default { - name: 'DuplicatesSettings', - i18n: { - DUPLICATES_TOGGLE_LABEL, - DUPLICATES_SETTING_EXCEPTION_TITLE, - DUPLICATES_SETTINGS_EXCEPTION_LEGEND, - }, - components: { - GlToggle, - GlFormGroup, - GlFormInput, - }, - props: { - loading: { - type: Boolean, - required: false, - default: false, - }, - duplicatesAllowed: { - type: Boolean, - default: false, - required: false, - }, - duplicateExceptionRegex: { - type: String, - default: '', - required: false, - }, - duplicateExceptionRegexError: { - type: String, - default: '', - required: false, - }, - modelNames: { - type: Object, - required: true, - validator(value) { - return isEqual(Object.keys(value), ['allowed', 'exception']); - }, - }, - toggleQaSelector: { - type: String, - required: false, - default: null, - }, - labelQaSelector: { - type: String, - required: false, - default: null, - }, - }, - computed: { - isExceptionRegexValid() { - return !this.duplicateExceptionRegexError; - }, - }, - methods: { - update(type, value) { - this.$emit('update', { [type]: value }); - }, - }, -}; -</script> - -<template> - <form> - <gl-toggle - :data-qa-selector="toggleQaSelector" - :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" - :value="!duplicatesAllowed" - :disabled="loading" - @change="update(modelNames.allowed, !$event)" - /> - <gl-form-group - v-if="!duplicatesAllowed" - class="gl-mt-4" - :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" - label-size="sm" - :state="isExceptionRegexValid" - :invalid-feedback="duplicateExceptionRegexError" - :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND" - label-for="maven-duplicated-settings-regex-input" - > - <gl-form-input - id="maven-duplicated-settings-regex-input" - :disabled="loading" - size="lg" - :value="duplicateExceptionRegex" - @change="update(modelNames.exception, $event)" - /> - </gl-form-group> - </form> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue new file mode 100644 index 00000000000..9ac1673dbf3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue @@ -0,0 +1,79 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; + +import { + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, +} from '~/packages_and_registries/settings/group/constants'; + +export default { + name: 'ExceptionsInput', + i18n: { + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, + }, + components: { + GlFormGroup, + GlFormInput, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + duplicatesAllowed: { + type: Boolean, + default: false, + required: false, + }, + duplicateExceptionRegex: { + type: String, + default: '', + required: false, + }, + duplicateExceptionRegexError: { + type: String, + default: '', + required: false, + }, + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + }, + computed: { + isExceptionRegexValid() { + return !this.duplicateExceptionRegexError; + }, + }, + methods: { + update(type, value) { + this.$emit('update', { [type]: value }); + }, + }, +}; +</script> + +<template> + <gl-form-group + class="gl-mb-0" + :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" + label-sr-only + :invalid-feedback="duplicateExceptionRegexError" + :label-for="id" + > + <gl-form-input + :id="id" + :disabled="duplicatesAllowed || loading" + size="lg" + :value="duplicateExceptionRegex" + :state="isExceptionRegexValid" + @change="update(name, $event)" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue deleted file mode 100644 index e5f63fe8d0d..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; - -export default { - name: 'GenericSettings', - components: { - SettingsTitles, - }, - i18n: { - title: s__('PackageRegistry|Generic'), - subTitle: s__('PackageRegistry|Settings for Generic packages'), - }, - modelNames: { - allowed: 'genericDuplicatesAllowed', - exception: 'genericDuplicateExceptionRegex', - }, -}; -</script> - -<template> - <div> - <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" /> - <slot :model-names="$options.modelNames"></slot> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue deleted file mode 100644 index a1cbd695f34..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; - -export default { - name: 'MavenSettings', - components: { - SettingsTitles, - }, - i18n: { - title: s__('PackageRegistry|Maven'), - subTitle: s__('PackageRegistry|Settings for Maven packages'), - }, - modelNames: { - allowed: 'mavenDuplicatesAllowed', - exception: 'mavenDuplicateExceptionRegex', - }, -}; -</script> - -<template> - <div> - <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" /> - <slot :model-names="$options.modelNames"></slot> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue index abb9f02d290..de087a8fcc5 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue @@ -1,27 +1,50 @@ <script> -import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; -import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; -import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import { GlTableLite, GlToggle } from '@gitlab/ui'; import { + GENERIC_PACKAGE_FORMAT, + MAVEN_PACKAGE_FORMAT, + PACKAGE_FORMATS_TABLE_HEADER, PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_TOGGLE_LABEL, } from '~/packages_and_registries/settings/group/constants'; import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; +import ExceptionsInput from '~/packages_and_registries/settings/group/components/exceptions_input.vue'; export default { name: 'PackageSettings', i18n: { PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_TOGGLE_LABEL, }, + tableHeaderFields: [ + { + key: 'packageFormat', + label: PACKAGE_FORMATS_TABLE_HEADER, + thClass: 'gl-bg-gray-10!', + }, + { + key: 'allowDuplicates', + label: DUPLICATES_TOGGLE_LABEL, + thClass: 'gl-bg-gray-10!', + }, + { + key: 'exceptions', + label: DUPLICATES_SETTING_EXCEPTION_TITLE, + thClass: 'gl-bg-gray-10!', + }, + ], components: { SettingsBlock, - MavenSettings, - GenericSettings, - DuplicatesSettings, + GlTableLite, + GlToggle, + ExceptionsInput, }, inject: ['groupPath'], props: { @@ -40,6 +63,37 @@ export default { errors: {}, }; }, + computed: { + tableRows() { + return [ + { + id: 'maven-duplicated-settings-regex-input', + format: MAVEN_PACKAGE_FORMAT, + duplicatesAllowed: this.packageSettings.mavenDuplicatesAllowed, + duplicateExceptionRegex: this.packageSettings.mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: this.errors.mavenDuplicateExceptionRegex, + modelNames: { + allowed: 'mavenDuplicatesAllowed', + exception: 'mavenDuplicateExceptionRegex', + }, + testid: 'maven-settings', + dataQaSelector: 'allow_duplicates_toggle', + }, + { + id: 'generic-duplicated-settings-regex-input', + format: GENERIC_PACKAGE_FORMAT, + duplicatesAllowed: this.packageSettings.genericDuplicatesAllowed, + duplicateExceptionRegex: this.packageSettings.genericDuplicateExceptionRegex, + duplicateExceptionRegexError: this.errors.genericDuplicateExceptionRegex, + modelNames: { + allowed: 'genericDuplicatesAllowed', + exception: 'genericDuplicateExceptionRegex', + }, + testid: 'generic-settings', + }, + ]; + }, + }, methods: { async updateSettings(payload) { this.errors = {}; @@ -79,6 +133,9 @@ export default { this.$emit('error'); } }, + update(type, value) { + this.updateSettings({ [type]: value }); + }, }, }; </script> @@ -92,32 +149,40 @@ export default { </span> </template> <template #default> - <maven-settings data-testid="maven-settings"> - <template #default="{ modelNames }"> - <duplicates-settings - :duplicates-allowed="packageSettings.mavenDuplicatesAllowed" - :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex" - :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" - :model-names="modelNames" - :loading="isLoading" - toggle-qa-selector="reject_duplicates_toggle" - label-qa-selector="reject_duplicates_label" - @update="updateSettings" - /> - </template> - </maven-settings> - <generic-settings class="gl-mt-6" data-testid="generic-settings"> - <template #default="{ modelNames }"> - <duplicates-settings - :duplicates-allowed="packageSettings.genericDuplicatesAllowed" - :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex" - :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex" - :model-names="modelNames" - :loading="isLoading" - @update="updateSettings" - /> - </template> - </generic-settings> + <form> + <gl-table-lite + :fields="$options.tableHeaderFields" + :items="tableRows" + stacked="sm" + :tbody-tr-attr="(item) => ({ 'data-testid': item.testid })" + > + <template #cell(packageFormat)="{ item }"> + <span class="gl-md-pt-3">{{ item.format }}</span> + </template> + <template #cell(allowDuplicates)="{ item }"> + <gl-toggle + :data-qa-selector="item.dataQaSelector" + :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" + :value="item.duplicatesAllowed" + :disabled="isLoading" + label-position="hidden" + class="gl-align-items-flex-end gl-sm-align-items-flex-start" + @change="update(item.modelNames.allowed, $event)" + /> + </template> + <template #cell(exceptions)="{ item }"> + <exceptions-input + :id="item.id" + :duplicates-allowed="item.duplicatesAllowed" + :duplicate-exception-regex="item.duplicateExceptionRegex" + :duplicate-exception-regex-error="item.duplicateExceptionRegexError" + :name="item.modelNames.exception" + :loading="isLoading" + @update="updateSettings" + /> + </template> + </gl-table-lite> + </form> </template> </settings-block> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue deleted file mode 100644 index 1e93875c1e3..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -export default { - name: 'SettingsTitle', - props: { - title: { - type: String, - required: true, - }, - subTitle: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> - -<template> - <div> - <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3"> - {{ title }} - </h5> - <p v-if="subTitle">{{ subTitle }}</p> - <slot></slot> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index 34764663892..2dd6d3f76f6 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -5,10 +5,11 @@ export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages') export const PACKAGE_SETTINGS_DESCRIPTION = s__( 'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.', ); +export const PACKAGE_FORMATS_TABLE_HEADER = s__('PackageRegistry|Package formats'); +export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven'); +export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic'); -export const DUPLICATES_TOGGLE_LABEL = s__( - 'PackageRegistry|Reject packages with the same name and version', -); +export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions'); export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Publish packages if their name or version matches this regex.', diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue new file mode 100644 index 00000000000..72e68aca070 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue @@ -0,0 +1,112 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { isEqual, get, isEmpty } from 'lodash'; +import { + CONTAINER_CLEANUP_POLICY_TITLE, + CONTAINER_CLEANUP_POLICY_DESCRIPTION, + FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, + UNAVAILABLE_ADMIN_FEATURE_TEXT, +} from '~/packages_and_registries/settings/project/constants'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; + +import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue'; + +export default { + components: { + GlAlert, + GlSprintf, + GlLink, + ContainerExpirationPolicyForm, + }, + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'], + i18n: { + CONTAINER_CLEANUP_POLICY_TITLE, + CONTAINER_CLEANUP_POLICY_DESCRIPTION, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + FETCH_SETTINGS_ERROR_MESSAGE, + }, + apollo: { + containerExpirationPolicy: { + query: expirationPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: (data) => data.project?.containerExpirationPolicy, + result({ data }) { + this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) }; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, + data() { + return { + fetchSettingsError: false, + containerExpirationPolicy: null, + workingCopy: {}, + }; + }, + computed: { + isEnabled() { + return this.containerExpirationPolicy || this.enableHistoricEntries; + }, + showDisabledFormMessage() { + return !this.isEnabled && !this.fetchSettingsError; + }, + unavailableFeatureMessage() { + return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; + }, + isEdited() { + if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) { + return false; + } + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, + }, +}; +</script> + +<template> + <div data-testid="container-expiration-policy-project-settings"> + <h4 data-testid="title">{{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</h4> + <p data-testid="description"> + <gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="helpPagePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <container-expiration-policy-form + v-if="isEnabled" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + /> + <template v-else> + <gl-alert + v-if="showDisabledFormMessage" + :dismissible="false" + :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE" + variant="tip" + > + {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }} + + <gl-sprintf :message="unavailableFeatureMessage"> + <template #link="{ content }"> + <gl-link :href="adminSettingsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> + </gl-alert> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index 1c44d2bc38b..b003b6aeb6b 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -1,9 +1,11 @@ <script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { isEqual, get, isEmpty } from 'lodash'; +import { GlAlert, GlSprintf, GlLink, GlCard, GlButton } from '@gitlab/ui'; import { CONTAINER_CLEANUP_POLICY_TITLE, CONTAINER_CLEANUP_POLICY_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_EDIT_RULES, + CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_SET_RULES, FETCH_SETTINGS_ERROR_MESSAGE, UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, @@ -13,20 +15,29 @@ import { import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; -import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue'; - export default { components: { SettingsBlock, GlAlert, GlSprintf, GlLink, - ContainerExpirationPolicyForm, + GlCard, + GlButton, }, - inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'], + inject: [ + 'projectPath', + 'isAdmin', + 'adminSettingsPath', + 'enableHistoricEntries', + 'helpPagePath', + 'cleanupSettingsPath', + ], i18n: { CONTAINER_CLEANUP_POLICY_TITLE, CONTAINER_CLEANUP_POLICY_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_EDIT_RULES, + CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_SET_RULES, UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, FETCH_SETTINGS_ERROR_MESSAGE, @@ -40,9 +51,6 @@ export default { }; }, update: (data) => data.project?.containerExpirationPolicy, - result({ data }) { - this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) }; - }, error(e) { this.fetchSettingsError = e; }, @@ -52,29 +60,25 @@ export default { return { fetchSettingsError: false, containerExpirationPolicy: null, - workingCopy: {}, }; }, computed: { - isDisabled() { - return !(this.containerExpirationPolicy || this.enableHistoricEntries); + isCleanupEnabled() { + return this.containerExpirationPolicy?.enabled ?? false; + }, + isEnabled() { + return this.containerExpirationPolicy || this.enableHistoricEntries; }, showDisabledFormMessage() { - return this.isDisabled && !this.fetchSettingsError; + return !this.isEnabled && !this.fetchSettingsError; }, unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - isEdited() { - if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) { - return false; - } - return !isEqual(this.containerExpirationPolicy, this.workingCopy); - }, - }, - methods: { - restoreOriginal() { - this.workingCopy = { ...this.containerExpirationPolicy }; + cleanupRulesButtonText() { + return this.isCleanupEnabled + ? this.$options.i18n.CONTAINER_CLEANUP_POLICY_EDIT_RULES + : this.$options.i18n.CONTAINER_CLEANUP_POLICY_SET_RULES; }, }, }; @@ -93,13 +97,19 @@ export default { </span> </template> <template #default> - <container-expiration-policy-form - v-if="!isDisabled" - v-model="workingCopy" - :is-loading="$apollo.queries.containerExpirationPolicy.loading" - :is-edited="isEdited" - @reset="restoreOriginal" - /> + <gl-card v-if="isEnabled"> + <p data-testid="description"> + {{ $options.i18n.CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION }} + </p> + <gl-button + data-testid="rules-button" + :href="cleanupSettingsPath" + category="secondary" + variant="confirm" + > + {{ cleanupRulesButtonText }} + </gl-button> + </gl-card> <template v-else> <gl-alert v-if="showDisabledFormMessage" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue index ae2d5f4fbc5..11d8732426d 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue @@ -1,8 +1,9 @@ <script> import { GlCard, GlButton, GlSprintf } from '@gitlab/ui'; +import { objectToQuery, visitUrl } from '~/lib/utils/url_utility'; import { UPDATE_SETTINGS_ERROR_MESSAGE, - UPDATE_SETTINGS_SUCCESS_MESSAGE, + SHOW_SETUP_SUCCESS_ALERT, SET_CLEANUP_POLICY_BUTTON, KEEP_HEADER_TEXT, KEEP_INFO_TEXT, @@ -37,7 +38,7 @@ export default { ExpirationRunText, }, mixins: [Tracking.mixin()], - inject: ['projectPath'], + inject: ['projectPath', 'projectSettingsPath'], props: { value: { type: Object, @@ -95,10 +96,10 @@ export default { return Object.values(this.localErrors).every((error) => error); }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.showLoadingIcon; + return !this.isEdited || !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading || this.mutationLoading; + return this.isLoading || this.mutationLoading; }, isFieldDisabled() { return this.showLoadingIcon || !this.value.enabled; @@ -119,12 +120,6 @@ export default { findDefaultOption(option) { return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key; }, - reset() { - this.track('reset_form'); - this.apiErrors = {}; - this.localErrors = {}; - this.$emit('reset'); - }, setApiErrors(response) { this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { curr.extensions.problems.forEach((item) => { @@ -168,7 +163,7 @@ export default { const customError = this.encapsulateError('nameRegex', errorMessage); throw customError; } else { - this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); + this.navigateToSettingsWithSuccessAlert(); } }) .catch((error) => { @@ -183,12 +178,17 @@ export default { this.$emit('input', { ...this.value, [model]: newValue }); this.apiErrors[model] = undefined; }, + navigateToSettingsWithSuccessAlert() { + const alertQuery = objectToQuery({ [SHOW_SETUP_SUCCESS_ALERT]: true }); + + visitUrl(`${this.projectSettingsPath}?${alertQuery}`); + }, }, }; </script> <template> - <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> + <form @submit.prevent="submit"> <expiration-toggle :value="prefilledForm.enabled" :disabled="showLoadingIcon" @@ -199,7 +199,7 @@ export default { <div class="gl-display-flex gl-mt-7"> <expiration-dropdown - v-model="prefilledForm.cadence" + :value="prefilledForm.cadence" :disabled="isFieldDisabled" :form-options="$options.formOptions.cadence" :label="$options.i18n.CADENCE_LABEL" @@ -231,7 +231,7 @@ export default { </gl-sprintf> </p> <expiration-dropdown - v-model="prefilledForm.keepN" + :value="prefilledForm.keepN" :disabled="isFieldDisabled" :form-options="$options.formOptions.keepN" :label="$options.i18n.KEEP_N_LABEL" @@ -270,7 +270,7 @@ export default { </gl-sprintf> </p> <expiration-dropdown - v-model="prefilledForm.olderThan" + :value="prefilledForm.olderThan" :disabled="isFieldDisabled" :form-options="$options.formOptions.olderThan" :label="$options.i18n.EXPIRATION_SCHEDULE_LABEL" @@ -306,7 +306,7 @@ export default { </gl-button> <gl-button data-testid="cancel-button" - type="reset" + :href="projectSettingsPath" :disabled="isCancelButtonDisabled" class="gl-mr-4" > diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 710cfe7b1eb..2c1368262f2 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -1,18 +1,57 @@ <script> +import { GlAlert } from '@gitlab/ui'; +import { historyReplaceState } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { + SHOW_SETUP_SUCCESS_ALERT, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '~/packages_and_registries/settings/project/constants'; import ContainerExpirationPolicy from './container_expiration_policy.vue'; import PackagesCleanupPolicy from './packages_cleanup_policy.vue'; export default { components: { ContainerExpirationPolicy, + GlAlert, PackagesCleanupPolicy, }, inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'], + i18n: { + UPDATE_SETTINGS_SUCCESS_MESSAGE, + }, + data() { + return { + showAlert: false, + }; + }, + mounted() { + this.checkAlert(); + }, + methods: { + checkAlert() { + const showAlert = getParameterByName(SHOW_SETUP_SUCCESS_ALERT); + + if (showAlert) { + this.showAlert = true; + const cleanUrl = window.location.href.split('?')[0]; + historyReplaceState(cleanUrl); + } + }, + }, }; </script> <template> <div> + <gl-alert + v-if="showAlert" + variant="success" + class="gl-mt-5" + dismissible + @dismiss="showAlert = false" + > + {{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }} + </gl-alert> <packages-cleanup-policy v-if="showPackageRegistrySettings" /> <container-expiration-policy v-if="showContainerRegistrySettings" /> </div> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index fcb4a8ee297..a9b47cbd343 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -4,6 +4,13 @@ export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up im export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__( `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`, ); +export const CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION = s__( + 'ContainerRegistry|Set rules to automatically remove unused packages to save storage space.', +); +export const CONTAINER_CLEANUP_POLICY_EDIT_RULES = s__('ContainerRegistry|Edit cleanup rules'); +export const CONTAINER_CLEANUP_POLICY_SET_RULES = s__('ContainerRegistry|Set cleanup rules'); +export const SHOW_SETUP_SUCCESS_ALERT = 'showSetupSuccessAlert'; + export const SET_CLEANUP_POLICY_BUTTON = __('Save changes'); export const UNAVAILABLE_FEATURE_TITLE = s__( `ContainerRegistry|Cleanup policy for tags is disabled`, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index daf1da6eac8..57c8d07e620 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js @@ -18,6 +18,7 @@ export default () => { enableHistoricEntries, projectPath, adminSettingsPath, + cleanupSettingsPath, tagsRegexHelpPagePath, helpPagePath, showContainerRegistrySettings, @@ -34,6 +35,7 @@ export default () => { enableHistoricEntries: parseBoolean(enableHistoricEntries), projectPath, adminSettingsPath, + cleanupSettingsPath, tagsRegexHelpPagePath, helpPagePath, showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings), diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js new file mode 100644 index 00000000000..b1401c448a1 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js @@ -0,0 +1,41 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import Translate from '~/vue_shared/translate'; +import CleanupImageTags from './components/cleanup_image_tags.vue'; +import { apolloProvider } from './graphql/index'; + +Vue.use(GlToast); +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-registry-settings-cleanup-image-tags'); + if (!el) { + return null; + } + const { + isAdmin, + enableHistoricEntries, + projectPath, + adminSettingsPath, + projectSettingsPath, + tagsRegexHelpPagePath, + helpPagePath, + } = el.dataset; + return new Vue({ + el, + apolloProvider, + provide: { + isAdmin: parseBoolean(isAdmin), + enableHistoricEntries: parseBoolean(enableHistoricEntries), + projectPath, + adminSettingsPath, + projectSettingsPath, + tagsRegexHelpPagePath, + helpPagePath, + }, + render(createElement) { + return createElement(CleanupImageTags, {}); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue index b2b1d2c8212..363304c20ce 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue @@ -18,6 +18,11 @@ export default { type: String, required: true, }, + tokens: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -68,7 +73,7 @@ export default { v-if="mountRegistrySearch" :filters="filters" :sorting="sorting" - :tokens="$options.tokens" + :tokens="tokens" :sortable-fields="sortableFields" @sorting:changed="updateSortingAndEmitUpdate" @filter:changed="updateFilters" diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue index 0458b914b58..7740924b058 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue @@ -1,7 +1,7 @@ <template> <section class="settings gl-py-7"> - <div class="gl-lg-display-flex gl-gap-6"> - <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0"> + <div class="row"> + <div class="col-lg-4"> <h4> <slot name="title"></slot> </h4> @@ -9,7 +9,7 @@ <slot name="description"></slot> </p> </div> - <div class="gl-pt-3 gl-flex-grow-1"> + <div class="col-lg-8 gl-pt-3"> <slot></slot> </div> </div> diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index 6744e821565..7fd440d0b27 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js @@ -33,10 +33,10 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package.', ); export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package file.', + 'PackageRegistry|Something went wrong while deleting the package asset.', ); export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( - 'PackageRegistry|Package file deleted successfully', + 'PackageRegistry|Package asset deleted successfully', ); export const PACKAGE_ERROR_STATUS = 'error'; diff --git a/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js b/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js new file mode 100644 index 00000000000..9b6fba9876e --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js @@ -0,0 +1,3 @@ +import initRunnerTokenExpirationIntervals from '~/admin/application_settings/runner_token_expiration/index'; + +initRunnerTokenExpirationIntervals(); diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index 84027203783..616005565c4 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -63,6 +63,8 @@ export default class PayloadPreviewer { insertPayload(data) { this.isInserted = true; + + // eslint-disable-next-line no-unsanitized/property this.getContainer().innerHTML = data; this.showPayload(); } diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index a4d89889d57..4cd312b403c 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; -import stopJobsModal from './components/stop_jobs_modal.vue'; +import StopJobsModal from './components/stop_jobs_modal.vue'; Vue.use(Translate); @@ -14,7 +14,7 @@ function initJobs() { new Vue({ el: `#js-${modalId}`, components: { - stopJobsModal, + StopJobsModal, }, mounted() { stopJobsButton.classList.remove('disabled'); diff --git a/app/assets/javascripts/pages/admin/runners/edit/index.js b/app/assets/javascripts/pages/admin/runners/edit/index.js index ddf135a2732..03d31f49a99 100644 --- a/app/assets/javascripts/pages/admin/runners/edit/index.js +++ b/app/assets/javascripts/pages/admin/runners/edit/index.js @@ -1,3 +1,3 @@ -import { initAdminRunnerEdit } from '~/runner/admin_runner_edit'; +import { initRunnerEdit } from '~/runner/runner_edit'; -initAdminRunnerEdit(); +initRunnerEdit('#js-admin-runner-edit'); diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js index f5e6d044865..b2cbd52fb27 100644 --- a/app/assets/javascripts/pages/admin/topics/edit/index.js +++ b/app/assets/javascripts/pages/admin/topics/edit/index.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; import initFilePickers from '~/file_pickers'; import ZenMode from '~/zen_mode'; -import initRemoveAvatar from '~/admin/topics'; +import { initRemoveAvatar } from '~/admin/topics'; new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new initFilePickers(); diff --git a/app/assets/javascripts/pages/admin/topics/index.js b/app/assets/javascripts/pages/admin/topics/index.js new file mode 100644 index 00000000000..ec0e11660d2 --- /dev/null +++ b/app/assets/javascripts/pages/admin/topics/index.js @@ -0,0 +1,3 @@ +import { initMergeTopics } from '~/admin/topics'; + +initMergeTopics(); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index e45a40bd44c..b6f42a27002 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -140,10 +140,10 @@ export default class Todos { restoreBtn.classList.add('hidden'); doneBtn.classList.remove('hidden'); } else if (target === doneBtn) { - row.classList.add('done-reversible'); + row.classList.add('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100'); restoreBtn.classList.remove('hidden'); } else if (target === restoreBtn) { - row.classList.remove('done-reversible'); + row.classList.remove('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100'); doneBtn.classList.remove('hidden'); } else { row.parentNode.removeChild(row); @@ -200,9 +200,11 @@ export default class Todos { }); document.dispatchEvent(event); + // eslint-disable-next-line no-unsanitized/property document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter( data.count, ); + // eslint-disable-next-line no-unsanitized/property document.querySelector('.js-todos-done .js-todos-badge').innerHTML = addDelimiter( data.done_count, ); diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js index 0417134f2a7..92490368b15 100644 --- a/app/assets/javascripts/pages/groups/details/index.js +++ b/app/assets/javascripts/pages/groups/details/index.js @@ -1,3 +1,5 @@ +import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; import initGroupDetails from '../shared/group_details'; initGroupDetails('details'); +initGroupOverviewTabs(); diff --git a/app/assets/javascripts/pages/groups/runners/edit/index.js b/app/assets/javascripts/pages/groups/runners/edit/index.js new file mode 100644 index 00000000000..febb0026b67 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/edit/index.js @@ -0,0 +1,3 @@ +import { initRunnerEdit } from '~/runner/runner_edit'; + +initRunnerEdit('#js-group-runner-edit'); diff --git a/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js new file mode 100644 index 00000000000..1943704ac3d --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js @@ -0,0 +1,6 @@ +// This "page" is only rendered as response to the create_deploy_token form. +// It shows the secret token to the user one time, but is otherwise identical +// with the Settings/Repository page. +// +// This is why we just import the other page's JavaScript here. +import '../show/index'; diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index e4a84dd5eec..161fca83a58 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,5 +1,7 @@ import leaveByUrl from '~/namespaces/leave_by_url'; +import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; import initGroupDetails from '../shared/group_details'; leaveByUrl('group'); initGroupDetails(); +initGroupOverviewTabs(); diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index 6b12604c76b..28b1aa02dfa 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -1,5 +1,6 @@ import initConfirmModal from '~/confirm_modal'; import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; +import { initExpiresAtField } from '~/access_tokens/index'; initConfirmModal(); @@ -23,3 +24,5 @@ function initSshKeyValidation() { } initSshKeyValidation(); + +initExpiresAtField(); diff --git a/app/assets/javascripts/pages/profiles/show/emoji_menu.js b/app/assets/javascripts/pages/profiles/show/emoji_menu.js deleted file mode 100644 index 286c1f1e929..00000000000 --- a/app/assets/javascripts/pages/profiles/show/emoji_menu.js +++ /dev/null @@ -1,19 +0,0 @@ -import '~/commons/bootstrap'; -import { AwardsHandler } from '~/awards_handler'; - -class EmojiMenu extends AwardsHandler { - constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback) { - super(emoji); - - this.selectEmojiCallback = selectEmojiCallback; - this.toggleButtonSelector = toggleButtonSelector; - this.menuClass = menuClass; - } - - postEmoji($emojiButton, awardUrl, selectedEmoji, callback) { - this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji)); - callback(); - } -} - -export default EmojiMenu; diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index 226ef4c4e23..96ea7329e6e 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,88 +1,18 @@ import emojiRegex from 'emoji-regex'; -import $ from 'jquery'; -import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import * as Emoji from '~/emoji'; -import createFlash from '~/flash'; import { __ } from '~/locale'; -import EmojiMenu from './emoji_menu'; +import { initSetStatusForm } from '~/profile/profile'; -const defaultStatusEmoji = 'speech_balloon'; -const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu'; -const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector); -const statusEmojiField = document.getElementById('js-status-emoji-field'); -const statusMessageField = document.getElementById('js-status-message-field'); - -const toggleNoEmojiPlaceholder = (isVisible) => { - const placeholderElement = document.getElementById('js-no-emoji-placeholder'); - placeholderElement.classList.toggle('hidden', !isVisible); -}; - -const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji'); -const removeStatusEmoji = () => { - const statusEmoji = findStatusEmoji(); - if (statusEmoji) { - statusEmoji.remove(); - } -}; - -const selectEmojiCallback = (emoji, emojiTag) => { - statusEmojiField.value = emoji; - toggleNoEmojiPlaceholder(false); - removeStatusEmoji(); - toggleEmojiMenuButton.innerHTML += emojiTag; -}; - -const clearEmojiButton = document.getElementById('js-clear-user-status-button'); -clearEmojiButton.addEventListener('click', () => { - statusEmojiField.value = ''; - statusMessageField.value = ''; - removeStatusEmoji(); - toggleNoEmojiPlaceholder(true); -}); - -const emojiAutocomplete = new GfmAutoComplete(); -emojiAutocomplete.setup($(statusMessageField), { emojis: true }); +initSetStatusForm(); const userNameInput = document.getElementById('user_name'); -userNameInput.addEventListener('input', () => { - const EMOJI_REGEX = emojiRegex(); - if (EMOJI_REGEX.test(userNameInput.value)) { - // set field to invalid so it gets detected by GlFieldErrors - userNameInput.setCustomValidity(__('Invalid field')); - } else { - userNameInput.setCustomValidity(''); - } -}); - -Emoji.initEmojiMap() - .then(() => { - const emojiMenu = new EmojiMenu( - Emoji, - toggleEmojiMenuButtonSelector, - 'js-status-emoji-menu', - selectEmojiCallback, - ); - emojiMenu.bindEvents(); - - const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji); - statusMessageField.addEventListener('input', () => { - const hasStatusMessage = statusMessageField.value.trim() !== ''; - const statusEmoji = findStatusEmoji(); - if (hasStatusMessage && statusEmoji) { - return; - } - - if (hasStatusMessage) { - toggleNoEmojiPlaceholder(false); - toggleEmojiMenuButton.innerHTML += defaultEmojiTag; - } else if (statusEmoji.dataset.name === defaultStatusEmoji) { - toggleNoEmojiPlaceholder(true); - removeStatusEmoji(); - } - }); - }) - .catch(() => - createFlash({ - message: __('Failed to load emoji list.'), - }), - ); +if (userNameInput) { + userNameInput.addEventListener('input', () => { + const EMOJI_REGEX = emojiRegex(); + if (EMOJI_REGEX.test(userNameInput.value)) { + // set field to invalid so it gets detected by GlFieldErrors + userNameInput.setCustomValidity(__('Invalid field')); + } else { + userNameInput.setCustomValidity(''); + } + }); +} diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js index 49fdf5bb6b5..96c4d0e0670 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -8,7 +8,10 @@ const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSk if (skippable) { const button = `<br/><a class="btn gl-button btn-sm btn-confirm gl-mt-3" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`; const flashAlert = document.querySelector('.flash-alert'); - if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button); + if (flashAlert) { + // eslint-disable-next-line no-unsanitized/method + flashAlert.insertAdjacentHTML('beforeend', button); + } } mount2faRegistration(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 740fdb8a96a..e45f9a10294 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -9,7 +9,7 @@ import GpgBadges from '~/gpg_badges'; import createDefaultClient from '~/lib/graphql'; import initBlob from '~/pages/projects/init_blob'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; -import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; import createStore from '~/code_navigation/store'; @@ -64,7 +64,7 @@ if (statusLink) { new Vue({ el: CommitPipelineStatusEl, components: { - commitPipelineStatus, + CommitPipelineStatus, }, render(createElement) { return createElement('commit-pipeline-status', { diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index f92a40e057f..b415e36bf09 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -3,15 +3,12 @@ import { GlIcon, GlLink, GlForm, - GlFormInputGroup, - GlInputGroupText, GlFormInput, GlFormGroup, GlFormTextarea, GlButton, GlFormRadio, GlFormRadioGroup, - GlFormSelect, } from '@gitlab/ui'; import { kebabCase } from 'lodash'; import { buildApiUrl } from '~/api/api_utils'; @@ -21,16 +18,13 @@ import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import validation from '~/vue_shared/directives/validation'; - -const PRIVATE_VISIBILITY = 'private'; -const INTERNAL_VISIBILITY = 'internal'; -const PUBLIC_VISIBILITY = 'public'; - -const VISIBILITY_LEVEL = { - [PRIVATE_VISIBILITY]: 0, - [INTERNAL_VISIBILITY]: 10, - [PUBLIC_VISIBILITY]: 20, -}; +import { + VISIBILITY_LEVEL_PRIVATE_STRING, + VISIBILITY_LEVEL_INTERNAL_STRING, + VISIBILITY_LEVEL_PUBLIC_STRING, + VISIBILITY_LEVELS_STRING_TO_INTEGER, +} from '~/visibility_level/constants'; +import ProjectNamespace from './project_namespace.vue'; const initFormField = ({ value, required = true, skipValidation = false }) => ({ value, @@ -39,28 +33,18 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({ feedback: null, }); -function sortNamespaces(namespaces) { - if (!namespaces || !namespaces?.length) { - return namespaces; - } - - return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name)); -} - export default { components: { GlForm, GlIcon, GlLink, GlButton, - GlFormInputGroup, - GlInputGroupText, GlFormInput, GlFormTextarea, GlFormGroup, GlFormRadio, GlFormRadioGroup, - GlFormSelect, + ProjectNamespace, }, directives: { validation: validation(), @@ -72,9 +56,6 @@ export default { visibilityHelpPath: { default: '', }, - endpoint: { - default: '', - }, projectFullPath: { default: '', }, @@ -96,6 +77,9 @@ export default { restrictedVisibilityLevels: { default: [], }, + namespaceId: { + default: '', + }, }, data() { const form = { @@ -117,20 +101,17 @@ export default { }; return { isSaving: false, - namespaces: [], form, }; }, computed: { - projectUrl() { - return `${gon.gitlab_url}/`; - }, projectVisibilityLevel() { - return VISIBILITY_LEVEL[this.projectVisibility]; + return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; }, namespaceVisibilityLevel() { - const visibility = this.form.fields.namespace.value?.visibility || PUBLIC_VISIBILITY; - return VISIBILITY_LEVEL[visibility]; + const visibility = + this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; + return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; }, visibilityLevelCap() { return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel); @@ -139,7 +120,7 @@ export default { return new Set(this.restrictedVisibilityLevels); }, allowedVisibilityLevels() { - const allowedLevels = Object.entries(VISIBILITY_LEVEL).reduce( + const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( (levels, [levelName, levelValue]) => { if ( !this.restrictedVisibilityLevelsSet.has(levelValue) && @@ -153,7 +134,7 @@ export default { ); if (!allowedLevels.length) { - return [PRIVATE_VISIBILITY]; + return [VISIBILITY_LEVEL_PRIVATE_STRING]; } return allowedLevels; @@ -162,58 +143,56 @@ export default { return [ { text: s__('ForkProject|Private'), - value: PRIVATE_VISIBILITY, + value: VISIBILITY_LEVEL_PRIVATE_STRING, icon: 'lock', help: s__( 'ForkProject|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.', ), - disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY), + disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_PRIVATE_STRING), }, { text: s__('ForkProject|Internal'), - value: INTERNAL_VISIBILITY, + value: VISIBILITY_LEVEL_INTERNAL_STRING, icon: 'shield', help: s__('ForkProject|The project can be accessed by any logged in user.'), - disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY), + disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_INTERNAL_STRING), }, { text: s__('ForkProject|Public'), - value: PUBLIC_VISIBILITY, + value: VISIBILITY_LEVEL_PUBLIC_STRING, icon: 'earth', help: s__('ForkProject|The project can be accessed without any authentication.'), - disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY), + disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_PUBLIC_STRING), }, ]; }, }, watch: { // eslint-disable-next-line func-names - 'form.fields.namespace.value': function () { - this.form.fields.visibility.value = - this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY; - }, - // eslint-disable-next-line func-names 'form.fields.name.value': function (newVal) { this.form.fields.slug.value = kebabCase(newVal); }, }, - mounted() { - this.fetchNamespaces(); - }, methods: { - async fetchNamespaces() { - const { data } = await axios.get(this.endpoint); - this.namespaces = sortNamespaces(data.namespaces); - }, isVisibilityLevelDisabled(visibility) { return !this.allowedVisibilityLevels.includes(visibility); }, getInitialVisibilityValue() { return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility; }, + setNamespace(namespace) { + this.form.fields.visibility.value = + this.restrictedVisibilityLevels.length !== 0 ? null : VISIBILITY_LEVEL_PRIVATE_STRING; + this.form.fields.namespace.value = namespace; + this.form.fields.namespace.state = true; + }, async onSubmit() { this.form.showValidation = true; + if (!this.form.fields.namespace.value) { + this.form.fields.namespace.state = false; + } + if (!this.form.state) { return; } @@ -282,30 +261,7 @@ export default { :state="form.fields.namespace.state" :invalid-feedback="s__('ForkProject|Please select a namespace')" > - <gl-form-input-group> - <template #prepend> - <gl-input-group-text> - {{ projectUrl }} - </gl-input-group-text> - </template> - <gl-form-select - id="fork-url" - v-model="form.fields.namespace.value" - v-validation:[form.showValidation] - name="namespace" - data-testid="fork-url-input" - data-qa-selector="fork_namespace_dropdown" - :state="form.fields.namespace.state" - required - > - <template #first> - <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option> - </template> - <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace"> - {{ namespace.full_name }} - </option> - </gl-form-select> - </gl-form-input-group> + <project-namespace @select="setNamespace" /> </gl-form-group> </div> <div class="gl-flex-basis-half"> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue new file mode 100644 index 00000000000..2b3055ecd66 --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue @@ -0,0 +1,136 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownSectionHeader, + GlSearchBoxByType, + GlTruncate, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchForkableNamespaces from '../queries/search_forkable_namespaces.query.graphql'; + +export default { + components: { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownSectionHeader, + GlSearchBoxByType, + GlTruncate, + }, + apollo: { + project: { + query: searchForkableNamespaces, + variables() { + return { + projectPath: this.projectFullPath, + search: this.search, + }; + }, + skip() { + const { length } = this.search; + return length > 0 && length < MINIMUM_SEARCH_LENGTH; + }, + error(error) { + createFlash({ + message: s__( + 'ForkProject|Something went wrong while loading data. Please refresh the page to try again.', + ), + captureError: true, + error, + }); + }, + debounce: DEBOUNCE_DELAY, + }, + }, + inject: ['projectFullPath'], + data() { + return { + project: {}, + search: '', + selectedNamespace: null, + }; + }, + computed: { + rootUrl() { + return `${gon.gitlab_url}/`; + }, + namespaces() { + return this.project.forkTargets?.nodes || []; + }, + hasMatches() { + return this.namespaces.length; + }, + dropdownText() { + return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace'); + }, + }, + methods: { + handleDropdownShown() { + this.$refs.search.focusInput(); + }, + setNamespace(namespace) { + const id = getIdFromGraphQLId(namespace.id); + + this.$emit('select', { + id, + name: namespace.name, + visibility: namespace.visibility, + }); + + this.selectedNamespace = { id, fullPath: namespace.fullPath }; + }, + }, +}; +</script> + +<template> + <gl-button-group class="gl-w-full"> + <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{ + rootUrl + }}</gl-button> + + <gl-dropdown + class="gl-flex-grow-1" + toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" + data-qa-selector="select_namespace_dropdown" + data-testid="select_namespace_dropdown" + no-flip + @shown="handleDropdownShown" + > + <template #button-text> + <gl-truncate :text="dropdownText" position="start" with-tooltip /> + </template> + <gl-search-box-by-type + ref="search" + v-model.trim="search" + :is-loading="$apollo.queries.project.loading" + data-qa-selector="select_namespace_dropdown_search_field" + data-testid="select_namespace_dropdown_search_field" + /> + <template v-if="!$apollo.queries.project.loading"> + <template v-if="hasMatches"> + <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="namespace of namespaces" + :key="namespace.id" + data-qa-selector="select_namespace_dropdown_item" + @click="setNamespace(namespace)" + > + {{ namespace.fullPath }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text> + </template> + </gl-dropdown> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index cbf74f755e7..d3a5ce5390f 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import App from './components/app.vue'; const mountElement = document.getElementById('fork-groups-mount-element'); @@ -17,9 +19,14 @@ const { restrictedVisibilityLevels, } = mountElement.dataset; +Vue.use(VueApollo); + // eslint-disable-next-line no-new new Vue({ el: mountElement, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { newGroupPath, visibilityHelpPath, diff --git a/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql new file mode 100644 index 00000000000..089b57815bd --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql @@ -0,0 +1,13 @@ +query searchForkableNamespaces($projectPath: ID!, $search: String) { + project(fullPath: $projectPath) { + id + forkTargets(search: $search) { + nodes { + id + fullPath + name + visibility + } + } + } +} diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js deleted file mode 100644 index 5482324f1cd..00000000000 --- a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import init from '~/google_cloud/databases/index'; - -init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js new file mode 100644 index 00000000000..e1dc0116707 --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/databases/init_index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js new file mode 100644 index 00000000000..698e788789b --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/databases/init_new'; + +init(); diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index d7e68484143..08d24344ffc 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -180,7 +180,7 @@ export default { v-for="({ group_name }, index) in dailyCoverageData" :key="index" :value="group_name" - :is-check-item="true" + is-check-item :is-checked="index === selectedCoverageIndex" @click="setSelectedCoverage(index)" > diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index ec21d8c84e0..5179d1b31ab 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,5 +1,74 @@ +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; + +import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; + import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; import initCheckFormState from './check_form_state'; +import initFormUpdate from './update_form'; + +function initTargetBranchSelector() { + const targetBranch = document.querySelector('.js-target-branch'); + const { selected, fieldName, refsUrl } = targetBranch?.dataset ?? {}; + const formField = document.querySelector(`input[name="${fieldName}"]`); + + if (targetBranch && refsUrl && formField) { + /* eslint-disable-next-line no-new */ + new GitLabDropdown(targetBranch, { + selectable: true, + filterable: true, + filterRemote: Boolean(refsUrl), + filterInput: 'input[type="search"]', + data(term, callback) { + const params = { + search: term, + }; + + axios + .get(refsUrl, { + params, + }) + .then(({ data }) => { + callback(data); + }) + .catch(() => + createFlash({ + message: __('Error fetching branches'), + }), + ); + }, + renderRow(branch) { + const item = document.createElement('li'); + const link = document.createElement('a'); + + link.setAttribute('href', '#'); + link.dataset.branch = branch; + link.classList.toggle('is-active', branch === selected); + link.textContent = branch; + + item.appendChild(link); + + return item; + }, + id(obj, $el) { + return $el.data('id'); + }, + toggleLabel(obj, $el) { + return $el.text().trim(); + }, + clicked({ $el, e }) { + e.preventDefault(); + + const branchName = $el[0].dataset.branch; + + formField.setAttribute('value', branchName); + }, + }); + } +} initMergeRequest(); +initFormUpdate(); initCheckFormState(); +initTargetBranchSelector(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js b/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js new file mode 100644 index 00000000000..3bb64f741e7 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js @@ -0,0 +1,23 @@ +const findForm = () => document.querySelector('.merge-request-form'); + +const removeHiddenCheckbox = (node) => { + const checkboxWrapper = node.closest('.form-check'); + const hiddenCheckbox = checkboxWrapper.querySelector('input[type="hidden"]'); + hiddenCheckbox.remove(); +}; + +export default () => { + const updateCheckboxes = () => { + const checkboxes = document.querySelectorAll('.js-form-update'); + + if (!checkboxes.length) return; + + checkboxes.forEach((checkbox) => { + if (checkbox.checked) { + removeHiddenCheckbox(checkbox); + } + }); + }; + + findForm().addEventListener('submit', () => updateCheckboxes()); +}; diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 2db804e1ad8..30734f0b698 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { s__ } from '~/locale'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; import { initIssuableSidebar } from '~/issuable'; @@ -10,6 +11,7 @@ import ZenMode from '~/zen_mode'; import initAwardsApp from '~/emoji/awards_app'; import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; import { initMrExperienceSurvey } from '~/surveys/merge_request_experience'; +import toast from '~/vue_shared/plugins/global_toast'; import getStateQuery from './queries/get_state.query.graphql'; export default function initMergeRequestShow() { @@ -65,4 +67,10 @@ export default function initMergeRequestShow() { }); }, }); + + const copyReferenceButton = document.querySelector('.js-copy-reference'); + + copyReferenceButton?.addEventListener('click', () => { + toast(s__('MergeRequests|Reference copied')); + }); } diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 7f49eb60c5c..cc5c393ff8c 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,9 +1,14 @@ +import Vue from 'vue'; +import StickyHeader from '~/merge_requests/components/sticky_header.vue'; import { initReviewBar } from '~/batch_comments'; import { initIssuableHeaderWarnings } from '~/issuable'; import initMrNotes from '~/mr_notes'; import store from '~/mr_notes/stores'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; +import { parseBoolean } from '~/lib/utils/common_utils'; import initShow from '../init_merge_request_show'; +import getStateQuery from '../queries/get_state.query.graphql'; initMrNotes(); initShow(); @@ -12,4 +17,29 @@ requestIdleCallback(() => { initSidebarBundle(store); initReviewBar(); initIssuableHeaderWarnings(store); + + const el = document.getElementById('js-merge-sticky-header'); + + if (el) { + const { data } = el.dataset; + const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data); + + // eslint-disable-next-line no-new + new Vue({ + el, + store, + apolloProvider, + provide: { + query: getStateQuery, + iid, + projectPath, + title, + tabs, + isFluidLayout: parseBoolean(isFluidLayout), + }, + render(h) { + return h(StickyHeader); + }, + }); + } }); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index 37e8a316ee4..b3ad50f395b 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -29,7 +29,7 @@ export default { </script> <template> <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout"> - <div class="bordered-box landing content-block" data-testid="innerContent"> + <div class="bordered-box landing content-block gl-p-5!" data-testid="innerContent"> <gl-button category="tertiary" icon="close" diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 5dae812bbcb..eae721771de 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -6,7 +6,7 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; import GlFieldErrors from '~/gl_field_errors'; import Translate from '~/vue_shared/translate'; -import intervalPatternInput from './components/interval_pattern_input.vue'; +import IntervalPatternInput from './components/interval_pattern_input.vue'; import TimezoneDropdown from './components/timezone_dropdown'; Vue.use(Translate); @@ -19,7 +19,7 @@ function initIntervalPatternInput() { return new Vue({ el: intervalPatternMount, components: { - intervalPatternInput, + IntervalPatternInput, }, render(createElement) { return createElement('interval-pattern-input', { diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 032e2410233..ccabaad5b2e 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -141,7 +141,7 @@ export default class Project { if (doesPathContainRef) { // We are ignoring the url containing the ref portion // and plucking the thereafter portion to reconstructure the url that is correct - const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0]; + const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0]; selectedUrl.searchParams.set('path', targetPath); selectedUrl.hash = window.location.hash; } diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js new file mode 100644 index 00000000000..739e666644c --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js @@ -0,0 +1,10 @@ +import groupsSelect from '~/groups_select'; +import UserCallout from '~/user_callout'; +import UsersSelect from '~/users_select'; + +// eslint-disable-next-line no-new +new UsersSelect(); +groupsSelect(); + +// eslint-disable-next-line no-new +new UserCallout({ className: 'js-mr-approval-callout' }); diff --git a/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js b/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js new file mode 100644 index 00000000000..acd5d3febff --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js @@ -0,0 +1,5 @@ +import registrySettingsCleanupTagsApp from '~/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle'; +import initSettingsPanels from '~/settings_panels'; + +registrySettingsCleanupTagsApp(); +initSettingsPanels(); diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js index 1dc238b56b4..6a7c6028c95 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js @@ -1,3 +1 @@ -import initForm from '../form'; - -initForm(); +import '../show/index'; diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index c7c331c7de5..a82f485bf44 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -5,7 +5,11 @@ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/s import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, s__ } from '~/locale'; import { - visibilityOptions, + VISIBILITY_LEVEL_PRIVATE_INTEGER, + VISIBILITY_LEVEL_INTERNAL_INTEGER, + VISIBILITY_LEVEL_PUBLIC_INTEGER, +} from '~/visibility_level/constants'; +import { visibilityLevelDescriptions, featureAccessLevelMembers, featureAccessLevelEveryone, @@ -14,8 +18,8 @@ import { featureAccessLevelDescriptions, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; -import projectFeatureSetting from './project_feature_setting.vue'; -import projectSettingRow from './project_setting_row.vue'; +import ProjectFeatureSetting from './project_feature_setting.vue'; +import ProjectSettingRow from './project_setting_row.vue'; const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')]; @@ -33,6 +37,11 @@ export default { environmentsHelpText: s__( 'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.', ), + featureFlagsLabel: s__('ProjectSettings|Feature flags'), + featureFlagsHelpText: s__( + 'ProjectSettings|Roll out new features without redeploying with feature flags.', + ), + monitorLabel: s__('ProjectSettings|Monitor'), packagesHelpText: s__( 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', ), @@ -45,6 +54,10 @@ export default { ciCdLabel: __('CI/CD'), repositoryLabel: s__('ProjectSettings|Repository'), requirementsLabel: s__('ProjectSettings|Requirements'), + releasesLabel: s__('ProjectSettings|Releases'), + releasesHelpText: s__( + 'ProjectSettings|Combine git tags with release notes, release evidence, and assets to create a release.', + ), securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'), snippetsLabel: s__('ProjectSettings|Snippets'), wikiLabel: s__('ProjectSettings|Wiki'), @@ -54,10 +67,13 @@ export default { ), confirmButtonText: __('Save changes'), }, + VISIBILITY_LEVEL_PRIVATE_INTEGER, + VISIBILITY_LEVEL_INTERNAL_INTEGER, + VISIBILITY_LEVEL_PUBLIC_INTEGER, components: { - projectFeatureSetting, - projectSettingRow, + ProjectFeatureSetting, + ProjectSettingRow, GlButton, GlIcon, GlSprintf, @@ -65,7 +81,7 @@ export default { GlFormCheckbox, GlToggle, ConfirmDanger, - otherProjectSettings: () => + OtherProjectSettings: () => import( 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue' ), @@ -96,9 +112,9 @@ export default { type: Array, required: false, default: () => [ - visibilityOptions.PRIVATE, - visibilityOptions.INTERNAL, - visibilityOptions.PUBLIC, + VISIBILITY_LEVEL_PRIVATE_INTEGER, + VISIBILITY_LEVEL_INTERNAL_INTEGER, + VISIBILITY_LEVEL_PUBLIC_INTEGER, ], }, lfsAvailable: { @@ -131,6 +147,21 @@ export default { required: false, default: '', }, + environmentsHelpPath: { + type: String, + required: false, + default: '', + }, + featureFlagsHelpPath: { + type: String, + required: false, + default: '', + }, + releasesHelpPath: { + type: String, + required: false, + default: '', + }, lfsHelpPath: { type: String, required: false, @@ -197,8 +228,7 @@ export default { }, data() { const defaults = { - visibilityOptions, - visibilityLevel: visibilityOptions.PUBLIC, + visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER, issuesAccessLevel: featureAccessLevel.EVERYONE, repositoryAccessLevel: featureAccessLevel.EVERYONE, forkingAccessLevel: featureAccessLevel.EVERYONE, @@ -214,6 +244,9 @@ export default { securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, operationsAccessLevel: featureAccessLevel.EVERYONE, environmentsAccessLevel: featureAccessLevel.EVERYONE, + featureFlagsAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + releasesAccessLevel: featureAccessLevel.EVERYONE, + monitorAccessLevel: featureAccessLevel.EVERYONE, containerRegistryAccessLevel: featureAccessLevel.EVERYONE, warnAboutPotentiallyUnwantedCharacters: true, lfsEnabled: true, @@ -234,7 +267,7 @@ export default { computed: { featureAccessLevelOptions() { const options = [featureAccessLevelMembers]; - if (this.visibilityLevel !== visibilityOptions.PRIVATE) { + if (this.visibilityLevel !== VISIBILITY_LEVEL_PRIVATE_INTEGER) { options.push(featureAccessLevelEveryone); } return options; @@ -246,18 +279,12 @@ export default { ); }, - operationsFeatureAccessLevelOptions() { - return this.featureAccessLevelOptions.filter( - ([value]) => value <= this.operationsAccessLevel, - ); - }, - packageRegistryFeatureAccessLevelOptions() { const options = [FEATURE_ACCESS_LEVEL_ANONYMOUS]; - if (this.visibilityLevel === visibilityOptions.PRIVATE) { + if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) { options.unshift(featureAccessLevelMembers); - } else if (this.visibilityLevel === visibilityOptions.INTERNAL) { + } else if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) { options.unshift(featureAccessLevelEveryone); } @@ -268,15 +295,15 @@ export default { const options = [featureAccessLevelMembers]; if (this.pagesAccessControlForced) { - if (this.visibilityLevel === visibilityOptions.INTERNAL) { + if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) { options.push(featureAccessLevelEveryone); } } else { - if (this.visibilityLevel !== visibilityOptions.PRIVATE) { + if (this.visibilityLevel !== VISIBILITY_LEVEL_PRIVATE_INTEGER) { options.push(featureAccessLevelEveryone); } - if (this.visibilityLevel !== visibilityOptions.PUBLIC) { + if (this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER) { options.push(FEATURE_ACCESS_LEVEL_ANONYMOUS); } } @@ -290,6 +317,11 @@ export default { environmentsEnabled() { return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED; }, + + monitorEnabled() { + return this.monitorAccessLevel > featureAccessLevel.NOT_ENABLED; + }, + repositoryEnabled() { return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -300,13 +332,13 @@ export default { showContainerRegistryPublicNote() { return ( - this.visibilityLevel === visibilityOptions.PUBLIC && + this.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_INTEGER && this.containerRegistryAccessLevel === featureAccessLevel.EVERYONE ); }, repositoryHelpText() { - if (this.visibilityLevel === visibilityOptions.PRIVATE) { + if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) { return s__('ProjectSettings|View and edit files in this project.'); } @@ -315,7 +347,7 @@ export default { ); }, cveIdRequestIsDisabled() { - return this.visibilityLevel !== visibilityOptions.PUBLIC; + return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER; }, isVisibilityReduced() { return ( @@ -329,11 +361,19 @@ export default { splitOperationsEnabled() { return this.glFeatures.splitOperationsVisibilityPermissions; }, + monitorOperationsFeatureAccessLevelOptions() { + if (this.splitOperationsEnabled) { + return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel); + } + return this.featureAccessLevelOptions.filter( + ([value]) => value <= this.operationsAccessLevel, + ); + }, }, watch: { visibilityLevel(value, oldValue) { - if (value === visibilityOptions.PRIVATE) { + if (value === VISIBILITY_LEVEL_PRIVATE_INTEGER) { // when private, features are restricted to "only team members" this.issuesAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, @@ -355,7 +395,7 @@ export default { if ( this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE || (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE && - oldValue === visibilityOptions.PUBLIC) + oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER) ) { this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS; } @@ -389,6 +429,18 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.environmentsAccessLevel, ); + this.featureFlagsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.featureFlagsAccessLevel, + ); + this.releasesAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.releasesAccessLevel, + ); + this.monitorAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.monitorAccessLevel, + ); this.containerRegistryAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.containerRegistryAccessLevel, @@ -398,7 +450,7 @@ export default { this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS; } this.highlightChanges(); - } else if (oldValue === visibilityOptions.PRIVATE) { + } else if (oldValue === VISIBILITY_LEVEL_PRIVATE_INTEGER) { // if changing away from private, make enabled features more permissive if (this.issuesAccessLevel > featureAccessLevel.NOT_ENABLED) this.issuesAccessLevel = featureAccessLevel.EVERYONE; @@ -432,19 +484,21 @@ export default { this.operationsAccessLevel = featureAccessLevel.EVERYONE; if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.environmentsAccessLevel = featureAccessLevel.EVERYONE; + if (this.monitorAccessLevel === featureAccessLevel.PROJECT_MEMBERS) + this.monitorAccessLevel = featureAccessLevel.EVERYONE; if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE; this.highlightChanges(); } else if (this.packageRegistryAccessLevelEnabled) { if ( - value === visibilityOptions.PUBLIC && + value === VISIBILITY_LEVEL_PUBLIC_INTEGER && this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE ) { // eslint-disable-next-line prefer-destructuring this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; } else if ( - value === visibilityOptions.INTERNAL && + value === VISIBILITY_LEVEL_INTERNAL_INTEGER && this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0] ) { this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE; @@ -467,6 +521,16 @@ export default { }, operationsAccessLevel(value, oldValue) { + this.updateSubFeatureAccessLevel(value, oldValue); + }, + + monitorAccessLevel(value, oldValue) { + this.updateSubFeatureAccessLevel(value, oldValue); + }, + }, + + methods: { + updateSubFeatureAccessLevel(value, oldValue) { if (value < oldValue) { // sub-features cannot have more permissive access level this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value); @@ -474,9 +538,7 @@ export default { this.metricsDashboardAccessLevel = value; } }, - }, - methods: { highlightChanges() { this.highlightChangesClass = true; this.$nextTick(() => { @@ -514,20 +576,20 @@ export default { data-qa-selector="project_visibility_dropdown" > <option - :value="visibilityOptions.PRIVATE" - :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + :value="$options.VISIBILITY_LEVEL_PRIVATE_INTEGER" + :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_PRIVATE_INTEGER)" > {{ s__('ProjectSettings|Private') }} </option> <option - :value="visibilityOptions.INTERNAL" - :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" + :value="$options.VISIBILITY_LEVEL_INTERNAL_INTEGER" + :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_INTERNAL_INTEGER)" > {{ s__('ProjectSettings|Internal') }} </option> <option - :value="visibilityOptions.PUBLIC" - :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" + :value="$options.VISIBILITY_LEVEL_PUBLIC_INTEGER" + :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_PUBLIC_INTEGER)" > {{ s__('ProjectSettings|Public') }} </option> @@ -558,7 +620,7 @@ export default { <div class="gl-mt-4"> <strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong> <label - v-if="visibilityLevel !== visibilityOptions.PRIVATE" + v-if="visibilityLevel !== $options.VISIBILITY_LEVEL_PRIVATE_INTEGER" class="gl-line-height-28 gl-font-weight-normal gl-mb-0" > <input @@ -570,7 +632,7 @@ export default { {{ s__('ProjectSettings|Users can request access') }} </label> <label - v-if="visibilityLevel !== visibilityOptions.PUBLIC" + v-if="visibilityLevel !== $options.VISIBILITY_LEVEL_PUBLIC_INTEGER" class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0" > <input @@ -847,6 +909,22 @@ export default { /> </project-setting-row> <project-setting-row + v-if="splitOperationsEnabled" + ref="monitor-settings" + :label="$options.i18n.monitorLabel" + :help-text=" + s__('ProjectSettings|Configure your project resources and monitor their health.') + " + > + <project-feature-setting + v-model="monitorAccessLevel" + :label="$options.i18n.monitorLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][monitor_access_level]" + /> + </project-setting-row> + <project-setting-row + v-else ref="operations-settings" :label="$options.i18n.operationsLabel" :help-text=" @@ -869,7 +947,7 @@ export default { <project-feature-setting v-model="metricsDashboardAccessLevel" :show-toggle="false" - :options="operationsFeatureAccessLevelOptions" + :options="monitorOperationsFeatureAccessLevelOptions" name="project[project_feature_attributes][metrics_dashboard_access_level]" /> </project-setting-row> @@ -879,6 +957,7 @@ export default { ref="environments-settings" :label="$options.i18n.environmentsLabel" :help-text="$options.i18n.environmentsHelpText" + :help-path="environmentsHelpPath" > <project-feature-setting v-model="environmentsAccessLevel" @@ -887,6 +966,32 @@ export default { name="project[project_feature_attributes][environments_access_level]" /> </project-setting-row> + <project-setting-row + ref="feature-flags-settings" + :label="$options.i18n.featureFlagsLabel" + :help-text="$options.i18n.featureFlagsHelpText" + :help-path="featureFlagsHelpPath" + > + <project-feature-setting + v-model="featureFlagsAccessLevel" + :label="$options.i18n.featureFlagsLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][feature_flags_access_level]" + /> + </project-setting-row> + <project-setting-row + ref="releases-settings" + :label="$options.i18n.releasesLabel" + :help-text="$options.i18n.releasesHelpText" + :help-path="releasesHelpPath" + > + <project-feature-setting + v-model="releasesAccessLevel" + :label="$options.i18n.releasesLabel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][releases_access_level]" + /> + </project-setting-row> </template> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index cfca9d400e3..4c687859344 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -1,17 +1,16 @@ import { s__, __ } from '~/locale'; - -export const visibilityOptions = { - PRIVATE: 0, - INTERNAL: 10, - PUBLIC: 20, -}; +import { + VISIBILITY_LEVEL_PRIVATE_INTEGER, + VISIBILITY_LEVEL_INTERNAL_INTEGER, + VISIBILITY_LEVEL_PUBLIC_INTEGER, +} from '~/visibility_level/constants'; export const visibilityLevelDescriptions = { - [visibilityOptions.PRIVATE]: __( + [VISIBILITY_LEVEL_PRIVATE_INTEGER]: __( `Only accessible by %{membersPageLinkStart}project members%{membersPageLinkEnd}. Membership must be explicitly granted to each user.`, ), - [visibilityOptions.INTERNAL]: __('Accessible by any user who is logged in.'), - [visibilityOptions.PUBLIC]: __('Accessible by anyone, regardless of authentication.'), + [VISIBILITY_LEVEL_INTERNAL_INTEGER]: __('Accessible by any user who is logged in.'), + [VISIBILITY_LEVEL_PUBLIC_INTEGER]: __('Accessible by anyone, regardless of authentication.'), }; export const featureAccessLevel = { diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index e92f386a29e..10b95fd6f3c 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -87,7 +87,7 @@ export default { v-else-if="!loadingContentFailed && !isLoadingContent" ref="content" data-qa-selector="wiki_page_content" - data-testid="wiki_page_content" + data-testid="wiki-page-content" class="js-wiki-page-content md" v-html="content /* eslint-disable-line vue/no-v-html */" ></div> diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 9d7d9e376cf..9acc1cb62a1 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -5,7 +5,6 @@ import { GlLink, GlButton, GlSprintf, - GlAlert, GlFormGroup, GlFormInput, GlFormSelect, @@ -59,14 +58,6 @@ export default { label: s__('WikiPage|Content'), placeholder: s__('WikiPage|Write your content or drag files here…'), }, - contentEditor: { - renderFailed: { - message: s__( - 'WikiPage|An error occurred while trying to render the content editor. Please try again later.', - ), - primaryAction: s__('WikiPage|Retry'), - }, - }, linksHelpText: s__( 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.', ), @@ -88,7 +79,6 @@ export default { { text: s__('Wiki Page|Rich text'), value: 'richText' }, ], components: { - GlAlert, GlIcon, GlForm, GlFormGroup, @@ -115,14 +105,12 @@ export default { content: this.pageInfo.content || '', commitMessage: '', isDirty: false, - contentEditorRenderFailed: false, contentEditorEmpty: false, switchEditingControlDisabled: false, }; }, computed: { noContent() { - if (this.isContentEditorActive) return this.contentEditorEmpty; return !this.content.trim(); }, csrfToken() { @@ -145,11 +133,6 @@ export default { linkExample() { return MARKDOWN_LINK_TEXT[this.format]; }, - toggleEditingModeButtonText() { - return this.isContentEditorActive - ? this.$options.i18n.editSourceButtonText - : this.$options.i18n.editRichTextButtonText; - }, submitButtonText() { return this.pageInfo.persisted ? this.$options.i18n.submitButton.existingPage @@ -177,7 +160,7 @@ export default { return !this.isContentEditorActive; }, disableSubmitButton() { - return this.noContent || !this.title || this.contentEditorRenderFailed; + return this.noContent || !this.title; }, isContentEditorActive() { return this.isMarkdownFormat && this.useContentEditor; @@ -201,23 +184,14 @@ export default { .then(({ data }) => data.body); }, - toggleEditingMode(editingMode) { + setEditingMode(editingMode) { this.editingMode = editingMode; - if (!this.useContentEditor && this.contentEditor) { - this.content = this.contentEditor.getSerializedContent(); - } - }, - - setEditingMode(value) { - this.editingMode = value; }, async handleFormSubmit(e) { e.preventDefault(); if (this.useContentEditor) { - this.content = this.contentEditor.getSerializedContent(); - this.trackFormSubmit(); } @@ -235,30 +209,10 @@ export default { this.isDirty = true; }, - async loadInitialContent(contentEditor) { - this.contentEditor = contentEditor; - - try { - await this.contentEditor.setSerializedContent(this.content); - this.trackContentEditorLoaded(); - } catch (e) { - this.contentEditorRenderFailed = true; - } - }, - - async retryInitContentEditor() { - try { - this.contentEditorRenderFailed = false; - await this.contentEditor.setSerializedContent(this.content); - } catch (e) { - this.contentEditorRenderFailed = true; - } - }, - - handleContentEditorChange({ empty }) { + handleContentEditorChange({ empty, markdown, changed }) { this.contentEditorEmpty = empty; - // TODO: Implement a precise mechanism to detect changes in the Content - this.isDirty = true; + this.isDirty = changed; + this.content = markdown; }, onPageUnload(event) { @@ -320,17 +274,6 @@ export default { class="wiki-form common-note-form gl-mt-3 js-quick-submit" @submit="handleFormSubmit" > - <gl-alert - v-if="isContentEditorActive && contentEditorRenderFailed" - class="gl-mb-6" - :dismissible="false" - variant="danger" - :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction" - @primaryAction="retryInitContentEditor" - > - {{ $options.i18n.contentEditor.renderFailed.message }} - </gl-alert> - <input :value="csrfToken" type="hidden" name="authenticity_token" /> <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" /> <input @@ -350,7 +293,6 @@ export default { {{ $options.i18n.title.helpText.learnMore }} </gl-link> </template> - <gl-form-input id="wiki_title" v-model="title" @@ -395,7 +337,7 @@ export default { :checked="editingMode" :options="$options.switchEditingControlOptions" :disabled="switchEditingControlDisabled" - @input="toggleEditingMode" + @input="setEditingMode" /> </div> <local-storage-sync @@ -436,13 +378,20 @@ export default { <content-editor :render-markdown="renderMarkdown" :uploads-path="pageInfo.uploadsPath" - @initialized="loadInitialContent" + :markdown="content" + @initialized="trackContentEditorLoaded" @change="handleContentEditorChange" @loading="disableSwitchEditingControl" @loadingSuccess="enableSwitchEditingControl" @loadingError="enableSwitchEditingControl" /> - <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> + <input + id="wiki_content" + v-model.trim="content" + type="hidden" + name="wiki[content]" + data-qa-selector="wiki_hidden_content" + /> </div> <div class="clearfix"></div> diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 94506d33b33..9e0af426f6e 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -1,8 +1,8 @@ import { select } from 'd3-selection'; -import dateFormat from 'dateformat'; import $ from 'jquery'; import { last } from 'lodash'; import createFlash from '~/flash'; +import dateFormat from '~/lib/dateformat'; import axios from '~/lib/utils/axios_utils'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime/date_format_utility'; diff --git a/app/assets/javascripts/pages/users/user_overview_block.js b/app/assets/javascripts/pages/users/user_overview_block.js index a7c3c9d104d..8d2d66d812e 100644 --- a/app/assets/javascripts/pages/users/user_overview_block.js +++ b/app/assets/javascripts/pages/users/user_overview_block.js @@ -33,6 +33,7 @@ export default class UserOverviewBlock { const containerEl = document.querySelector(this.container); const contentList = containerEl.querySelector('.overview-content-list'); + // eslint-disable-next-line no-unsanitized/property contentList.innerHTML += html; const loadingEl = containerEl.querySelector('.loading'); diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index 644eccc0232..ddc880db227 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -2,10 +2,10 @@ import pdfjsLib from 'pdfjs-dist/build/pdf'; import workerSrc from 'pdfjs-dist/build/pdf.worker.min'; -import page from './page/index.vue'; +import Page from './page/index.vue'; export default { - components: { page }, + components: { Page }, props: { pdf: { type: [String, Uint8Array], diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index 9cea89f4990..7e331bdd91d 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -7,12 +7,13 @@ const DEFERRED_LINK_CLASS = 'deferred-link'; export default class PersistentUserCallout { constructor(container, options = container.dataset) { - const { dismissEndpoint, featureId, groupId, namespaceId, deferLinks } = options; + const { dismissEndpoint, featureId, groupId, namespaceId, projectId, deferLinks } = options; this.container = container; this.dismissEndpoint = dismissEndpoint; this.featureId = featureId; this.groupId = groupId; this.namespaceId = namespaceId; + this.projectId = projectId; this.deferLinks = parseBoolean(deferLinks); this.closeButtons = this.container.querySelectorAll('.js-close'); @@ -58,6 +59,7 @@ export default class PersistentUserCallout { feature_name: this.featureId, group_id: this.groupId, namespace_id: this.namespaceId, + project_id: this.projectId, }) .then(() => { this.container.remove(); diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index ead512e3574..2580cbcb944 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -17,6 +17,9 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-submit-license-usage-data-banner', '.js-project-usage-limitations-callout', '.js-namespace-storage-alert', + '.js-web-hook-disabled-callout', + '.js-merge-request-settings-callout', + '.js-ultimate-feature-removal-banner', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 4398ba67d47..1f8ddae3696 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -237,7 +237,7 @@ export default { v-for="branch in availableBranches" :key="branch" :is-checked="currentBranch === branch" - :is-check-item="true" + is-check-item data-qa-selector="branch_menu_item_button" @click="selectBranch(branch)" > diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index 7beabcfe403..feadc60a22a 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '../../constants'; @@ -10,8 +10,6 @@ export default { }, components: { PipelineMiniGraph, - LinkedPipelinesMiniList: () => - import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, inject: ['projectFullPath'], props: { @@ -47,9 +45,6 @@ export default { downstreamPipelines() { return this.linkedPipelines?.downstream?.nodes || []; }, - hasDownstreamPipelines() { - return this.downstreamPipelines.length > 0; - }, hasPipelineStages() { return this.pipelineStages.length > 0; }, @@ -87,23 +82,11 @@ export default { </script> <template> - <div + <pipeline-mini-graph v-if="hasPipelineStages" - class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell gl-mr-5" - > - <linked-pipelines-mini-list - v-if="upstreamPipeline" - :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - upstreamPipeline, - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - data-testid="pipeline-editor-mini-graph-upstream" - /> - <pipeline-mini-graph :stages="pipelineStages" /> - <linked-pipelines-mini-list - v-if="hasDownstreamPipelines" - :triggered="downstreamPipelines" - :pipeline-path="pipelinePath" - data-testid="pipeline-editor-mini-graph-downstream" - /> - </div> + :downstream-pipelines="downstreamPipelines" + :pipeline-path="pipelinePath" + :stages="pipelineStages" + :upstream-pipeline="upstreamPipeline" + /> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 4b9c98135ec..137dfca68d6 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -174,7 +174,7 @@ export default { <div class="gl-display-flex gl-flex-wrap"> <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" /> <gl-button - class="gl-mt-2 gl-md-mt-0" + class="gl-ml-3" category="secondary" variant="confirm" :href="status.detailsPath" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 3fd31edec2c..548769eb214 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -47,6 +47,7 @@ export default { currentCiFileContent: '', failureType: null, failureReasons: [], + hasBranchLoaded: false, initialCiFileContent: '', isFetchingCommitSha: false, isLintUnavailable: false, @@ -234,7 +235,7 @@ export default { return this.lastCommittedContent !== this.currentCiFileContent; }, isBlobContentLoading() { - return this.$apollo.queries.initialCiFileContent.loading; + return !this.hasBranchLoaded || this.$apollo.queries.initialCiFileContent.loading; }, isCiConfigDataLoading() { return this.$apollo.queries.ciConfigData.loading; @@ -243,7 +244,7 @@ export default { return this.currentCiFileContent === ''; }, shouldSkipBlobContentQuery() { - return this.isNewCiConfigFile || this.lastCommittedContent || !this.currentBranch; + return this.isNewCiConfigFile || this.lastCommittedContent || !this.hasBranchLoaded; }, shouldSkipCiConfigQuery() { return !this.currentCiFileContent || !this.commitSha; @@ -264,6 +265,17 @@ export default { }, }, watch: { + currentBranch: { + immediate: true, + handler(branch) { + // currentBranch is a client query so it starts off undefined. In the index.js, + // write to the apollo cache. Once that operation is done, we can safely do operations + // that require the branch to have loaded. + if (branch) { + this.hasBranchLoaded = true; + } + }, + }, isEmpty(flag) { if (flag) { this.setAppStatus(EDITOR_APP_STATUS_EMPTY); diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue new file mode 100644 index 00000000000..529ec4897b4 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue @@ -0,0 +1,490 @@ +<script> +import { + GlAlert, + GlIcon, + GlButton, + GlDropdown, + GlDropdownItem, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + GlLoadingIcon, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { uniqueId } from 'lodash'; +import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import { backOff } from '~/lib/utils/common_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { s__, __, n__ } from '~/locale'; +import { + VARIABLE_TYPE, + FILE_TYPE, + CONFIG_VARIABLES_TIMEOUT, + CC_VALIDATION_REQUIRED_ERROR, +} from '../constants'; +import filterVariables from '../utils/filter_variables'; +import RefsDropdown from './refs_dropdown.vue'; + +const i18n = { + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + defaultError: __('Something went wrong on our end. Please try again.'), + refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), + submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'), + warningTitle: __('The form contains the following warning:'), + maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), + removeVariableLabel: s__('CiVariables|Remove variable'), +}; + +export default { + typeOptions: { + [VARIABLE_TYPE]: __('Variable'), + [FILE_TYPE]: __('File'), + }, + i18n, + formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', + // this height value is used inline on the textarea to match the input field height + // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used + textAreaStyle: { height: '32px' }, + components: { + GlAlert, + GlIcon, + GlButton, + GlDropdown, + GlDropdownItem, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + GlLoadingIcon, + RefsDropdown, + CcValidationRequiredAlert: () => + import('ee_component/billings/components/cc_validation_required_alert.vue'), + }, + directives: { SafeHtml }, + props: { + pipelinesPath: { + type: String, + required: true, + }, + configVariablesPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + settingsLink: { + type: String, + required: true, + }, + fileParams: { + type: Object, + required: false, + default: () => ({}), + }, + refParam: { + type: String, + required: false, + default: '', + }, + variableParams: { + type: Object, + required: false, + default: () => ({}), + }, + maxWarnings: { + type: Number, + required: true, + }, + }, + data() { + return { + refValue: { + shortName: this.refParam, + }, + form: {}, + errorTitle: null, + error: null, + warnings: [], + totalWarnings: 0, + isWarningDismissed: false, + isLoading: false, + submitted: false, + ccAlertDismissed: false, + }; + }, + computed: { + overMaxWarningsLimit() { + return this.totalWarnings > this.maxWarnings; + }, + warningsSummary() { + return n__('%d warning found:', '%d warnings found:', this.warnings.length); + }, + summaryMessage() { + return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary; + }, + shouldShowWarning() { + return this.warnings.length > 0 && !this.isWarningDismissed; + }, + refShortName() { + return this.refValue.shortName; + }, + refFullName() { + return this.refValue.fullName; + }, + variables() { + return this.form[this.refFullName]?.variables ?? []; + }, + descriptions() { + return this.form[this.refFullName]?.descriptions ?? {}; + }, + ccRequiredError() { + return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; + }, + }, + watch: { + refValue() { + this.loadConfigVariablesForm(); + }, + }, + created() { + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + if (this.refValue.shortName === this.defaultBranch) { + this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; + } + + this.loadConfigVariablesForm(); + }, + methods: { + addEmptyVariable(refValue) { + const { variables } = this.form[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + setVariable(refValue, type, key, value) { + const { variables } = this.form[refValue]; + + const variable = variables.find((v) => v.key === key); + if (variable) { + variable.type = type; + variable.value = value; + } else { + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + key, + value, + variable_type: type, + }); + } + }, + setVariableType(key, type) { + const { variables } = this.form[this.refFullName]; + const variable = variables.find((v) => v.key === key); + variable.variable_type = type; + }, + setVariableParams(refValue, type, paramsObj) { + Object.entries(paramsObj).forEach(([key, value]) => { + this.setVariable(refValue, type, key, value); + }); + }, + removeVariable(index) { + this.variables.splice(index, 1); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + loadConfigVariablesForm() { + // Skip when variables already cached in `form` + if (this.form[this.refFullName]) { + return; + } + + this.fetchConfigVariables(this.refFullName || this.refShortName) + .then(({ descriptions, params }) => { + Vue.set(this.form, this.refFullName, { + variables: [], + descriptions, + }); + + // Add default variables from yml + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); + }) + .catch(() => { + Vue.set(this.form, this.refFullName, { + variables: [], + descriptions: {}, + }); + }) + .finally(() => { + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }); + }, + fetchConfigVariables(refValue) { + this.isLoading = true; + + return backOff((next, stop) => { + axios + .get(this.configVariablesPath, { + params: { + sha: refValue, + }, + }) + .then(({ data, status }) => { + if (status === httpStatusCodes.NO_CONTENT) { + next(); + } else { + this.isLoading = false; + stop(data); + } + }) + .catch((error) => { + stop(error); + }); + }, CONFIG_VARIABLES_TIMEOUT) + .then((data) => { + const params = {}; + const descriptions = {}; + + Object.entries(data).forEach(([key, { value, description }]) => { + if (description) { + params[key] = value; + descriptions[key] = description; + } + }); + + return { params, descriptions }; + }) + .catch((error) => { + this.isLoading = false; + + Sentry.captureException(error); + + return { params: {}, descriptions: {} }; + }); + }, + createPipeline() { + this.submitted = true; + this.ccAlertDismissed = false; + + return axios + .post(this.pipelinesPath, { + // send shortName as fall back for query params + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + ref: this.refValue.fullName || this.refShortName, + variables_attributes: filterVariables(this.variables), + }) + .then(({ data }) => { + redirectTo(`${this.pipelinesPath}/${data.id}`); + }) + .catch((err) => { + // always re-enable submit button + this.submitted = false; + + const { + errors = [], + warnings = [], + total_warnings: totalWarnings = 0, + } = err.response.data; + const [error] = errors; + + this.reportError({ + title: i18n.submitErrorTitle, + error, + warnings, + totalWarnings, + }); + }); + }, + onRefsLoadingError(error) { + this.reportError({ title: i18n.refsLoadingErrorTitle }); + + Sentry.captureException(error); + }, + reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) { + this.errorTitle = title; + this.error = error; + this.warnings = warnings; + this.totalWarnings = totalWarnings; + }, + dismissError() { + this.ccAlertDismissed = true; + this.error = null; + }, + }, +}; +</script> + +<template> + <gl-form @submit.prevent="createPipeline"> + <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" /> + <gl-alert + v-else-if="error" + :title="errorTitle" + :dismissible="false" + variant="danger" + class="gl-mb-4" + data-testid="run-pipeline-error-alert" + > + <span v-safe-html="error"></span> + </gl-alert> + <gl-alert + v-if="shouldShowWarning" + :title="$options.i18n.warningTitle" + variant="warning" + class="gl-mb-4" + data-testid="run-pipeline-warning-alert" + @dismiss="isWarningDismissed = true" + > + <details> + <summary> + <gl-sprintf :message="summaryMessage"> + <template #total> + {{ totalWarnings }} + </template> + <template #warningsDisplayed> + {{ maxWarnings }} + </template> + </gl-sprintf> + </summary> + <p + v-for="(warning, index) in warnings" + :key="`warning-${index}`" + data-testid="run-pipeline-warning" + > + {{ warning }} + </p> + </details> + </gl-alert> + <gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> + <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" /> + </gl-form-group> + + <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> + + <gl-form-group v-else :label="s__('Pipeline|Variables')"> + <div + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-mb-3 gl-ml-n3 gl-pb-2" + data-testid="ci-variable-row" + data-qa-selector="ci_variable_row_container" + > + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-dropdown + :text="$options.typeOptions[variable.variable_type]" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-type" + > + <gl-dropdown-item + v-for="type in Object.keys($options.typeOptions)" + :key="type" + @click="setVariableType(variable.key, type)" + > + {{ $options.typeOptions[type] }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" + @change="addEmptyVariable(refFullName)" + /> + <gl-form-textarea + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3" + :style="$options.textAreaStyle" + :no-resize="false" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + :aria-label="$options.i18n.removeVariableLabel" + @click="removeVariable(index)" + > + <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" /> + <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span> + </gl-button> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> + </div> + + <template #description + ><gl-sprintf :message="$options.i18n.variablesDescription"> + <template #link="{ content }"> + <gl-link :href="settingsLink">{{ content }}</gl-link> + </template> + </gl-sprintf></template + > + </gl-form-group> + <div class="gl-pt-5 gl-display-flex"> + <gl-button + type="submit" + category="primary" + variant="confirm" + class="js-no-auto-disable gl-mr-3" + data-qa-selector="run_pipeline_button" + data-testid="run_pipeline_button" + :disabled="submitted" + >{{ s__('Pipeline|Run pipeline') }}</gl-button + > + <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 9378b67b915..529ec4897b4 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -282,7 +282,7 @@ export default { const descriptions = {}; Object.entries(data).forEach(([key, { value, description }]) => { - if (description !== null) { + if (description) { params[key] = value; descriptions[key] = description; } diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index 927eeb5e144..e3f363f4ada 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,27 +1,72 @@ import Vue from 'vue'; +import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; -export default () => { - const el = document.getElementById('js-new-pipeline'); +const mountLegacyPipelineNewForm = (el) => { const { // provide/inject projectRefsEndpoint, // props - projectId, - pipelinesPath, configVariablesPath, defaultBranch, + fileParam, + maxWarnings, + pipelinesPath, + projectId, refParam, + settingsLink, varParam, + } = el.dataset; + + const variableParams = JSON.parse(varParam); + const fileParams = JSON.parse(fileParam); + + return new Vue({ + el, + provide: { + projectRefsEndpoint, + }, + render(createElement) { + return createElement(LegacyPipelineNewForm, { + props: { + configVariablesPath, + defaultBranch, + fileParams, + maxWarnings: Number(maxWarnings), + pipelinesPath, + projectId, + refParam, + settingsLink, + variableParams, + }, + }); + }, + }); +}; + +const mountPipelineNewForm = (el) => { + const { + // provide/inject + projectRefsEndpoint, + + // props + configVariablesPath, + defaultBranch, fileParam, - settingsLink, maxWarnings, + pipelinesPath, + projectId, + refParam, + settingsLink, + varParam, } = el.dataset; const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); + // TODO: add apolloProvider + return new Vue({ el, provide: { @@ -30,17 +75,27 @@ export default () => { render(createElement) { return createElement(PipelineNewForm, { props: { - projectId, - pipelinesPath, configVariablesPath, defaultBranch, - refParam, - variableParams, fileParams, - settingsLink, maxWarnings: Number(maxWarnings), + pipelinesPath, + projectId, + refParam, + settingsLink, + variableParams, }, }); }, }); }; + +export default () => { + const el = document.getElementById('js-new-pipeline'); + + if (gon.features?.runPipelineGraphql) { + mountPipelineNewForm(el); + } else { + mountLegacyPipelineNewForm(el); + } +}; diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue index 41611233f71..0c063241173 100644 --- a/app/assets/javascripts/pipeline_wizard/components/editor.vue +++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue @@ -27,7 +27,7 @@ export default { data() { return { editor: null, - isUpdating: false, + isFocused: false, yamlEditorExtension: null, }; }, @@ -60,19 +60,23 @@ export default { this.editor.onDidChangeModelContent( debounce(() => this.handleChange(), CONTENT_UPDATE_DEBOUNCE), ); + this.editor.onDidFocusEditorText(() => { + this.isFocused = true; + }); + this.editor.onDidBlurEditorText(() => { + this.isFocused = false; + }); this.updateEditorContent(); this.emitValue(); }, methods: { async updateEditorContent() { - this.isUpdating = true; this.editor.setDoc(this.doc); - this.isUpdating = false; this.requestHighlight(this.highlight); }, handleChange() { this.emitValue(); - if (!this.isUpdating) { + if (this.isFocused) { this.handleTouch(); } }, diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue index 0fe87bcee7b..adeb4ae598b 100644 --- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -5,6 +5,7 @@ import { uniqueId } from 'lodash'; import { merge } from '~/lib/utils/yaml'; import { __ } from '~/locale'; import { isValidStepSeq } from '~/pipeline_wizard/validators'; +import Tracking from '~/tracking'; import YamlEditor from './editor.vue'; import WizardStep from './step.vue'; import CommitStep from './commit.vue'; @@ -16,6 +17,8 @@ export const i18n = { YAML-file for you to add to your repository`), }; +const trackingMixin = Tracking.mixin(); + export default { name: 'PipelineWizardWrapper', i18n, @@ -25,6 +28,7 @@ export default { WizardStep, CommitStep, }, + mixins: [trackingMixin], props: { steps: { type: Object, @@ -43,6 +47,11 @@ export default { type: String, required: true, }, + templateId: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -77,6 +86,11 @@ export default { template: this.steps.get(i).get('template', true), })); }, + tracking() { + return { + category: `pipeline_wizard:${this.templateId}`, + }; + }, }, watch: { isLastStep(value) { @@ -84,9 +98,6 @@ export default { }, }, methods: { - getStep(index) { - return this.steps.get(index); - }, resetHighlight() { this.highlightPath = null; }, @@ -106,6 +117,43 @@ export default { }); return doc; }, + onBack() { + this.currentStepIndex -= 1; + this.track('click_button', { + property: 'back', + label: 'pipeline_wizard_navigation', + extra: { + fromStep: this.currentStepIndex + 1, + toStep: this.currentStepIndex, + }, + }); + }, + onNext() { + this.currentStepIndex += 1; + this.track('click_button', { + property: 'next', + label: 'pipeline_wizard_navigation', + extra: { + fromStep: this.currentStepIndex - 1, + toStep: this.currentStepIndex, + }, + }); + }, + onDone() { + this.$emit('done'); + this.track('click_button', { + label: 'pipeline_wizard_commit', + property: 'commit', + }); + }, + onEditorTouched() { + this.track('edit', { + label: 'pipeline_wizard_editor_interaction', + extra: { + currentStep: this.currentStepIndex, + }, + }); + }, }, }; </script> @@ -127,8 +175,8 @@ export default { :file-content="pipelineBlob" :filename="filename" :project-path="projectPath" - @back="currentStepIndex--" - @done="$emit('done')" + @back="onBack" + @done="onDone" /> <wizard-step v-for="(step, i) in stepList" @@ -141,8 +189,8 @@ export default { :highlight.sync="highlightPath" :inputs="step.inputs" :template="step.template" - @back="currentStepIndex--" - @next="currentStepIndex++" + @back="onBack" + @next="onNext" @update:compiled="onUpdate" /> </section> @@ -162,6 +210,7 @@ export default { :highlight="highlightPath" class="gl-w-full" @update:yaml="onEditorUpdate" + @touch.once="onEditorTouched" /> <div v-if="showPlaceholder" diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue index 79b1507ad0e..5a93de3b1be 100644 --- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue +++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue @@ -42,6 +42,9 @@ export default { steps() { return this.parsedTemplate?.get('steps'); }, + templateId() { + return this.parsedTemplate?.get('id'); + }, }, }; </script> @@ -60,6 +63,7 @@ export default { :filename="filename" :project-path="projectPath" :steps="steps" + :template-id="templateId" @done="$emit('done')" /> </div> diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml index cd2242b1ba7..9d7936f2f5a 100644 --- a/app/assets/javascripts/pipeline_wizard/templates/pages.yml +++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml @@ -1,3 +1,4 @@ +id: gitlab/pages title: Get started with Pages description: "GitLab Pages lets you deploy static websites in minutes. All you need is a .gitlab-ci.yml file. Follow the below steps to diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 31a34ab4fb5..1a05710a13e 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -170,7 +170,7 @@ export default { ref="mainPipelineContainer" class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" :class="{ - 'gl-pipeline-min-h gl-py-5 gl-overflow-auto gl-border-t-solid gl-border-t-1 gl-border-gray-100': !isLinkedPipeline, + 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline, }" > <linked-graph-wrapper> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 14872c34afb..f822e2c0874 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -281,7 +281,6 @@ export default { :type="graphViewType" :show-links="showLinks" :tip-previously-dismissed="hoverTipPreviouslyDismissed" - :is-pipeline-complete="pipeline.complete" @dismissHoverTip="handleTipDismissal" @updateViewType="updateViewType" @updateShowLinksState="updateShowLinksState" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index a8c5d85f4ed..6d8c35f4482 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,33 +1,19 @@ <script> -import { - GlAlert, - GlButton, - GlButtonGroup, - GlLoadingIcon, - GlToggle, - GlModalDirective, -} from '@gitlab/ui'; +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import Tracking from '~/tracking'; -import PerformanceInsightsModal from '../performance_insights_modal.vue'; -import { performanceModalId } from '../../constants'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; export default { name: 'GraphViewSelector', - performanceModalId, + components: { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle, - PerformanceInsightsModal, - }, - directives: { - GlModal: GlModalDirective, }, - mixins: [Tracking.mixin()], + props: { showLinks: { type: Boolean, @@ -41,10 +27,6 @@ export default { type: String, required: true, }, - isPipelineComplete: { - type: Boolean, - required: true, - }, }, data() { return { @@ -59,7 +41,6 @@ export default { hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'), linksLabelText: s__('GraphViewType|Show dependencies'), viewLabelText: __('Group jobs by'), - performanceBtnText: __('Performance insights'), }, views: { [STAGE_VIEW]: { @@ -150,9 +131,6 @@ export default { this.$emit('updateShowLinksState', val); }); }, - trackInsightsClick() { - this.track('click_insights_button', { label: 'performance_insights' }); - }, }, }; </script> @@ -178,15 +156,6 @@ export default { </gl-button> </gl-button-group> - <gl-button - v-if="isPipelineComplete" - v-gl-modal="$options.performanceModalId" - data-testid="pipeline-insights-btn" - @click="trackInsightsClick" - > - {{ $options.i18n.performanceBtnText }} - </gl-button> - <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> <gl-toggle v-model="showLinksActive" @@ -202,7 +171,5 @@ export default { <gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip"> {{ $options.i18n.hoverTipText }} </gl-alert> - - <performance-insights-modal /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 8d764fad0c5..02d0c07ea54 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -82,7 +82,9 @@ export default { :stage-name="stageName" /> - <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div> + <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4 gl-align-self-center"> + {{ group.size }} + </div> </div> </button> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 6ab4eb58977..4aec28295bd 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,5 +1,5 @@ <script> -import { capitalize, escape, isEmpty } from 'lodash'; +import { escape, isEmpty } from 'lodash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { reportToSentry } from '../../utils'; import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue'; @@ -64,8 +64,7 @@ export default { }, }, jobClasses: [ - 'gl-py-3', - 'gl-px-4', + 'gl-p-3', 'gl-border-gray-100', 'gl-border-solid', 'gl-border-1', @@ -92,9 +91,6 @@ export default { columnSpacingClass() { return this.isStageView ? 'gl-px-6' : 'gl-px-9'; }, - formattedTitle() { - return capitalize(escape(this.name)); - }, hasAction() { return !isEmpty(this.action); }, @@ -141,8 +137,8 @@ export default { class="gl-display-flex gl-justify-content-space-between gl-relative" :class="$options.titleClasses" > - <span :title="formattedTitle" class="gl-text-truncate gl-pr-3 gl-w-85p"> - {{ formattedTitle }} + <span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p"> + {{ name }} </span> <action-component v-if="hasAction && canUpdatePipeline" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index fabae62fc45..a36d5d9b58f 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -9,7 +9,7 @@ import { } from '@gitlab/ui'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import ciHeader from '~/vue_shared/components/header_ci_component.vue'; +import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import { LOAD_FAILURE, POST_FAILURE, @@ -33,7 +33,7 @@ export default { pipelineRetry: 'pipelineRetry', finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], components: { - ciHeader, + CiHeader, GlAlert, GlButton, GlLoadingIcon, diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue index 70d1a5c08cc..f4fc6893520 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue @@ -1,5 +1,5 @@ <script> -import ciIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; /** * Component that renders both the CI icon status and the job name. @@ -9,7 +9,7 @@ import ciIcon from '~/vue_shared/components/ci_icon.vue'; */ export default { components: { - ciIcon, + CiIcon, }, props: { name: { diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue deleted file mode 100644 index fdbf0ca19bc..00000000000 --- a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue +++ /dev/null @@ -1,171 +0,0 @@ -<script> -import { GlAlert, GlCard, GlLink, GlLoadingIcon, GlModal } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; -import { humanizeTimeInterval } from '~/lib/utils/datetime_utility'; -import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import getPerformanceInsightsQuery from '../graphql/queries/get_performance_insights.query.graphql'; -import { performanceModalId } from '../constants'; -import { calculateJobStats, calculateSlowestFiveJobs } from '../utils'; - -export default { - name: 'PerformanceInsightsModal', - i18n: { - queuedCardHeader: s__('Pipeline|Longest queued job'), - queuedCardHelp: s__( - 'Pipeline|The longest queued job is the job that spent the longest time in the pending state, waiting to be picked up by a Runner', - ), - executedCardHeader: s__('Pipeline|Last executed job'), - executedCardHelp: s__( - 'Pipeline|The last executed job is the last job to start in the pipeline.', - ), - viewDependency: s__('Pipeline|View dependency'), - slowJobsTitle: s__('Pipeline|Five slowest jobs'), - feeback: __('Feedback issue'), - insightsLimit: s__('Pipeline|Only able to show first 100 results'), - }, - modal: { - title: s__('Pipeline|Performance insights'), - actionCancel: { - text: __('Close'), - attributes: { - variant: 'confirm', - }, - }, - }, - performanceModalId, - components: { - GlAlert, - GlCard, - GlLink, - GlModal, - GlLoadingIcon, - HelpPopover, - }, - inject: { - pipelineIid: { - default: '', - }, - pipelineProjectPath: { - default: '', - }, - }, - apollo: { - jobs: { - query: getPerformanceInsightsQuery, - variables() { - return { - fullPath: this.pipelineProjectPath, - iid: this.pipelineIid, - }; - }, - update(data) { - return data.project?.pipeline?.jobs; - }, - }, - }, - data() { - return { - jobs: null, - }; - }, - computed: { - longestQueuedJob() { - return calculateJobStats(this.jobs, 'queuedDuration'); - }, - lastExecutedJob() { - return calculateJobStats(this.jobs, 'startedAt'); - }, - slowestFiveJobs() { - return calculateSlowestFiveJobs(this.jobs); - }, - queuedDurationDisplay() { - return humanizeTimeInterval(this.longestQueuedJob.queuedDuration); - }, - showLimitMessage() { - return this.jobs.pageInfo.hasNextPage; - }, - }, -}; -</script> - -<template> - <gl-modal - :modal-id="$options.performanceModalId" - :title="$options.modal.title" - :action-cancel="$options.modal.actionCancel" - > - <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" /> - - <template v-else> - <gl-alert class="gl-mb-4" :dismissible="false"> - <p v-if="showLimitMessage" data-testid="limit-alert-text"> - {{ $options.i18n.insightsLimit }} - </p> - <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5"> - {{ $options.i18n.feeback }} - </gl-link> - </gl-alert> - - <div class="gl-display-flex gl-justify-content-space-between gl-mt-2 gl-mb-7"> - <gl-card class="gl-w-half gl-mr-7 gl-text-center"> - <template #header> - <span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span> - <help-popover> - {{ $options.i18n.queuedCardHelp }} - </help-popover> - </template> - <div class="gl-display-flex gl-flex-direction-column"> - <span - class="gl-font-weight-bold gl-font-size-h2 gl-mb-2" - data-testid="insights-queued-card-data" - > - {{ queuedDurationDisplay }} - </span> - <gl-link - :href="longestQueuedJob.detailedStatus.detailsPath" - data-testid="insights-queued-card-link" - > - {{ longestQueuedJob.name }} - </gl-link> - </div> - </gl-card> - <gl-card class="gl-w-half gl-text-center" data-testid="insights-executed-card"> - <template #header> - <span class="gl-font-weight-bold">{{ $options.i18n.executedCardHeader }}</span> - <help-popover> - {{ $options.i18n.executedCardHelp }} - </help-popover> - </template> - <div class="gl-display-flex gl-flex-direction-column"> - <span - class="gl-font-weight-bold gl-font-size-h2 gl-mb-2" - data-testid="insights-executed-card-data" - > - {{ lastExecutedJob.name }} - </span> - <gl-link - :href="lastExecutedJob.detailedStatus.detailsPath" - data-testid="insights-executed-card-link" - > - {{ $options.i18n.viewDependency }} - </gl-link> - </div> - </gl-card> - </div> - - <div class="gl-mt-7"> - <span class="gl-font-weight-bold">{{ $options.i18n.slowJobsTitle }}</span> - <div - v-for="job in slowestFiveJobs" - :key="job.name" - class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-3 gl-p-4 gl-border-t-1 gl-border-t-solid gl-border-b-0 gl-border-b-solid gl-border-gray-100" - > - <span data-testid="insights-slow-job-stage">{{ job.stage.name }}</span> - <gl-link :href="job.detailedStatus.detailsPath" data-testid="insights-slow-job-link">{{ - job.name - }}</gl-link> - </div> - </div> - </template> - </gl-modal> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 793e343a02a..3f1d7255a2b 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -1,9 +1,9 @@ <script> -import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { - tooltipOnTruncate, + TooltipOnTruncate, }, props: { jobName: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue index e485b38ce11..600832b7633 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue @@ -1,10 +1,9 @@ <script> -import { capitalize, escape } from 'lodash'; -import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { - tooltipOnTruncate, + TooltipOnTruncate, }, props: { stageName: { @@ -12,17 +11,12 @@ export default { required: true, }, }, - computed: { - formattedTitle() { - return capitalize(escape(this.stageName)); - }, - }, }; </script> <template> <tooltip-on-truncate :title="stageName" truncate-target="child" placement="top"> <div class="gl-py-2 gl-text-truncate gl-font-weight-bold gl-w-20"> - {{ formattedTitle }} + {{ stageName }} </div> </tooltip-on-truncate> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js new file mode 100644 index 00000000000..1ca9e35c008 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js @@ -0,0 +1,14 @@ +import { get } from 'lodash'; + +export const accessors = { + rest: { + detailedStatus: ['details', 'status'], + }, + graphql: { + detailedStatus: 'detailedStatus', + }, +}; + +export const accessValue = (pipeline, dataMethod, path) => { + return get(pipeline, accessors[dataMethod][path]); +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue index 670fa398536..211c5f117c7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue @@ -158,7 +158,7 @@ export default { :href="detailsPath" :title="tooltipText" :class="jobClasses" - class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" + class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" data-testid="job-with-link" @click.stop="hideTooltips" @mouseout="hideTooltips" diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue new file mode 100644 index 00000000000..a5c6dc98694 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue @@ -0,0 +1,132 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { accessValue } from './accessors/linked_pipelines_accessors'; +/** + * Renders the upstream/downstream portions of the pipeline mini graph. + */ +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CiIcon, + }, + inject: { + dataMethod: { + default: 'rest', + }, + }, + props: { + triggeredBy: { + type: Array, + required: false, + default: () => [], + }, + triggered: { + type: Array, + required: false, + default: () => [], + }, + pipelinePath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + maxRenderedPipelines: 3, + }; + }, + computed: { + // Exactly one of these (triggeredBy and triggered) must be truthy. Never both. Never neither. + isUpstream() { + return Boolean(this.triggeredBy.length) && !this.triggered.length; + }, + isDownstream() { + return !this.triggeredBy.length && Boolean(this.triggered.length); + }, + linkedPipelines() { + return this.isUpstream ? this.triggeredBy : this.triggered; + }, + totalPipelineCount() { + return this.linkedPipelines.length; + }, + linkedPipelinesTrimmed() { + return this.totalPipelineCount > this.maxRenderedPipelines + ? this.linkedPipelines.slice(0, this.maxRenderedPipelines) + : this.linkedPipelines; + }, + shouldRenderCounter() { + return this.isDownstream && this.linkedPipelines.length > this.maxRenderedPipelines; + }, + counterLabel() { + return `+${this.linkedPipelines.length - this.maxRenderedPipelines}`; + }, + counterTooltipText() { + return sprintf(s__('LinkedPipelines|%{counterLabel} more downstream pipelines'), { + counterLabel: this.counterLabel, + }); + }, + }, + methods: { + pipelineTooltipText(pipeline) { + const { label } = accessValue(pipeline, this.dataMethod, 'detailedStatus'); + + return `${pipeline.project.name} - ${label}`; + }, + pipelineStatus(pipeline) { + // detailedStatus is graphQL, details.status is REST + return pipeline?.detailedStatus || pipeline?.details?.status; + }, + triggerButtonClass(pipeline) { + const { group } = accessValue(pipeline, this.dataMethod, 'detailedStatus'); + + return `ci-status-icon-${group}`; + }, + }, +}; +</script> + +<template> + <span + v-if="linkedPipelines" + :class="{ + 'is-upstream': isUpstream, + 'is-downstream': isDownstream, + }" + class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle" + > + <a + v-for="pipeline in linkedPipelinesTrimmed" + :key="pipeline.id" + v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }" + :href="pipeline.path" + :class="triggerButtonClass(pipeline)" + class="linked-pipeline-mini-item gl-display-inline-block gl-h-6 gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle" + data-testid="linked-pipeline-mini-item" + > + <ci-icon + is-borderless + is-interactive + css-classes="gl-rounded-full" + :size="24" + :status="pipelineStatus(pipeline)" + class="gl-align-items-center gl-border gl-display-inline-flex" + /> + </a> + + <a + v-if="shouldRenderCounter" + v-gl-tooltip="{ title: counterTooltipText }" + :title="counterTooltipText" + :href="pipelinePath" + class="gl-align-items-center gl-bg-gray-50 gl-display-inline-flex gl-font-sm gl-h-6 gl-justify-content-center gl-rounded-pill gl-text-decoration-none gl-text-gray-500 gl-w-7 linked-pipelines-counter linked-pipeline-mini-item" + data-testid="linked-pipeline-counter" + > + {{ counterLabel }} + </a> + </span> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue new file mode 100644 index 00000000000..993fa121d89 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue @@ -0,0 +1,103 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import PipelineStages from './pipeline_stages.vue'; +import LinkedPipelinesMiniList from './linked_pipelines_mini_list.vue'; +/** + * Renders the pipeline mini graph. + */ +export default { + components: { + GlIcon, + LinkedPipelinesMiniList, + PipelineStages, + }, + arrowStyles: [ + 'arrow-icon gl-display-inline-block gl-mx-1 gl-text-gray-500 gl-vertical-align-middle!', + ], + props: { + downstreamPipelines: { + type: Array, + required: false, + default: () => [], + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pipelinePath: { + type: String, + required: false, + default: '', + }, + stages: { + type: Array, + required: true, + default: () => [], + }, + stagesClass: { + type: [Array, Object, String], + required: false, + default: '', + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + upstreamPipeline: { + type: Object, + required: false, + default: () => {}, + }, + }, + computed: { + hasDownstreamPipelines() { + return Boolean(this.downstreamPipelines.length); + }, + }, + methods: { + onPipelineActionRequestComplete() { + this.$emit('pipelineActionRequestComplete'); + }, + }, +}; +</script> +<template> + <div class="stage-cell" data-testid="pipeline-mini-graph"> + <linked-pipelines-mini-list + v-if="upstreamPipeline" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + upstreamPipeline, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + data-testid="pipeline-mini-graph-upstream" + /> + <gl-icon + v-if="upstreamPipeline" + :class="$options.arrowStyles" + name="long-arrow" + data-testid="upstream-arrow-icon" + /> + <pipeline-stages + :is-merge-train="isMergeTrain" + :stages="stages" + :update-dropdown="updateDropdown" + :stages-class="stagesClass" + data-testid="pipeline-stages" + @pipelineActionRequestComplete="onPipelineActionRequestComplete" + @miniGraphStageClick="$emit('miniGraphStageClick')" + /> + <gl-icon + v-if="hasDownstreamPipelines" + :class="$options.arrowStyles" + name="long-arrow" + data-testid="downstream-arrow-icon" + /> + <linked-pipelines-mini-list + v-if="hasDownstreamPipelines" + :triggered="downstreamPipelines" + :pipeline-path="pipelinePath" + data-testid="pipeline-mini-graph-downstream" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue index d7e55d36ff6..a68797a7235 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue @@ -77,6 +77,10 @@ export default { this.isDropdownOpen = true; this.isLoading = true; this.fetchJobs(); + + // used for tracking and is separate from event hub + // to avoid complexity with mixin + this.$emit('miniGraphStageClick'); }, fetchJobs() { axios diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue index 05cb2ebb769..e965dc5e6b0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue @@ -1,7 +1,7 @@ <script> -import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; +import PipelineStage from './pipeline_stage.vue'; /** - * Renders the pipeline mini graph. + * Renders the pipeline stages portion of the pipeline mini graph. */ export default { components: { @@ -36,7 +36,7 @@ export default { }; </script> <template> - <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle"> + <div data-testid="pipeline-stages" class="gl-display-inline gl-vertical-align-middle"> <div v-for="stage in stages" :key="stage.name" @@ -48,6 +48,7 @@ export default { :update-dropdown="updateDropdown" :is-merge-train="isMergeTrain" @pipelineActionRequestComplete="onPipelineActionRequestComplete" + @miniGraphStageClick="$emit('miniGraphStageClick')" /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index 05a1ceface3..2d2f649f651 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -10,6 +10,8 @@ import { import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { TRACKING_CATEGORIES } from '../../constants'; export const i18n = { downloadArtifacts: __('Download artifacts'), @@ -29,6 +31,7 @@ export default { GlSearchBoxByType, GlLoadingIcon, }, + mixins: [Tracking.mixin()], inject: { artifactsEndpoint: { default: '', @@ -60,6 +63,10 @@ export default { }, methods: { fetchArtifacts() { + // refactor tracking based on action once this dropdown supports + // actions other than artifacts + this.track('click_artifacts_dropdown', { label: TRACKING_CATEGORIES.table }); + this.isLoading = true; // Replace the placeholder with the ID of the pipeline we are viewing const endpoint = this.artifactsEndpoint.replace( diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index 7a08dacb824..dd62ffb27f7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -1,7 +1,8 @@ <script> import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; +import Tracking from '~/tracking'; import eventHub from '../../event_hub'; -import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '../../constants'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '../../constants'; import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; @@ -17,6 +18,7 @@ export default { PipelineMultiActions, PipelinesManualActions, }, + mixins: [Tracking.mixin()], props: { pipeline: { type: Object, @@ -52,6 +54,7 @@ export default { }, methods: { handleCancelClick() { + this.trackClick('click_cancel_button'); eventHub.$emit('openConfirmationModal', { pipeline: this.pipeline, endpoint: this.pipeline.cancel_path, @@ -59,8 +62,12 @@ export default { }, handleRetryClick() { this.isRetrying = true; + this.trackClick('click_retry_button'); eventHub.$emit('retryPipeline', this.pipeline.retry_path); }, + trackClick(action) { + this.track(action, { label: TRACKING_CATEGORIES.table }); + }, }, }; </script> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue index ef21673115e..eb70b5fbb7a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue @@ -83,9 +83,7 @@ export default { <span class="font-weight-bold">{{ __('Pipeline') }}</span> - <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" - >#{{ pipeline.id }}</a - > + <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a> <template v-if="hasRef"> {{ __('from') }} <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 09d588aaafd..39d41415456 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,9 +1,10 @@ <script> import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { ICONS } from '../../constants'; +import { ICONS, TRACKING_CATEGORIES } from '../../constants'; import PipelineLabels from './pipeline_labels.vue'; export default { @@ -17,6 +18,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin()], props: { pipeline: { type: Object, @@ -114,6 +116,11 @@ export default { return this.pipeline?.commit?.title; }, }, + methods: { + trackClick(action) { + this.track(action, { label: TRACKING_CATEGORIES.table }); + }, + }, }; </script> <template> @@ -125,6 +132,7 @@ export default { :href="commitUrl" class="commit-row-message gl-text-gray-900" data-testid="commit-title" + @click="trackClick('click_commit_title')" >{{ commitTitle }}</gl-link > </tooltip-on-truncate> @@ -137,6 +145,7 @@ export default { class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3" data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" + @click="trackClick('click_pipeline_id')" >#{{ pipeline[pipelineKey] }}</gl-link > <!--Commit row--> @@ -154,11 +163,17 @@ export default { :href="mergeRequestRef.path" class="ref-name gl-mr-3" data-testid="merge-request-ref" + @click="trackClick('click_mr_ref')" >{{ mergeRequestRef.iid }}</gl-link > - <gl-link v-else :href="refUrl" class="ref-name gl-mr-3" data-testid="commit-ref-name">{{ - commitRef.name - }}</gl-link> + <gl-link + v-else + :href="refUrl" + class="ref-name gl-mr-3" + data-testid="commit-ref-name" + @click="trackClick('click_commit_name')" + >{{ commitRef.name }}</gl-link + > </tooltip-on-truncate> <gl-icon v-gl-tooltip @@ -167,9 +182,13 @@ export default { :title="__('Commit')" data-testid="commit-icon" /> - <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{ - commitShortSha - }}</gl-link> + <gl-link + :href="commitUrl" + class="commit-sha mr-0" + data-testid="commit-short-sha" + @click="trackClick('click_commit_sha')" + >{{ commitShortSha }}</gl-link + > <user-avatar-link v-if="commitAuthor" :link-href="commitAuthor.path" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 485e338f639..f9022be888a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -4,6 +4,7 @@ import { isEqual } from 'lodash'; import createFlash from '~/flash'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; +import Tracking from '~/tracking'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import { @@ -11,6 +12,7 @@ import { RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER, PipelineKeyOptions, + TRACKING_CATEGORIES, } from '../../constants'; import PipelinesMixin from '../../mixins/pipelines_mixin'; import PipelinesService from '../../services/pipelines_service'; @@ -35,7 +37,7 @@ export default { PipelinesTableComponent, TablePagination, }, - mixins: [PipelinesMixin], + mixins: [PipelinesMixin, Tracking.mixin()], props: { store: { type: Object, @@ -246,6 +248,8 @@ export default { params = this.onChangeWithFilter(params); this.updateContent(params); + + this.track('click_filter_tabs', { label: TRACKING_CATEGORIES.tabs }); }, successCallback(resp) { // Because we are polling & the user is interacting verify if the response received diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 4d28545a035..af089aebbbe 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -2,7 +2,9 @@ import { GlFilteredSearch } from '@gitlab/ui'; import { map } from 'lodash'; import { s__ } from '~/locale'; +import Tracking from '~/tracking'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { TRACKING_CATEGORIES } from '../../constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineSourceToken from './tokens/pipeline_source_token.vue'; import PipelineStatusToken from './tokens/pipeline_status_token.vue'; @@ -19,6 +21,7 @@ export default { components: { GlFilteredSearch, }, + mixins: [Tracking.mixin()], props: { projectId: { type: String, @@ -110,6 +113,7 @@ export default { }, methods: { onSubmit(filters) { + this.track('click_filtered_search', { label: TRACKING_CATEGORIES.search }); this.$emit('filterPipelines', filters); }, }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue index 47fffa8a6b2..16a747f6165 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue @@ -4,8 +4,10 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; +import { TRACKING_CATEGORIES } from '../../constants'; export default { directives: { @@ -17,6 +19,7 @@ export default { GlDropdownItem, GlIcon, }, + mixins: [Tracking.mixin()], props: { actions: { type: Array, @@ -66,7 +69,6 @@ export default { createFlash({ message: __('An error occurred while making the request.') }); }); }, - isActionDisabled(action) { if (action.playable === undefined) { return false; @@ -74,6 +76,9 @@ export default { return !action.playable; }, + trackClick() { + this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table }); + }, }, }; </script> @@ -86,6 +91,7 @@ export default { right lazy icon="play" + @shown="trackClick" > <gl-dropdown-item v-for="action in actions" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index e765a8cd86c..936ae4da1ec 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -1,6 +1,7 @@ <script> -import { CHILD_VIEW } from '~/pipelines/constants'; +import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; export default { @@ -8,6 +9,7 @@ export default { CiBadge, PipelinesTimeago, }, + mixins: [Tracking.mixin()], props: { pipeline: { type: Object, @@ -26,6 +28,11 @@ export default { return this.viewType === CHILD_VIEW; }, }, + methods: { + trackClick() { + this.track('click_ci_status_badge', { label: TRACKING_CATEGORIES.table }); + }, + }, }; </script> @@ -37,6 +44,7 @@ export default { :show-text="!isChildView" :icon-classes="'gl-vertical-align-middle!'" data-qa-selector="pipeline_commit_status" + @ciStatusBadgeClick="trackClick" /> <pipelines-timeago class="gl-mt-3" :pipeline="pipeline" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 53da98434b0..f6e46c090d3 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,8 +1,10 @@ <script> import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import eventHub from '../../event_hub'; -import PipelineMiniGraph from './pipeline_mini_graph.vue'; +import { TRACKING_CATEGORIES } from '../../constants'; import PipelineOperations from './pipeline_operations.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; import PipelineTriggerer from './pipeline_triggerer.vue'; @@ -17,8 +19,6 @@ const DEFAULT_TH_CLASSES = export default { components: { GlTableLite, - LinkedPipelinesMiniList: () => - import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), PipelineMiniGraph, PipelineOperations, PipelinesStatusBadge, @@ -70,6 +70,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin()], props: { pipelines: { type: Array, @@ -126,6 +127,9 @@ export default { onPipelineActionRequestComplete() { eventHub.$emit('refreshPipelinesTable'); }, + trackPipelineMiniGraph() { + this.track('click_minigraph', { label: TRACKING_CATEGORIES.table }); + }, }, TBODY_TR_ATTR: { 'data-testid': 'pipeline-table-row', @@ -169,29 +173,15 @@ export default { </template> <template #cell(stages)="{ item }"> - <div class="stage-cell"> - <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 --> - <div></div> - <linked-pipelines-mini-list - v-if="item.triggered_by" - :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - item.triggered_by, - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - data-testid="mini-graph-upstream" - /> - <pipeline-mini-graph - v-if="item.details && item.details.stages && item.details.stages.length > 0" - :stages="item.details.stages" - :update-dropdown="updateGraphDropdown" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" - /> - <linked-pipelines-mini-list - v-if="item.triggered.length" - :triggered="item.triggered" - :pipeline-path="item.path" - data-testid="mini-graph-downstream" - /> - </div> + <pipeline-mini-graph + :downstream-pipelines="item.triggered" + :pipeline-path="item.path" + :stages="item.details.stages" + :update-dropdown="updateGraphDropdown" + :upstream-pipeline="item.triggered_by" + @pipelineActionRequestComplete="onPipelineActionRequestComplete" + @miniGraphStageClick="trackPipelineMiniGraph" + /> </template> <template #cell(actions)="{ item }"> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 7b38f870cb6..327633dcb1a 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -110,4 +110,8 @@ export const DEFAULT_FIELDS = [ }, ]; -export const performanceModalId = 'performanceInsightsModal'; +export const TRACKING_CATEGORIES = { + table: 'pipelines_table_component', + tabs: 'pipelines_filter_tabs', + search: 'pipelines_filtered_search', +}; diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql deleted file mode 100644 index 25e990c8934..00000000000 --- a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql +++ /dev/null @@ -1,28 +0,0 @@ -query getPerformanceInsightsData($fullPath: ID!, $iid: ID!) { - project(fullPath: $fullPath) { - id - pipeline(iid: $iid) { - id - jobs { - pageInfo { - hasNextPage - } - nodes { - id - duration - detailedStatus { - id - detailsPath - } - name - stage { - id - name - } - startedAt - queuedDuration - } - } - } - } -} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql index 641ec7a3cf6..b0f875160d4 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql @@ -11,6 +11,7 @@ query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) { } nodes { artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { downloadPath fileType diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index 2fedd7e7a98..c9e60756407 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import pipelineHeader from './components/header_component.vue'; +import PipelineHeader from './components/header_component.vue'; Vue.use(VueApollo); @@ -16,7 +16,7 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou new Vue({ el, components: { - pipelineHeader, + PipelineHeader, }, apolloProvider, provide: { diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js index 7051d356089..508f188c229 100644 --- a/app/assets/javascripts/pipelines/pipeline_tabs.js +++ b/app/assets/javascripts/pipelines/pipeline_tabs.js @@ -20,6 +20,8 @@ export const createAppOptions = (selector, apolloProvider) => { const { canGenerateCodequalityReports, codequalityReportDownloadPath, + codequalityBlobPath, + codequalityProjectPath, downloadablePathForReportType, exposeSecurityDashboard, exposeLicenseScanningData, @@ -40,9 +42,12 @@ export const createAppOptions = (selector, apolloProvider) => { hasTestReport, emptyStateImagePath, artifactsExpiredImagePath, + isFullCodequalityReportAvailable, testsCount, } = dataset; + // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved + const projectPath = fullPath; const defaultTabValue = getPipelineDefaultTab(window.location.href); return { @@ -63,6 +68,10 @@ export const createAppOptions = (selector, apolloProvider) => { provide: { canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports), codequalityReportDownloadPath, + codequalityBlobPath, + codequalityProjectPath, + isFullCodequalityReportAvailable: parseBoolean(isFullCodequalityReportAvailable), + projectPath, defaultTabValue, downloadablePathForReportType, exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard), diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 83e00b80426..588d15495ab 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -153,24 +153,3 @@ export const getPipelineDefaultTab = (url) => { return null; }; - -export const calculateJobStats = (jobs, sortField) => { - const jobNodes = [...jobs.nodes]; - - const sorted = jobNodes.sort((a, b) => { - return b[sortField] - a[sortField]; - }); - - return sorted[0]; -}; - -export const calculateSlowestFiveJobs = (jobs) => { - const jobNodes = [...jobs.nodes]; - const limit = 5; - - return jobNodes - .sort((a, b) => { - return b.duration - a.duration; - }) - .slice(0, limit); -}; diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js index f208280af27..2d31cf772e3 100644 --- a/app/assets/javascripts/profile/account/index.js +++ b/app/assets/javascripts/profile/account/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; -import deleteAccountModal from './components/delete_account_modal.vue'; +import DeleteAccountModal from './components/delete_account_modal.vue'; import UpdateUsername from './components/update_username.vue'; export default () => { @@ -27,7 +27,7 @@ export default () => { new Vue({ el: deleteAccountModalEl, components: { - deleteAccountModal, + DeleteAccountModal, }, mounted() { deleteAccountButton.disabled = false; diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 064bcf8e4c4..af5beeb686c 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,11 +1,14 @@ import $ from 'jquery'; +import Vue from 'vue'; import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseRailsFormFields } from '~/lib/utils/forms'; import { Rails } from '~/lib/utils/rails_ujs'; import TimezoneDropdown, { formatTimezone, } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; +import UserProfileSetStatusWrapper from '~/set_status_modal/user_profile_set_status_wrapper.vue'; export default class Profile { constructor({ form } = {}) { @@ -116,3 +119,24 @@ export default class Profile { } } } + +export const initSetStatusForm = () => { + const el = document.getElementById('js-user-profile-set-status-form'); + + if (!el) { + return null; + } + + const fields = parseRailsFormFields(el); + + return new Vue({ + el, + name: 'UserProfileStatusForm', + provide: { + fields, + }, + render(h) { + return h(UserProfileSetStatusWrapper); + }, + }); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue index 1cdf26b76b7..4505dd1f85c 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -2,11 +2,11 @@ import { GlLoadingIcon } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; import { getQueryHeaders, toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import { formatStages } from '../utils'; import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql'; import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql'; @@ -21,8 +21,6 @@ export default { components: { GlLoadingIcon, PipelineMiniGraph, - LinkedPipelinesMiniList: () => - import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, inject: { fullPath: { @@ -92,12 +90,12 @@ export default { }; }, computed: { - hasDownstream() { - return this.pipeline?.downstream?.nodes.length > 0; - }, downstreamPipelines() { return this.pipeline?.downstream?.nodes; }, + pipelinePath() { + return this.pipeline?.path ?? ''; + }, upstreamPipeline() { return this.pipeline?.upstream; }, @@ -128,23 +126,13 @@ export default { <template> <div class="gl-pt-2"> <gl-loading-icon v-if="$apollo.queries.pipeline.loading" /> - <div v-else class="gl-align-items-center gl-display-flex"> - <linked-pipelines-mini-list - v-if="upstreamPipeline" - :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - upstreamPipeline, - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - data-testid="commit-box-mini-graph-upstream" - /> - - <pipeline-mini-graph :stages="formattedStages" data-testid="commit-box-mini-graph" /> - - <linked-pipelines-mini-list - v-if="hasDownstream" - :triggered="downstreamPipelines" - :pipeline-path="pipeline.path" - data-testid="commit-box-mini-graph-downstream" - /> - </div> + <pipeline-mini-graph + v-else + data-testid="commit-box-pipeline-mini-graph" + :downstream-pipelines="downstreamPipelines" + :pipeline-path="pipelinePath" + :stages="formattedStages" + :upstream-pipeline="upstreamPipeline" + /> </div> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index ecd2288eb2f..06d96ef7bef 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -1,7 +1,7 @@ <script> import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import dateFormat from 'dateformat'; +import dateFormat from '~/lib/dateformat'; import { getDateInPast } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js index b8ac17a01f2..84b8936c17f 100644 --- a/app/assets/javascripts/projects/project_visibility.js +++ b/app/assets/javascripts/projects/project_visibility.js @@ -1,13 +1,7 @@ import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import eventHub from '~/projects/new/event_hub'; - -// Values are from lib/gitlab/visibility_level.rb -const visibilityLevel = { - private: 0, - internal: 10, - public: 20, -}; +import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants'; function setVisibilityOptions({ name, visibility, showPath, editPath }) { document.querySelectorAll('.visibility-level-setting .gl-form-radio').forEach((option) => { @@ -19,13 +13,14 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) { const optionInput = option.querySelector('input[type=radio]'); const optionValue = optionInput ? parseInt(optionInput.value, 10) : 0; - if (visibilityLevel[visibility] < optionValue) { + if (VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility] < optionValue) { option.classList.add('disabled'); optionInput.disabled = true; const reason = option.querySelector('.option-disabled-reason'); if (reason) { const optionTitle = option.querySelector('.js-visibility-level-radio span'); const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : ''; + // eslint-disable-next-line no-unsanitized/property reason.innerHTML = sprintf( __( 'This project cannot be %{visibilityLevel} because the visibility of %{openShowLink}%{name}%{closeShowLink} is %{visibility}. To make this project %{visibilityLevel}, you must first %{openEditLink}change the visibility%{closeEditLink} of the parent group.', diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index ada951f6867..e8eaf0a70b2 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,16 +1,58 @@ <script> -import { __ } from '~/locale'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import branchRulesQuery from './graphql/queries/branch_rules.query.graphql'; +import BranchRule from './components/branch_rule.vue'; + +export const i18n = { + queryError: s__( + 'ProtectedBranch|An error occurred while loading branch rules. Please try again.', + ), + emptyState: s__( + 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.', + ), +}; export default { name: 'BranchRules', - i18n: { heading: __('Branch') }, + i18n, + components: { + BranchRule, + }, + apollo: { + branchRules: { + query: branchRulesQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + return data.project?.branchRules?.nodes || []; + }, + error() { + createFlash({ message: this.$options.i18n.queryError }); + }, + }, + }, + props: { + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + branchRules: [], + }; + }, }; </script> <template> - <div> - <strong>{{ $options.i18n.heading }}</strong> + <div class="settings-content"> + <branch-rule v-for="rule in branchRules" :key="rule.name" :name="rule.name" /> - <!-- TODO - List branch rules (https://gitlab.com/gitlab-org/gitlab/-/issues/362217) --> + <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span> </div> </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue new file mode 100644 index 00000000000..68750318029 --- /dev/null +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -0,0 +1,61 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export const i18n = { + defaultLabel: s__('BranchRules|default'), + protectedLabel: s__('BranchRules|protected'), +}; + +export default { + name: 'BranchRule', + i18n, + components: { + GlBadge, + }, + props: { + name: { + type: String, + required: true, + }, + isDefault: { + type: Boolean, + required: false, + default: false, + }, + isProtected: { + type: Boolean, + required: false, + default: false, + }, + approvalDetails: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + hasApprovalDetails() { + return this.approvalDetails && this.approvalDetails.length; + }, + }, +}; +</script> + +<template> + <div class="gl-border-b gl-pt-5 gl-pb-5"> + <strong class="gl-font-monospace">{{ name }}</strong> + + <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{ + $options.i18n.defaultLabel + }}</gl-badge> + + <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ + $options.i18n.protectedLabel + }}</gl-badge> + + <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> + <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql new file mode 100644 index 00000000000..104a0c25a80 --- /dev/null +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql @@ -0,0 +1,10 @@ +query getBranchRules($projectPath: ID!) { + project(fullPath: $projectPath) { + id + branchRules { + nodes { + name + } + } + } +} diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js index abe0b93081e..35322e2e466 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js @@ -1,13 +1,28 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import BranchRulesApp from '~/projects/settings/repository/branch_rules/app.vue'; +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + export default function mountBranchRules(el) { if (!el) return null; + const { projectPath } = el.dataset; + return new Vue({ el, + apolloProvider, render(createElement) { - return createElement(BranchRulesApp); + return createElement(BranchRulesApp, { + props: { + projectPath, + }, + }); }, }); } diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue index 9c8de9bef2d..3d553e71f71 100644 --- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue +++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue @@ -2,7 +2,7 @@ import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui'; import { s__ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import searchProjectTopics from '../queries/project_topics_search.query.graphql'; +import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; export default { components: { diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 14c8c53dd19..71ff3e892b1 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,12 +1,18 @@ <script> -import { GlAlert, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlAlert, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { __, sprintf } from '~/locale'; import ServiceDeskSetting from './service_desk_setting.vue'; export default { + customEmailHelpPath: helpPagePath('/user/project/service_desk.html', { + anchor: 'using-a-custom-email-address', + }), components: { GlAlert, + GlSprintf, + GlLink, ServiceDeskSetting, }, directives: { @@ -43,6 +49,9 @@ export default { templates: { default: [], }, + publicProject: { + default: false, + }, }, data() { return { @@ -127,6 +136,27 @@ export default { <template> <div> + <gl-alert + v-if="publicProject && isEnabled" + class="mb-3" + variant="warning" + data-testid="public-project-alert" + :dismissible="false" + > + <gl-sprintf + :message=" + __( + 'This project is public. Non-members can guess the Service Desk email address, because it contains the group and project name. %{linkStart}How do I create a custom email address?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="$options.customEmailHelpPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss"> <span v-safe-html="alertMessage"></span> </gl-alert> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 8a9a0b541f3..452e7a4fd21 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -176,7 +176,7 @@ export default { </template> </gl-form-input-group> <template v-if="email && hasCustomEmail" #description> - <span class="gl-mt-2 d-inline-block"> + <span class="gl-mt-2 gl-display-inline-block"> <gl-sprintf :message="__('Emails sent to %{email} are also supported.')"> <template #email> <code>{{ incomingEmail }}</code> @@ -190,7 +190,11 @@ export default { </template> </gl-form-group> - <gl-form-group :label="__('Email address suffix')" :state="!projectKeyError"> + <gl-form-group + :label="__('Email address suffix')" + :state="!projectKeyError" + data-testid="suffix-form-group" + > <gl-form-input v-if="hasProjectKeySupport" id="service-desk-project-suffix" @@ -216,22 +220,24 @@ export default { </gl-sprintf> </template> <template v-else #description> - <gl-sprintf - :message=" - __( - 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link - :href="customEmailAddressHelpUrl" - target="_blank" - class="gl-text-blue-600 font-size-inherit" - >{{ content }} - </gl-link> - </template> - </gl-sprintf> + <span class="gl-text-gray-900"> + <gl-sprintf + :message=" + __( + 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + :href="customEmailAddressHelpUrl" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </span> </template> <template v-if="hasProjectKeySupport && projectKeyError" #invalid-feedback> @@ -266,7 +272,27 @@ export default { /> <template v-if="hasProjectKeySupport" #description> - {{ __('Emails sent from Service Desk have this name.') }} + {{ __('Name to be used as the sender for emails from Service Desk.') }} + </template> + <template v-else #description> + <span class="gl-text-gray-900"> + <gl-sprintf + :message=" + __( + 'To add display name, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + :href="customEmailAddressHelpUrl" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </span> </template> </gl-form-group> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue index bdd9f940d79..315f0743b53 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue @@ -100,7 +100,7 @@ export default { <gl-dropdown-item v-for="template in item" :key="template.key" - :is-check-item="true" + is-check-item :is-checked=" template.project_id === selectedFileTemplateProjectId && template.name === selectedTemplate diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index e14cdee17ce..26435a5fac9 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -20,6 +20,7 @@ export default () => { selectedTemplate, selectedFileTemplateProjectId, templates, + publicProject, } = el.dataset; return new Vue({ @@ -35,6 +36,7 @@ export default () => { selectedTemplate, selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null, templates: JSON.parse(templates), + publicProject: parseBoolean(publicProject), }, render: (createElement) => createElement(ServiceDeskRoot), }); diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js index 5bbace11b15..e063064663b 100644 --- a/app/assets/javascripts/projects/star.js +++ b/app/assets/javascripts/projects/star.js @@ -22,11 +22,14 @@ export default class Star { starSpan.classList.remove('starred'); starSpan.textContent = s__('StarProject|Star'); starIcon.remove(); + // eslint-disable-next-line no-unsanitized/method starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses)); } else { starSpan.classList.add('starred'); starSpan.textContent = __('Unstar'); starIcon.remove(); + + // eslint-disable-next-line no-unsanitized/method starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses)); } }) diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index a79da00de43..6b14ebadacc 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -4,7 +4,7 @@ import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import { __, s__, sprintf } from '~/locale'; -import ciIcon from '~/vue_shared/components/ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitPipelineService from '../services/commit_pipeline_service'; export default { @@ -12,7 +12,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - ciIcon, + CiIcon, GlLoadingIcon, }, props: { diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 270d4632a54..09ecad2d90e 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -7,14 +7,14 @@ import { inputPlaceholderTextMap, issuableTypesMap, } from '../constants'; -import issueToken from './issue_token.vue'; +import IssueToken from './issue_token.vue'; const SPACE_FACTOR = 1; export default { name: 'RelatedIssuableInput', components: { - issueToken, + IssueToken, }, props: { inputId: { diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index 5b4a6d1fe0d..53f2dbbbbd7 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -3,7 +3,6 @@ import { GlLink, GlIcon, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import { issuableIconMap, - issuableQaClassMap, linkedIssueTypesMap, linkedIssueTypesTextMap, issuablesBlockHeaderTextMap, @@ -142,9 +141,6 @@ export default { issuableTypeIcon() { return issuableIconMap[this.issuableType]; }, - qaClass() { - return issuableQaClassMap[this.issuableType]; - }, toggleIcon() { return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; }, @@ -166,11 +162,15 @@ export default { </script> <template> - <div id="related-issues" class="related-issues-block gl-mt-5"> - <div class="card card-slim gl-overflow-hidden"> + <div id="related-issues" class="related-issues-block"> + <div class="card card-slim gl-overflow-hidden gl-mt-5 gl-mb-0"> <div - :class="{ 'panel-empty-heading border-bottom-0': !hasBody, 'gl-border-b-0': !isOpen }" - class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + :class="{ + 'panel-empty-heading border-bottom-0': !hasBody, + 'gl-border-b-1': isOpen, + 'gl-border-b-0': !isOpen, + }" + class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-solid gl-border-b-gray-100" > <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1"> <gl-link @@ -205,7 +205,6 @@ export default { data-qa-selector="related_issues_plus_button" data-testid="related-issues-plus-button" :aria-label="addIssuableButtonText" - :class="qaClass" class="gl-ml-3" @click="addButtonClick" > diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index cad5037d7e4..ae40232df6f 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -40,7 +40,7 @@ import RelatedIssuesBlock from './related_issues_block.vue'; export default { name: 'RelatedIssuesRoot', components: { - relatedIssuesBlock: RelatedIssuesBlock, + RelatedIssuesBlock, }, props: { endpoint: { diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js index 23ea93cd258..4eb054ccb5c 100644 --- a/app/assets/javascripts/related_issues/constants.js +++ b/app/assets/javascripts/related_issues/constants.js @@ -99,15 +99,6 @@ export const issuableIconMap = { [issuableTypesMap.EPIC]: 'epic', }; -/** - * These are used to map issuableType to the correct QA class. - * Since these are never used for any display purposes, don't wrap - * them inside i18n functions. - */ -export const issuableQaClassMap = { - [issuableTypesMap.EPIC]: 'qa-add-epics-button', -}; - export const PathIdSeparator = { Epic: '&', Issue: '#', diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue index 78831ceefe9..6d415471b14 100644 --- a/app/assets/javascripts/releases/components/evidence_block.vue +++ b/app/assets/javascripts/releases/components/evidence_block.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import dateFormat from 'dateformat'; +import dateFormat from '~/lib/dateformat'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index a71a8125d65..669e5928143 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -16,6 +16,8 @@ import { import * as types from './mutation_types'; +class GraphQLError extends Error {} + export const initializeRelease = ({ commit, dispatch, state }) => { if (state.isExistingRelease) { // When editing an existing release, @@ -110,35 +112,35 @@ export const saveRelease = ({ commit, dispatch, state }) => { * * @param {Object} gqlResponse The response object returned by the GraphQL client * @param {String} mutationName The name of the mutation that was executed - * @param {String} messageIfError An message to build into the error object if something went wrong */ -const checkForErrorsAsData = (gqlResponse, mutationName, messageIfError) => { +const checkForErrorsAsData = (gqlResponse, mutationName) => { const allErrors = gqlResponse.data[mutationName].errors; if (allErrors.length > 0) { - const allErrorMessages = JSON.stringify(allErrors); - throw new Error(`${messageIfError}: ${allErrorMessages}`); + throw new GraphQLError(allErrors[0]); } }; -export const createRelease = async ({ commit, dispatch, state, getters }) => { +export const createRelease = async ({ commit, dispatch, getters }) => { try { const response = await gqClient.mutate({ mutation: createReleaseMutation, variables: getters.releaseCreateMutatationVariables, }); - checkForErrorsAsData( - response, - 'releaseCreate', - `Something went wrong while creating a new release with projectPath "${state.projectPath}" and tagName "${state.release.tagName}"`, - ); + checkForErrorsAsData(response, 'releaseCreate'); dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl); } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash({ - message: s__('Release|Something went wrong while creating a new release.'), - }); + if (error instanceof GraphQLError) { + createFlash({ + message: error.message, + }); + } else { + createFlash({ + message: s__('Release|Something went wrong while creating a new release.'), + }); + } } }; @@ -146,7 +148,7 @@ export const createRelease = async ({ commit, dispatch, state, getters }) => { * Deletes a single release link. * Throws an error if any network or validation errors occur. */ -const deleteReleaseLinks = async ({ state, id }) => { +const deleteReleaseLinks = async ({ id }) => { const deleteResponse = await gqClient.mutate({ mutation: deleteReleaseAssetLinkMutation, variables: { @@ -154,11 +156,7 @@ const deleteReleaseLinks = async ({ state, id }) => { }, }); - checkForErrorsAsData( - deleteResponse, - 'releaseAssetLinkDelete', - `Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`, - ); + checkForErrorsAsData(deleteResponse, 'releaseAssetLinkDelete'); }; /** @@ -180,11 +178,7 @@ const createReleaseLink = async ({ state, link }) => { }, }); - checkForErrorsAsData( - createResponse, - 'releaseAssetLinkCreate', - `Something went wrong while creating a release asset link for release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`, - ); + checkForErrorsAsData(createResponse, 'releaseAssetLinkCreate'); }; export const updateRelease = async ({ commit, dispatch, state, getters }) => { @@ -210,11 +204,7 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => { variables: getters.releaseUpdateMutatationVariables, }); - checkForErrorsAsData( - updateReleaseResponse, - 'releaseUpdate', - `Something went wrong while updating release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`, - ); + checkForErrorsAsData(updateReleaseResponse, 'releaseUpdate'); // Delete all links currently associated with this Release await Promise.all( @@ -266,7 +256,7 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => { mutation: deleteReleaseMutation, variables: getters.releaseDeleteMutationVariables, }) - .then((response) => checkForErrorsAsData(response, 'releaseDelete', '')) + .then((response) => checkForErrorsAsData(response, 'releaseDelete')) .then(() => { window.sessionStorage.setItem( deleteReleaseSessionKey(state.projectPath), diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index 62d6bd42d51..ccca9ca8250 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -130,7 +130,7 @@ export const releaseUpdateMutatationVariables = (state, getters) => { projectPath: state.projectPath, tagName: state.release.tagName, name, - releasedAt: state.release.releasedAt, + releasedAt: getters.releasedAtChanged ? state.release.releasedAt : null, description: state.includeTagNotes ? getters.formattedReleaseNotes : state.release.description, @@ -167,3 +167,6 @@ export const formattedReleaseNotes = ({ includeTagNotes, release: { description includeTagNotes && tagNotes ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n` : description; + +export const releasedAtChanged = ({ originalReleasedAt, release }) => + originalReleasedAt !== release.releasedAt; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index ea794f91f66..34361f84a5a 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js @@ -14,7 +14,7 @@ export default { description: '', milestones: [], groupMilestones: [], - releasedAt: new Date(), + releasedAt: state.originalReleasedAt, assets: { links: [], }, @@ -29,6 +29,7 @@ export default { state.isFetchingRelease = false; state.release = data; state.originalRelease = Object.freeze(cloneDeep(state.release)); + state.originalReleasedAt = state.originalRelease.releasedAt; }, [types.RECEIVE_RELEASE_ERROR](state, error) { state.fetchError = error; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js index cb447cf9aaf..11a2f9df59b 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js @@ -61,4 +61,5 @@ export default ({ tagNotes: '', includeTagNotes: false, existingRelease: null, + originalReleasedAt: new Date(), }); diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 78572f11f6f..902077ba3e4 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -13,9 +13,10 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import CodeIntelligence from '~/code_navigation/components/app.vue'; import LineHighlighter from '~/blob/line_highlighter'; +import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import addBlameLink from '~/blob/blob_blame_link'; +import projectInfoQuery from '../queries/project_info.query.graphql'; import getRefMixin from '../mixins/get_ref'; -import blobInfoQuery from '../queries/blob_info.query.graphql'; import userInfoQuery from '../queries/user_info.query.graphql'; import applicationInfoQuery from '../queries/application_info.query.graphql'; import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE, LEGACY_FILE_TYPES } from '../constants'; @@ -41,6 +42,21 @@ export default { }, }, apollo: { + projectInfo: { + query: projectInfoQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + error() { + this.displayError(); + }, + update({ project }) { + this.pathLocks = project.pathLocks || DEFAULT_BLOB_INFO.pathLocks; + this.userPermissions = project.userPermissions; + }, + }, gitpodEnabled: { query: applicationInfoQuery, error() { @@ -121,6 +137,8 @@ export default { gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled, currentUser: DEFAULT_BLOB_INFO.currentUser, useFallback: false, + pathLocks: DEFAULT_BLOB_INFO.pathLocks, + userPermissions: DEFAULT_BLOB_INFO.userPermissions, }; }, computed: { @@ -163,7 +181,7 @@ export default { ); }, canLock() { - const { pushCode, downloadCode } = this.project.userPermissions; + const { pushCode, downloadCode } = this.userPermissions; const currentUsername = window.gon?.current_username; if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) { @@ -173,12 +191,12 @@ export default { return pushCode && downloadCode; }, pathLockedByUser() { - const pathLock = this.project?.pathLocks?.nodes.find((node) => node.path === this.path); + const pathLock = this.pathLocks?.nodes.find((node) => node.path === this.path); return pathLock ? pathLock.user : null; }, showForkSuggestion() { - const { createMergeRequestIn, forkProject } = this.project.userPermissions; + const { createMergeRequestIn, forkProject } = this.userPermissions; const { canModifyBlob } = this.blobInfo; return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject; @@ -338,7 +356,7 @@ export default { :name="blobInfo.name" :replace-path="blobInfo.replacePath" :delete-path="blobInfo.webPath" - :can-push-code="project.userPermissions.pushCode" + :can-push-code="userPermissions.pushCode" :can-push-to-branch="blobInfo.canCurrentUserPushToBranch" :empty-repo="project.repository.empty" :project-path="projectPath" diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue index 3223ed92fe2..fb1227f0df9 100644 --- a/app/assets/javascripts/repository/components/blob_controls.vue +++ b/app/assets/javascripts/repository/components/blob_controls.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <div v-if="showBlobControls"> + <div v-if="showBlobControls" class="gl-display-flex gl-gap-3"> <gl-button data-testid="find" :href="blobInfo.findFilePath" :class="$options.buttonClassList"> {{ $options.i18n.findFile }} </gl-button> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 20888db80a9..46dee9db69a 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -191,7 +191,7 @@ export default { href: `${this.newBlobPath}/${ this.currentPath ? encodeURIComponent(this.currentPath) : '' }`, - class: 'qa-new-file-option', + 'data-qa-selector': 'new_file_menu_item', }, text: __('New file'), }, @@ -300,7 +300,11 @@ export default { </router-link> </li> <li v-if="renderAddToTreeDropdown" class="breadcrumb-item"> - <gl-dropdown toggle-class="add-to-tree qa-add-to-tree gl-ml-2"> + <gl-dropdown + toggle-class="add-to-tree gl-ml-2" + data-testid="add-to-tree" + data-qa-selector="add_to_tree_dropdown" + > <template #button-content> <span class="sr-only">{{ __('Add to tree') }}</span> <gl-icon name="plus" :size="16" class="float-left" /> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 7f408485326..22fe3fe440e 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -135,7 +135,7 @@ export default { <div class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0" > - <div class="commit-content qa-commit-content"> + <div class="commit-content" data-qa-selector="commit_content"> <gl-link v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" :href="commit.webPath" @@ -148,6 +148,7 @@ export default { :class="{ open: showDescription }" :title="__('Toggle commit description')" :aria-label="__('Toggle commit description')" + :selected="showDescription" class="text-expander gl-vertical-align-bottom!" icon="ellipsis_h" @click="toggleShowDescription" diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 1f6b5e98122..99eb167172b 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -2,6 +2,7 @@ import { GlSkeletonLoader, GlButton } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; import getRefMixin from '../../mixins/get_ref'; import projectPathQuery from '../../queries/project_path.query.graphql'; import TableHeader from './header.vue'; @@ -108,7 +109,9 @@ export default { return {}; } - return this.commits.find((commitEntry) => commitEntry.fileName === fileName); + return this.commits.find( + (commitEntry) => commitEntry.filePath === joinPaths(this.path, fileName), + ); }, }, }; diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 99b7395d6e7..c8cd64b5311 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -17,8 +17,8 @@ import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import getRefMixin from '../../mixins/get_ref'; -import blobInfoQuery from '../../queries/blob_info.query.graphql'; import commitQuery from '../../queries/commit.query.graphql'; export default { @@ -244,7 +244,7 @@ export default { /><span class="position-relative">{{ fullPath }}</span> </component> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-qa-selector="label-lfs" + <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-testid="label-lfs" >LFS</gl-badge > <!-- eslint-enable @gitlab/vue-require-i18n-strings --> diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 9345a8406e3..a5bcd9e6b5e 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -1,6 +1,7 @@ import produce from 'immer'; import { normalizeData } from 'ee_else_ce/repository/utils/commit'; import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import commitsQuery from './queries/commits.query.graphql'; import projectPathQuery from './queries/project_path.query.graphql'; import refQuery from './queries/ref.query.graphql'; @@ -16,7 +17,7 @@ function setNextOffset(offset) { } export function resolveCommit(commits, path, { resolve, entry }) { - const commit = commits.find((c) => c.filePath === `${path}/${entry.name}`); + const commit = commits.find((c) => c.filePath === joinPaths(path, entry.name)); if (commit) { resolve(commit); diff --git a/app/assets/javascripts/repository/queries/project_info.query.graphql b/app/assets/javascripts/repository/queries/project_info.query.graphql new file mode 100644 index 00000000000..7a380d25bb1 --- /dev/null +++ b/app/assets/javascripts/repository/queries/project_info.query.graphql @@ -0,0 +1,14 @@ +#import "ee_else_ce/repository/queries/path_locks.fragment.graphql" + +query getProjectInfo($projectPath: ID!) { + project(fullPath: $projectPath) { + id + userPermissions { + pushCode + downloadCode + createMergeRequestIn + forkProject + } + ...ProjectPathLocksFragment + } +} diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js index 878b4fdd71a..247e30d20fc 100644 --- a/app/assets/javascripts/repository/utils/commit.js +++ b/app/assets/javascripts/repository/utils/commit.js @@ -1,3 +1,5 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + export function normalizeData(data, path, extra = () => {}) { return data.map((d) => ({ sha: d.commit.id, @@ -6,7 +8,7 @@ export function normalizeData(data, path, extra = () => {}) { committedDate: d.commit.committed_date, commitPath: d.commit_path, fileName: d.file_name, - filePath: `${path}/${d.file_name}`, + filePath: joinPaths(path, d.file_name), __typename: 'LogTreeCommit', ...extra(d), })); diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 0b6c5063129..7b5babdd3a6 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -6,6 +6,7 @@ export * from './api/bulk_imports_api'; export * from './api/namespaces_api'; export * from './api/tags_api'; export * from './api/alert_management_alerts_api'; +export * from './api/harbor_registry'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 777a332333d..f5620876783 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -15,6 +15,7 @@ import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.gr import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; +import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue'; @@ -37,6 +38,7 @@ export default { components: { GlLink, RegistrationDropdown, + RunnerStackedLayoutBanner, RunnerFilteredSearchBar, RunnerBulkDelete, RunnerBulkDeleteCheckbox, @@ -169,6 +171,8 @@ export default { </script> <template> <div> + <runner-stacked-layout-banner /> + <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > diff --git a/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue new file mode 100644 index 00000000000..e5d49eb7c8e --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue @@ -0,0 +1,112 @@ +<script> +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; + +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerName from '../runner_name.vue'; +import RunnerTags from '../runner_tags.vue'; +import RunnerTypeBadge from '../runner_type_badge.vue'; + +import { formatJobCount } from '../../utils'; +import { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_VERSION_LABEL, + I18N_LAST_CONTACT_LABEL, + I18N_CREATED_AT_LABEL, +} from '../../constants'; +import RunnerSummaryField from './runner_summary_field.vue'; + +export default { + components: { + GlIcon, + GlSprintf, + TimeAgo, + RunnerSummaryField, + RunnerName, + RunnerTags, + RunnerTypeBadge, + RunnerUpgradeStatusIcon: () => + import('ee_component/runner/components/runner_upgrade_status_icon.vue'), + TooltipOnTruncate, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + jobCount() { + return formatJobCount(this.runner.jobCount); + }, + }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_VERSION_LABEL, + I18N_LAST_CONTACT_LABEL, + I18N_CREATED_AT_LABEL, + }, +}; +</script> + +<template> + <div> + <div> + <slot :runner="runner" name="runner-name"> + <runner-name :runner="runner" /> + </slot> + <gl-icon + v-if="runner.locked" + v-gl-tooltip + :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + /> + <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" /> + </div> + + <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2"> + <div class="gl-flex-shrink-0"> + <runner-upgrade-status-icon :runner="runner" /> + <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL"> + <template #version>{{ runner.version }}</template> + </gl-sprintf> + </div> + <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> + <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> + {{ runner.description }} + </tooltip-on-truncate> + </div> + + <div> + <runner-summary-field icon="clock"> + <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + <template v-else>{{ __('Never') }}</template> + </template> + </gl-sprintf> + </runner-summary-field> + + <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')"> + {{ runner.ipAddress }} + </runner-summary-field> + + <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> + {{ jobCount }} + </runner-summary-field> + + <runner-summary-field icon="calendar"> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + </gl-sprintf> + </runner-summary-field> + </div> + + <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue index a48db9f8ac8..eb98d4ae2fb 100644 --- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue @@ -32,17 +32,14 @@ export default { <div> <runner-status-badge :runner="runner" - size="sm" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> <runner-upgrade-status-badge :runner="runner" - size="sm" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> <runner-paused-badge v-if="paused" - size="sm" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> </div> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue deleted file mode 100644 index 1cd098d6713..00000000000 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; - -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import RunnerName from '../runner_name.vue'; -import RunnerTypeBadge from '../runner_type_badge.vue'; - -import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../../constants'; - -export default { - components: { - GlIcon, - TooltipOnTruncate, - RunnerName, - RunnerTypeBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - runner: { - type: Object, - required: true, - }, - }, - computed: { - runnerType() { - return this.runner.runnerType; - }, - locked() { - return this.runner.locked; - }, - description() { - return this.runner.description; - }, - ipAddress() { - return this.runner.ipAddress; - }, - }, - i18n: { - I18N_LOCKED_RUNNER_DESCRIPTION, - }, -}; -</script> - -<template> - <div> - <slot :runner="runner" name="runner-name"> - <runner-name :runner="runner" /> - </slot> - - <runner-type-badge :type="runnerType" size="sm" /> - <gl-icon - v-if="locked" - v-gl-tooltip - :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" - name="lock" - /> - <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description"> - {{ description }} - </tooltip-on-truncate> - <tooltip-on-truncate - v-if="ipAddress" - class="gl-display-block gl-text-truncate" - :title="ipAddress" - > - <span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span> - <strong>{{ ipAddress }}</strong> - </tooltip-on-truncate> - </div> -</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue new file mode 100644 index 00000000000..1bbbd55089a --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue @@ -0,0 +1,33 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + icon: { + type: String, + required: false, + default: '', + }, + tooltip: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2"> + <gl-icon v-if="icon" :name="icon" /> + <!-- display tooltip as a label for screen readers --> + <span class="gl-sr-only">{{ tooltip }}</span> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue index 584f77b7648..c260670b517 100644 --- a/app/assets/javascripts/runner/components/runner_detail.vue +++ b/app/assets/javascripts/runner/components/runner_detail.vue @@ -21,7 +21,8 @@ export default { props: { label: { type: String, - required: true, + default: null, + required: false, }, value: { type: String, @@ -39,7 +40,11 @@ export default { <template> <div class="gl-display-contents"> - <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</dt> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26"> + <template v-if="label || $scopedSlots.label"> + <slot name="label">{{ label }}</slot> + </template> + </dt> <dd class="gl-mb-5"> <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index d5222f39b81..79f934764c6 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -1,7 +1,10 @@ <script> -import { GlIntersperse } from '@gitlab/ui'; +import { GlIntersperse, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; import RunnerDetail from './runner_detail.vue'; @@ -12,6 +15,8 @@ import RunnerTags from './runner_tags.vue'; export default { components: { GlIntersperse, + GlLink, + HelpPopover, RunnerDetail, RunnerMaintenanceNoteDetail: () => import('ee_component/runner/components/runner_maintenance_note_detail.vue'), @@ -24,6 +29,7 @@ export default { RunnerTags, TimeAgo, }, + mixins: [glFeatureFlagMixin()], props: { runner: { type: Object, @@ -60,6 +66,16 @@ export default { isProjectRunner() { return this.runner?.runnerType === PROJECT_TYPE; }, + tokenExpirationHelpPopoverOptions() { + return { + title: s__('Runners|Runner authentication token expiration'), + }; + }, + tokenExpirationHelpUrl() { + return helpPagePath('ci/runners/configure_runners', { + anchor: 'authentication-token-security', + }); + }, }, ACCESS_LEVEL_REF_PROTECTED, }; @@ -101,6 +117,34 @@ export default { </template> </runner-detail> <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> + <runner-detail + v-if="glFeatures.enforceRunnerTokenExpiresAt" + :empty-value="s__('Runners|Never expires')" + > + <template #label> + {{ s__('Runners|Token expiry') }} + <help-popover :options="tokenExpirationHelpPopoverOptions"> + <p> + {{ + s__( + 'Runners|Runner authentication tokens will expire based on a set interval. They will automatically rotate once expired.', + ) + }} + </p> + <p class="gl-mb-0"> + <gl-link + :href="tokenExpirationHelpUrl" + target="_blank" + class="gl-reset-font-size" + >{{ __('Learn more') }}</gl-link + > + </p> + </help-popover> + </template> + <template v-if="runner.tokenExpiresAt" #value> + <time-ago :time="runner.tokenExpiresAt" /> + </template> + </runner-detail> <runner-detail :label="s__('Runners|Tags')"> <template v-if="tagList.length" #value> <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" /> diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue index abc07cec1ad..874c234ca4c 100644 --- a/app/assets/javascripts/runner/components/runner_header.vue +++ b/app/assets/javascripts/runner/components/runner_header.vue @@ -38,31 +38,33 @@ export default { </script> <template> <div - class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" > - <div> + <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap"> <runner-status-badge :runner="runner" /> <runner-type-badge v-if="runner" :type="runner.runnerType" /> - <template v-if="runner.createdAt"> - <gl-sprintf :message="__('%{runner} created %{timeago}')"> - <template #runner> - <strong>{{ heading }}</strong> - <gl-icon - v-if="runner.locked" - v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - name="lock" - :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - /> - </template> - <template #timeago> - <time-ago :time="runner.createdAt" /> - </template> - </gl-sprintf> - </template> - <template v-else> - <strong>{{ heading }}</strong> - </template> + <span> + <template v-if="runner.createdAt"> + <gl-sprintf :message="__('%{runner} created %{timeago}')"> + <template #runner> + <strong>{{ heading }}</strong> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> + <template v-else> + <strong>{{ heading }}</strong> + </template> + </span> </div> - <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div> + <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 2e406f71792..26f1f3ce08c 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,24 +1,17 @@ <script> import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __, s__ } from '~/locale'; -import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { s__ } from '~/locale'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; import { formatJobCount, tableField } from '../utils'; -import RunnerSummaryCell from './cells/runner_summary_cell.vue'; +import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; -import RunnerTags from './runner_tags.vue'; const defaultFields = [ tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }), - tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), - tableField({ key: 'version', label: __('Version') }), - tableField({ key: 'jobCount', label: __('Jobs') }), - tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), - tableField({ key: 'contactedAt', label: __('Last contact') }), - tableField({ key: 'actions', label: '' }), + tableField({ key: 'summary', label: s__('Runners|Runner') }), + tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }), ]; export default { @@ -26,11 +19,8 @@ export default { GlFormCheckbox, GlTableLite, GlSkeletonLoader, - TooltipOnTruncate, - TimeAgo, RunnerStatusPopover, - RunnerSummaryCell, - RunnerTags, + RunnerStackedSummaryCell, RunnerStatusCell, }, directives: { @@ -74,6 +64,8 @@ export default { }; }, fields() { + const fields = defaultFields; + if (this.checkable) { const checkboxField = tableField({ key: 'checkbox', @@ -81,9 +73,9 @@ export default { thClasses: ['gl-w-9'], tdClass: ['gl-text-center'], }); - return [checkboxField, ...defaultFields]; + return [checkboxField, ...fields]; } - return defaultFields; + return fields; }, }, methods: { @@ -141,30 +133,11 @@ export default { </template> <template #cell(summary)="{ item, index }"> - <runner-summary-cell :runner="item"> + <runner-stacked-summary-cell :runner="item"> <template #runner-name="{ runner }"> <slot name="runner-name" :runner="runner" :index="index"></slot> </template> - </runner-summary-cell> - </template> - - <template #cell(version)="{ item: { version } }"> - <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version"> - {{ version }} - </tooltip-on-truncate> - </template> - - <template #cell(jobCount)="{ item: { jobCount } }"> - {{ formatJobCount(jobCount) }} - </template> - - <template #cell(tagList)="{ item: { tagList } }"> - <runner-tags :tag-list="tagList" size="sm" /> - </template> - - <template #cell(contactedAt)="{ item: { contactedAt } }"> - <time-ago v-if="contactedAt" :time="contactedAt" /> - <template v-else>{{ __('Never') }}</template> + </runner-stacked-summary-cell> </template> <template #cell(actions)="{ item }"> diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/runner/components/runner_name.vue index 8e495125e03..d4ecfd2d776 100644 --- a/app/assets/javascripts/runner/components/runner_name.vue +++ b/app/assets/javascripts/runner/components/runner_name.vue @@ -14,5 +14,7 @@ export default { }; </script> <template> - <span>#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span> + <span class="gl-font-weight-bold gl-vertical-align-middle" + >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span + > </template> diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue index 27618290ce6..00fd84a48d8 100644 --- a/app/assets/javascripts/runner/components/runner_paused_badge.vue +++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue @@ -1,6 +1,6 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { I18N_PAUSED_DESCRIPTION } from '../constants'; +import { I18N_PAUSED, I18N_PAUSED_DESCRIPTION } from '../constants'; export default { components: { @@ -9,11 +9,17 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + I18N_PAUSED, I18N_PAUSED_DESCRIPTION, }; </script> <template> - <gl-badge v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" variant="danger" v-bind="$attrs"> - {{ s__('Runners|paused') }} + <gl-badge + v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" + variant="warning" + icon="status-paused" + v-bind="$attrs" + > + {{ $options.I18N_PAUSED }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index 2c1d2fc2b10..84008e8eee8 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -1,11 +1,13 @@ <script> -import { GlSkeletonLoader } from '@gitlab/ui'; +import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; import { sprintf, formatNumber } from '~/locale'; import { createAlert } from '~/flash'; import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql'; import { I18N_ASSIGNED_PROJECTS, - I18N_NONE, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, I18N_FETCH_ERROR, RUNNER_DETAILS_PROJECTS_PAGE_SIZE, } from '../constants'; @@ -14,9 +16,12 @@ import { captureException } from '../sentry_utils'; import RunnerAssignedItem from './runner_assigned_item.vue'; import RunnerPagination from './runner_pagination.vue'; +const SHORT_SEARCH_LENGTH = 3; + export default { name: 'RunnerProjects', components: { + GlSearchBoxByType, GlSkeletonLoader, RunnerAssignedItem, RunnerPagination, @@ -35,6 +40,7 @@ export default { pageInfo: {}, count: 0, }, + search: '', pagination: {}, }; }, @@ -61,9 +67,10 @@ export default { }, computed: { variables() { - const { id } = this.runner; + const { search, runner } = this; return { - id, + id: runner.id, + search: search.length >= SHORT_SEARCH_LENGTH ? search : '', ...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE), }; }, @@ -80,22 +87,38 @@ export default { isOwner(projectId) { return projectId === this.projects.ownerProjectId; }, + onSearchInput(search) { + this.search = search; + this.pagination = {}; + }, onPaginationInput(value) { this.pagination = value; }, }, - I18N_NONE, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, }; </script> <template> <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> - <h3 class="gl-font-lg gl-mt-5 gl-mb-0"> + <h3 class="gl-font-lg gl-mt-5"> {{ heading }} </h3> + <gl-search-box-by-type + :is-loading="loading" + :clear-button-title="$options.I18N_CLEAR_FILTER_PROJECTS" + :placeholder="$options.I18N_FILTER_PROJECTS" + debounce="500" + class="gl-w-28" + :value="search" + @input="onSearchInput" + /> - <div v-if="loading" class="gl-py-5"> - <gl-skeleton-loader /> + <div v-if="!projects.items.length && loading" class="gl-py-5"> + <gl-skeleton-loader v-for="i in $options.RUNNER_DETAILS_PROJECTS_PAGE_SIZE" :key="i" /> </div> <template v-else-if="projects.items.length"> <runner-assigned-item @@ -110,7 +133,7 @@ export default { :is-owner="isOwner(project.id)" /> </template> - <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span> + <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> <runner-pagination :disabled="loading" diff --git a/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue new file mode 100644 index 00000000000..e3a9a9fd8a4 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue @@ -0,0 +1,58 @@ +<script> +import allChangesCommittedSvg from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg'; +import { GlBanner } from '@gitlab/ui'; + +import { s__ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +const I18N_TITLE = s__("Runners|We've made some changes and want your feedback"); +const I18N_DESCRIPTION = s__( + "Runners|We want you to be able to manage your runners easily and efficiently from this page, and we are making changes to get there. Give us feedback on how we're doing!", +); +const I18N_LINK = s__('Runners|Add your feedback in the issue'); + +// use a data url instead getting it from via HTML data-* attributes to simplify removal of this feature flag +const ILLUSTRATION_URL = `data:image/svg+xml;utf8,${encodeURIComponent(allChangesCommittedSvg)}`; +const ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/371621'; +const STORAGE_KEY = 'runner_list_stacked_layout_feedback_dismissed'; + +export default { + components: { + GlBanner, + LocalStorageSync, + }, + data() { + return { + isDismissed: false, + }; + }, + methods: { + onClose() { + this.isDismissed = true; + }, + }, + I18N_TITLE, + I18N_DESCRIPTION, + I18N_LINK, + ILLUSTRATION_URL, + ISSUE_URL, + STORAGE_KEY, +}; +</script> + +<template> + <div> + <local-storage-sync v-model="isDismissed" :storage-key="$options.STORAGE_KEY" /> + <gl-banner + v-if="!isDismissed" + :svg-path="$options.ILLUSTRATION_URL" + :title="$options.I18N_TITLE" + :button-text="$options.I18N_LINK" + :button-link="$options.ISSUE_URL" + class="gl-my-5" + @close="onClose" + > + <p>{{ $options.I18N_DESCRIPTION }}</p> + </gl-banner> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index 073d0a49f59..d084408781e 100644 --- a/app/assets/javascripts/runner/components/runner_status_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -1,8 +1,12 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { __, s__, sprintf } from '~/locale'; +import { __, sprintf } from '~/locale'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, I18N_ONLINE_TIMEAGO_TOOLTIP, I18N_NEVER_CONTACTED_TOOLTIP, I18N_OFFLINE_TIMEAGO_TOOLTIP, @@ -39,26 +43,30 @@ export default { switch (this.runner?.status) { case STATUS_ONLINE: return { + icon: 'status-active', variant: 'success', - label: s__('Runners|online'), + label: I18N_STATUS_ONLINE, tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP), }; case STATUS_NEVER_CONTACTED: return { + icon: 'time-out', variant: 'muted', - label: s__('Runners|never contacted'), + label: I18N_STATUS_NEVER_CONTACTED, tooltip: I18N_NEVER_CONTACTED_TOOLTIP, }; case STATUS_OFFLINE: return { + icon: 'time-out', variant: 'muted', - label: s__('Runners|offline'), + label: I18N_STATUS_OFFLINE, tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP), }; case STATUS_STALE: return { + icon: 'time-out', variant: 'warning', - label: s__('Runners|stale'), + label: I18N_STATUS_STALE, // runner may have contacted (or not) and be stale: consider both cases. tooltip: this.runner.contactedAt ? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP) @@ -77,7 +85,13 @@ export default { }; </script> <template> - <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs"> + <gl-badge + v-if="badge" + v-gl-tooltip="badge.tooltip" + :variant="badge.variant" + :icon="badge.icon" + v-bind="$attrs" + > {{ badge.label }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue index 797d2a35b2c..38e566f9f53 100644 --- a/app/assets/javascripts/runner/components/runner_tags.vue +++ b/app/assets/javascripts/runner/components/runner_tags.vue @@ -20,7 +20,7 @@ export default { }; </script> <template> - <span> + <span v-if="tagList && tagList.length"> <runner-tag v-for="tag in tagList" :key="tag" diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue index b885dcefdcb..f568f914004 100644 --- a/app/assets/javascripts/runner/components/runner_type_badge.vue +++ b/app/assets/javascripts/runner/components/runner_type_badge.vue @@ -1,26 +1,31 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + I18N_INSTANCE_TYPE, I18N_INSTANCE_RUNNER_DESCRIPTION, + I18N_GROUP_TYPE, I18N_GROUP_RUNNER_DESCRIPTION, + I18N_PROJECT_TYPE, I18N_PROJECT_RUNNER_DESCRIPTION, } from '../constants'; const BADGE_DATA = { [INSTANCE_TYPE]: { - text: s__('Runners|shared'), + icon: 'users', + text: I18N_INSTANCE_TYPE, tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION, }, [GROUP_TYPE]: { - text: s__('Runners|group'), + icon: 'group', + text: I18N_GROUP_TYPE, tooltip: I18N_GROUP_RUNNER_DESCRIPTION, }, [PROJECT_TYPE]: { - text: s__('Runners|specific'), + icon: 'project', + text: I18N_PROJECT_TYPE, tooltip: I18N_PROJECT_RUNNER_DESCRIPTION, }, }; @@ -50,7 +55,13 @@ export default { }; </script> <template> - <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" variant="info" v-bind="$attrs"> + <gl-badge + v-if="badge" + v-gl-tooltip="badge.tooltip" + variant="muted" + :icon="badge.icon" + v-bind="$attrs" + > {{ badge.text }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js index c1ad5da3ab9..97ee8ec3eef 100644 --- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js @@ -1,7 +1,7 @@ -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { PARAM_KEY_PAUSED } from '../../constants'; +import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; const options = [ { value: 'true', title: __('Yes') }, @@ -10,7 +10,7 @@ const options = [ export const pausedTokenConfig = { icon: 'pause', - title: s__('Runners|Paused'), + title: I18N_PAUSED, type: PARAM_KEY_PAUSED, token: BaseToken, unique: true, diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 9e6f63d3f7c..f5c42d120fb 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -1,7 +1,11 @@ -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, STATUS_ONLINE, STATUS_OFFLINE, STATUS_NEVER_CONTACTED, @@ -10,10 +14,10 @@ import { } from '../../constants'; const options = [ - { value: STATUS_ONLINE, title: s__('Runners|Online') }, - { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, - { value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') }, - { value: STATUS_STALE, title: s__('Runners|Stale') }, + { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE }, + { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE }, + { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED }, + { value: STATUS_STALE, title: I18N_STATUS_STALE }, ]; export const statusTokenConfig = { diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue index 93e54ebe7f4..4df59f5a0c9 100644 --- a/app/assets/javascripts/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue @@ -1,7 +1,13 @@ <script> -import { s__ } from '~/locale'; import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue'; -import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, +} from '../../constants'; export default { components: { @@ -29,8 +35,8 @@ export default { skip: this.statusCountSkip(STATUS_ONLINE), variables: { ...this.variables, status: STATUS_ONLINE }, variant: 'success', - title: s__('Runners|Online runners'), - metaText: s__('Runners|online'), + title: I18N_STATUS_ONLINE, + metaIcon: 'status-active', }, }, { @@ -39,8 +45,8 @@ export default { skip: this.statusCountSkip(STATUS_OFFLINE), variables: { ...this.variables, status: STATUS_OFFLINE }, variant: 'muted', - title: s__('Runners|Offline runners'), - metaText: s__('Runners|offline'), + title: I18N_STATUS_OFFLINE, + metaIcon: 'status-waiting', }, }, { @@ -49,8 +55,8 @@ export default { skip: this.statusCountSkip(STATUS_STALE), variables: { ...this.variables, status: STATUS_STALE }, variant: 'warning', - title: s__('Runners|Stale runners'), - metaText: s__('Runners|stale'), + title: I18N_STATUS_STALE, + metaIcon: 'time-out', }, }, ]; diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index ed1afcbf691..3009577599f 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -23,6 +23,12 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__( ); export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); +// Status +export const I18N_STATUS_ONLINE = s__('Runners|Online'); +export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted'); +export const I18N_STATUS_OFFLINE = s__('Runners|Offline'); +export const I18N_STATUS_STALE = s__('Runners|Stale'); + // Status help popover export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses'); @@ -62,6 +68,7 @@ export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__( export const I18N_EDIT = __('Edit'); export const I18N_PAUSE = __('Pause'); +export const I18N_PAUSED = s__('Runners|Paused'); export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs'); export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs'); @@ -77,20 +84,27 @@ export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__( ); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); +// List export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); +export const I18N_VERSION_LABEL = s__('Runners|Version %{version}'); +export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}'); +export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}'); // Runner details export const I18N_DETAILS = s__('Runners|Details'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); +export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects'); +export const I18N_CLEAR_FILTER_PROJECTS = __('Clear'); export const I18N_NONE = __('None'); export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.'); +export const I18N_NO_PROJECTS_FOUND = __('No projects found'); // Styles -export const RUNNER_TAG_BADGE_VARIANT = 'neutral'; +export const RUNNER_TAG_BADGE_VARIANT = 'info'; export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; // Filtered search parameter names diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql index ce23bddb898..a12ba7a751a 100644 --- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql @@ -9,6 +9,7 @@ fragment ListItemShared on CiRunner { locked jobCount tagList + createdAt contactedAt status(legacyMode: null) userPermissions { diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql index 499c0156770..b5689ff7687 100644 --- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql @@ -17,6 +17,7 @@ fragment RunnerDetailsShared on CiRunner { createdAt status(legacyMode: null) contactedAt + tokenExpiresAt version editAdminUrl userPermissions { diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql index acc4a641565..e42648b3079 100644 --- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql @@ -2,6 +2,7 @@ query getRunnerProjects( $id: CiRunnerID! + $search: String $first: Int $last: Int $before: String @@ -13,7 +14,7 @@ query getRunnerProjects( id } projectCount - projects(first: $first, last: $last, before: $before, after: $after) { + projects(search: $search, first: $first, last: $last, before: $before, after: $after) { nodes { id avatarUrl diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js index 62a0dab9211..e75f337b38e 100644 --- a/app/assets/javascripts/runner/group_runner_show/index.js +++ b/app/assets/javascripts/runner/group_runner_show/index.js @@ -1,11 +1,14 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import GroupRunnerShowApp from './group_runner_show_app.vue'; Vue.use(VueApollo); export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { + showAlertFromLocalStorage(); + const el = document.querySelector(selector); if (!el) { diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index a82411a2120..70826a6bfa1 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -13,6 +13,7 @@ import { import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; +import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; @@ -37,6 +38,7 @@ export default { components: { GlLink, RegistrationDropdown, + RunnerStackedLayoutBanner, RunnerFilteredSearchBar, RunnerList, RunnerListEmptyState, @@ -50,7 +52,8 @@ export default { props: { registrationToken: { type: String, - required: true, + required: false, + default: null, }, groupFullPath: { type: String, @@ -178,6 +181,8 @@ export default { <template> <div> + <runner-stacked-layout-banner /> + <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs ref="runner-type-tabs" @@ -191,6 +196,7 @@ export default { /> <registration-dropdown + v-if="registrationToken" class="gl-ml-auto" :registration-token="registrationToken" :type="$options.GROUP_TYPE" diff --git a/app/assets/javascripts/runner/admin_runner_edit/index.js b/app/assets/javascripts/runner/runner_edit/index.js index a2ac5731a62..5b2ddb8f68e 100644 --- a/app/assets/javascripts/runner/admin_runner_edit/index.js +++ b/app/assets/javascripts/runner/runner_edit/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import AdminRunnerEditApp from './admin_runner_edit_app.vue'; +import RunnerEditApp from './runner_edit_app.vue'; Vue.use(VueApollo); -export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => { +export const initRunnerEdit = (selector) => { const el = document.querySelector(selector); if (!el) { @@ -22,7 +22,7 @@ export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => { el, apolloProvider, render(h) { - return h(AdminRunnerEditApp, { + return h(RunnerEditApp, { props: { runnerId, runnerPath, diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue index 40787cf72da..879162916a9 100644 --- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue +++ b/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue @@ -9,7 +9,7 @@ import runnerFormQuery from '../graphql/edit/runner_form.query.graphql'; import { captureException } from '../sentry_utils'; export default { - name: 'AdminRunnerEditApp', + name: 'RunnerEditApp', components: { RunnerHeader, RunnerUpdateForm, diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js index cb2917a92fd..1ca0a9e86b5 100644 --- a/app/assets/javascripts/runner/utils.js +++ b/app/assets/javascripts/runner/utils.js @@ -70,3 +70,14 @@ export const getPaginationVariables = (pagination, pageSize = 10) => { // Get the first N items return { first: pageSize }; }; + +/** + * Turns a server-provided interval integer represented as a string into an + * integer that the frontend can use. + * + * @param {String} interval - String to convert + * @returns Parsed integer + */ +export const parseInterval = (interval) => { + return typeof interval === 'string' ? parseInt(interval, 10) : null; +}; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index d9d4056466a..446ab7f433c 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,11 +1,11 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import { queryToObject } from '~/lib/utils/url_utility'; -import Project from '~/pages/projects/project'; import refreshCounts from '~/pages/search/show/refresh_counts'; import { initSidebar } from './sidebar'; import { initSearchSort } from './sort'; import createStore from './store'; import { initTopbar } from './topbar'; +import { initBlobRefSwitcher } from './under_topbar'; export const initSearchApp = () => { const query = queryToObject(window.location.search); @@ -18,5 +18,5 @@ export const initSearchApp = () => { setHighlightClass(query.search); // Code Highlighting refreshCounts(); // Other Scope Tab Counts - Project.initRefSwitcher(); // Code Search Branch Picker + initBlobRefSwitcher(); // Code Search Branch Picker }; diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue index 5653cddda60..ff639d538b3 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -144,9 +144,9 @@ export default { /> <gl-dropdown-item class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" - :is-check-item="true" + is-check-item :is-checked="isSelected($options.ANY_OPTION)" - :is-check-centered="true" + is-check-centered @click="updateDropdown($options.ANY_OPTION)" > <span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue index a4254a355a2..70156142365 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -53,9 +53,9 @@ export default { <template> <gl-dropdown-item - :is-check-item="true" + is-check-item :is-checked="isSelected" - :is-check-centered="true" + is-check-centered @click="$emit('change', item)" > <div class="gl-display-flex gl-align-items-center"> diff --git a/app/assets/javascripts/search/under_topbar/index.js b/app/assets/javascripts/search/under_topbar/index.js new file mode 100644 index 00000000000..8e50c6655dd --- /dev/null +++ b/app/assets/javascripts/search/under_topbar/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; + +Vue.use(Translate); + +export const initBlobRefSwitcher = () => { + const el = document.getElementById('js-blob-ref-switcher'); + + if (!el) return false; + + const { projectId, ref, fieldName } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selected) { + visitUrl(setUrlParams({ [fieldName]: selected })); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 5a9ef832e05..77216408c39 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -157,7 +157,7 @@ export const SCANNER_NAMES_MAP = { COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, SECRET_DETECTION: SECRET_DETECTION_NAME, DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, - GENERIC: s__('ciReport|Manually Added'), + GENERIC: s__('ciReport|Manually added'), }; export const securityFeatures = [ diff --git a/app/assets/javascripts/set_status_modal/constants.js b/app/assets/javascripts/set_status_modal/constants.js new file mode 100644 index 00000000000..53e64db1497 --- /dev/null +++ b/app/assets/javascripts/set_status_modal/constants.js @@ -0,0 +1,14 @@ +import { timeRanges } from '~/vue_shared/constants'; +import { __ } from '~/locale'; + +export const NEVER_TIME_RANGE = { + label: __('Never'), + name: 'never', +}; + +export const TIME_RANGES_WITH_NEVER = [NEVER_TIME_RANGE, ...timeRanges]; + +export const AVAILABILITY_STATUS = { + BUSY: 'busy', + NOT_SET: 'not_set', +}; diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue new file mode 100644 index 00000000000..7f9a30b7ff1 --- /dev/null +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -0,0 +1,231 @@ +<script> +import { + GlButton, + GlTooltipDirective, + GlIcon, + GlFormCheckbox, + GlFormInput, + GlFormInputGroup, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlFormGroup, + GlSafeHtmlDirective, +} from '@gitlab/ui'; +import $ from 'jquery'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +import * as Emoji from '~/emoji'; +import { s__ } from '~/locale'; +import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS } from './constants'; + +export default { + components: { + GlButton, + GlIcon, + GlFormCheckbox, + GlFormInput, + GlFormInputGroup, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlFormGroup, + EmojiPicker: () => import('~/emoji/components/picker.vue'), + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, + }, + props: { + defaultEmoji: { + type: String, + required: false, + default: '', + }, + emoji: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + availability: { + type: Boolean, + required: true, + }, + clearStatusAfter: { + type: Object, + required: false, + default: () => ({}), + }, + currentClearStatusAfter: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + defaultEmojiTag: '', + emojiTag: '', + }; + }, + computed: { + isCustomEmoji() { + return this.emoji !== this.defaultEmoji; + }, + isDirty() { + return Boolean(this.message.length || this.isCustomEmoji); + }, + noEmoji() { + return this.emojiTag === ''; + }, + }, + mounted() { + this.setupEmojiListAndAutocomplete(); + }, + methods: { + async setupEmojiListAndAutocomplete() { + const emojiAutocomplete = new GfmAutoComplete(); + emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true }); + + if (this.emoji) { + this.emojiTag = Emoji.glEmojiTag(this.emoji); + } + this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); + + this.setDefaultEmoji(); + }, + setDefaultEmoji() { + const { emojiTag } = this; + const hasStatusMessage = Boolean(this.message.length); + if (hasStatusMessage && emojiTag) { + return; + } + + if (hasStatusMessage) { + this.emojiTag = this.defaultEmojiTag; + } else if (emojiTag === this.defaultEmojiTag) { + this.clearEmoji(); + } + }, + handleEmojiClick(emoji) { + this.$emit('emoji-click', emoji); + + this.emojiTag = Emoji.glEmojiTag(emoji); + }, + clearEmoji() { + if (this.emojiTag) { + this.emojiTag = ''; + } + }, + clearStatusInputs() { + this.$emit('emoji-click', ''); + this.$emit('message-input', ''); + this.clearEmoji(); + }, + }, + TIME_RANGES_WITH_NEVER, + AVAILABILITY_STATUS, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + i18n: { + statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`), + clearStatusButtonLabel: s__('SetStatusModal|Clear status'), + availabilityCheckboxLabel: s__('SetStatusModal|Busy'), + availabilityCheckboxHelpText: s__( + 'SetStatusModal|An indicator appears next to your name and avatar', + ), + clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'), + clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'), + }, +}; +</script> + +<template> + <div> + <gl-form-input-group class="gl-mb-5"> + <gl-form-input + ref="statusMessageField" + :value="message" + :placeholder="$options.i18n.statusMessagePlaceholder" + @keyup="setDefaultEmoji" + @input="$emit('message-input', $event)" + @keyup.enter.prevent + /> + <template #prepend> + <emoji-picker + dropdown-class="gl-h-full" + toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + boundary="viewport" + :right="false" + @click="handleEmojiClick" + > + <template #button-content> + <span + v-if="noEmoji" + class="no-emoji-placeholder position-relative" + data-testid="no-emoji-placeholder" + > + <gl-icon name="slight-smile" class="award-control-icon-neutral" /> + <gl-icon name="smiley" class="award-control-icon-positive" /> + <gl-icon name="smile" class="award-control-icon-super-positive" /> + </span> + <span v-else> + <span + v-safe-html:[$options.safeHtmlConfig]="emojiTag" + data-testid="selected-emoji" + ></span> + </span> + </template> + </emoji-picker> + </template> + <template v-if="isDirty" #append> + <gl-button + v-gl-tooltip.bottom + :title="$options.i18n.clearStatusButtonLabel" + :aria-label="$options.i18n.clearStatusButtonLabel" + icon="close" + class="js-clear-user-status-button" + @click="clearStatusInputs" + /> + </template> + </gl-form-input-group> + + <gl-form-checkbox + :checked="availability" + class="gl-mb-5" + data-testid="user-availability-checkbox" + @input="$emit('availability-input', $event)" + > + {{ $options.i18n.availabilityCheckboxLabel }} + <template #help> + {{ $options.i18n.availabilityCheckboxHelpText }} + </template> + </gl-form-checkbox> + + <gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0"> + <gl-dropdown + block + :text="clearStatusAfter.label" + data-testid="clear-status-at-dropdown" + toggle-class="gl-mb-0 gl-form-input-md" + > + <gl-dropdown-item + v-for="after in $options.TIME_RANGES_WITH_NEVER" + :key="after.name" + :data-testid="after.name" + @click="$emit('clear-status-after-click', after)" + >{{ after.label }}</gl-dropdown-item + > + </gl-dropdown> + + <template v-if="currentClearStatusAfter.length" #description> + <span data-testid="clear-status-at-message"> + <gl-sprintf :message="$options.i18n.clearStatusAfterMessage"> + <template #date>{{ currentClearStatusAfter }}</template> + </gl-sprintf> + </span> + </template> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 2cdec8fc481..80b1cb8c4d5 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -1,55 +1,21 @@ <script> -import { - GlButton, - GlToast, - GlModal, - GlTooltipDirective, - GlIcon, - GlFormCheckbox, - GlFormInput, - GlFormInputGroup, - GlDropdown, - GlDropdownItem, - GlSafeHtmlDirective, -} from '@gitlab/ui'; -import $ from 'jquery'; +import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui'; import Vue from 'vue'; -import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import * as Emoji from '~/emoji'; import createFlash from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { __, s__, sprintf } from '~/locale'; +import { s__ } from '~/locale'; import { updateUserStatus } from '~/rest_api'; -import { timeRanges } from '~/vue_shared/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isUserBusy } from './utils'; - -export const AVAILABILITY_STATUS = { - BUSY: 'busy', - NOT_SET: 'not_set', -}; +import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants'; +import SetStatusForm from './set_status_form.vue'; Vue.use(GlToast); -const statusTimeRanges = [ - { - label: __('Never'), - name: 'never', - }, - ...timeRanges, -]; - export default { components: { - GlButton, - GlIcon, GlModal, - GlFormCheckbox, - GlFormInput, - GlFormInputGroup, - GlDropdown, - GlDropdownItem, - EmojiPicker: () => import('~/emoji/components/picker.vue'), + SetStatusForm, }, directives: { GlTooltip: GlTooltipDirective, @@ -85,26 +51,12 @@ export default { return { defaultEmojiTag: '', emoji: this.currentEmoji, - emojiMenu: null, - emojiTag: '', message: this.currentMessage, modalId: 'set-user-status-modal', - noEmoji: true, availability: isUserBusy(this.currentAvailability), - clearStatusAfter: statusTimeRanges[0], - clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), { - date: this.currentClearStatusAfter, - }), + clearStatusAfter: NEVER_TIME_RANGE, }; }, - computed: { - isCustomEmoji() { - return this.emoji !== this.defaultEmoji; - }, - isDirty() { - return Boolean(this.message.length || this.isCustomEmoji); - }, - }, mounted() { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, @@ -112,62 +64,10 @@ export default { closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, - setupEmojiListAndAutocomplete() { - const emojiAutocomplete = new GfmAutoComplete(); - emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true }); - - Emoji.initEmojiMap() - .then(() => { - if (this.emoji) { - this.emojiTag = Emoji.glEmojiTag(this.emoji); - } - this.noEmoji = this.emoji === ''; - this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); - - this.setDefaultEmoji(); - }) - .catch(() => - createFlash({ - message: __('Failed to load emoji list.'), - }), - ); - }, - setDefaultEmoji() { - const { emojiTag } = this; - const hasStatusMessage = Boolean(this.message.length); - if (hasStatusMessage && emojiTag) { - return; - } - - if (hasStatusMessage) { - this.noEmoji = false; - this.emojiTag = this.defaultEmojiTag; - } else if (emojiTag === this.defaultEmojiTag) { - this.noEmoji = true; - this.clearEmoji(); - } - }, - setEmoji(emoji) { - this.emoji = emoji; - this.noEmoji = false; - this.clearEmoji(); - - this.emojiTag = Emoji.glEmojiTag(this.emoji); - }, - clearEmoji() { - if (this.emojiTag) { - this.emojiTag = ''; - } - }, - clearStatusInputs() { - this.emoji = ''; - this.message = ''; - this.noEmoji = true; - this.clearEmoji(); - }, removeStatus() { this.availability = false; - this.clearStatusInputs(); + this.emoji = ''; + this.message = ''; this.setStatus(); }, setStatus() { @@ -178,7 +78,7 @@ export default { message, availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, clearStatusAfter: - clearStatusAfter.label === statusTimeRanges[0].label ? null : clearStatusAfter.shortcut, + clearStatusAfter.label === NEVER_TIME_RANGE.label ? null : clearStatusAfter.shortcut, }) .then(this.onUpdateSuccess) .catch(this.onUpdateFail); @@ -197,11 +97,19 @@ export default { this.closeModal(); }, - setClearStatusAfter(after) { + handleMessageInput(value) { + this.message = value; + }, + handleEmojiClick(emoji) { + this.emoji = emoji; + }, + handleClearStatusAfterClick(after) { this.clearStatusAfter = after; }, + handleAvailabilityInput(value) { + this.availability = value; + }, }, - statusTimeRanges, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, actionPrimary: { text: s__('SetStatusModal|Set status') }, actionSecondary: { text: s__('SetStatusModal|Remove status') }, @@ -215,85 +123,20 @@ export default { :action-primary="$options.actionPrimary" :action-secondary="$options.actionSecondary" modal-class="set-user-status-modal" - @shown="setupEmojiListAndAutocomplete" @primary="setStatus" @secondary="removeStatus" > - <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" /> - <gl-form-input-group class="gl-mb-5"> - <gl-form-input - ref="statusMessageField" - v-model="message" - :placeholder="s__(`SetStatusModal|What's your status?`)" - class="js-status-message-field" - name="user[status][message]" - @keyup="setDefaultEmoji" - @keyup.enter.prevent - /> - <template #prepend> - <emoji-picker - dropdown-class="gl-h-full" - toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - boundary="viewport" - :right="false" - @click="setEmoji" - > - <template #button-content> - <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span> - <span - v-show="noEmoji" - class="js-no-emoji-placeholder no-emoji-placeholder position-relative" - > - <gl-icon name="slight-smile" class="award-control-icon-neutral" /> - <gl-icon name="smiley" class="award-control-icon-positive" /> - <gl-icon name="smile" class="award-control-icon-super-positive" /> - </span> - </template> - </emoji-picker> - </template> - <template v-if="isDirty" #append> - <gl-button - v-gl-tooltip.bottom - :title="s__('SetStatusModal|Clear status')" - :aria-label="s__('SetStatusModal|Clear status')" - icon="close" - class="js-clear-user-status-button" - @click="clearStatusInputs" - /> - </template> - </gl-form-input-group> - - <gl-form-checkbox - v-model="availability" - class="gl-mb-5" - data-testid="user-availability-checkbox" - > - {{ s__('SetStatusModal|Busy') }} - <template #help> - {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }} - </template> - </gl-form-checkbox> - - <div class="form-group"> - <div class="gl-display-flex gl-align-items-baseline"> - <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span> - <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown"> - <gl-dropdown-item - v-for="after in $options.statusTimeRanges" - :key="after.name" - :data-testid="after.name" - @click="setClearStatusAfter(after)" - >{{ after.label }}</gl-dropdown-item - > - </gl-dropdown> - </div> - <div - v-if="currentClearStatusAfter.length" - class="gl-mt-3 gl-text-gray-400 gl-font-sm" - data-testid="clear-status-at-message" - > - {{ clearStatusAfterMessage }} - </div> - </div> + <set-status-form + :default-emoji="defaultEmoji" + :emoji="emoji" + :message="message" + :availability="availability" + :clear-status-after="clearStatusAfter" + :current-clear-status-after="currentClearStatusAfter" + @message-input="handleMessageInput" + @emoji-click="handleEmojiClick" + @clear-status-after-click="handleClearStatusAfterClick" + @availability-input="handleAvailabilityInput" + /> </gl-modal> </template> diff --git a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue new file mode 100644 index 00000000000..c709611e13d --- /dev/null +++ b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue @@ -0,0 +1,100 @@ +<script> +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; +import dateFormat from '~/lib/dateformat'; +import SetStatusForm from './set_status_form.vue'; +import { isUserBusy } from './utils'; +import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants'; + +export default { + components: { SetStatusForm }, + inject: ['fields'], + data() { + return { + emoji: this.fields.emoji.value, + message: this.fields.message.value, + availability: isUserBusy(this.fields.availability.value), + clearStatusAfter: NEVER_TIME_RANGE, + currentClearStatusAfter: this.fields.clearStatusAfter.value, + }; + }, + computed: { + clearStatusAfterInputValue() { + return this.clearStatusAfter.label === NEVER_TIME_RANGE.label + ? null + : this.clearStatusAfter.shortcut; + }, + availabilityInputValue() { + return this.availability + ? this.$options.AVAILABILITY_STATUS.BUSY + : this.$options.AVAILABILITY_STATUS.NOT_SET; + }, + }, + mounted() { + this.$options.formEl = document.querySelector('form.js-edit-user'); + + if (!this.$options.formEl) return; + + this.$options.formEl.addEventListener('ajax:success', this.handleFormSuccess); + }, + beforeDestroy() { + if (!this.$options.formEl) return; + + this.$options.formEl.removeEventListener('ajax:success', this.handleFormSuccess); + }, + methods: { + handleMessageInput(value) { + this.message = value; + }, + handleEmojiClick(emoji) { + this.emoji = emoji; + }, + handleClearStatusAfterClick(after) { + this.clearStatusAfter = after; + }, + handleAvailabilityInput(value) { + this.availability = value; + }, + handleFormSuccess() { + if (!this.clearStatusAfter?.duration?.seconds) { + this.currentClearStatusAfter = ''; + + return; + } + + const now = new Date(); + const currentClearStatusAfterDate = new Date( + now.getTime() + secondsToMilliseconds(this.clearStatusAfter.duration.seconds), + ); + + this.currentClearStatusAfter = dateFormat( + currentClearStatusAfterDate, + "UTC:yyyy-mm-dd HH:MM:ss 'UTC'", + ); + this.clearStatusAfter = NEVER_TIME_RANGE; + }, + }, + AVAILABILITY_STATUS, + formEl: null, +}; +</script> + +<template> + <div> + <input :value="emoji" type="hidden" :name="fields.emoji.name" /> + <input :value="message" type="hidden" :name="fields.message.name" /> + <input :value="availabilityInputValue" type="hidden" :name="fields.availability.name" /> + <input :value="clearStatusAfterInputValue" type="hidden" :name="fields.clearStatusAfter.name" /> + <set-status-form + default-emoji="speech_balloon" + :emoji="emoji" + :message="message" + :availability="availability" + :clear-status-after="clearStatusAfter" + :current-clear-status-after="currentClearStatusAfter" + @message-input="handleMessageInput" + @emoji-click="handleEmojiClick" + @clear-status-after-click="handleClearStatusAfterClick" + @availability-input="handleAvailabilityInput" + /> + </div> +</template> diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js index e17d95adb25..950091195d2 100644 --- a/app/assets/javascripts/set_status_modal/utils.js +++ b/app/assets/javascripts/set_status_modal/utils.js @@ -1,7 +1,4 @@ -export const AVAILABILITY_STATUS = { - BUSY: 'busy', - NOT_SET: 'not_set', -}; +import { AVAILABILITY_STATUS } from './constants'; export const isUserBusy = (status = '') => Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY); diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index a94dd128a1a..4408ebb881b 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -23,6 +23,10 @@ export default { required: false, default: false, }, + editable: { + type: Boolean, + required: true, + }, }, computed: { assigneesText() { @@ -43,7 +47,7 @@ export default { data-testid="none" > <span> {{ __('None') }}</span> - <template v-if="signedIn"> + <template v-if="signedIn && editable"> <span class="gl-ml-2">-</span> <gl-button data-testid="assign-yourself" 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 5c432ca0e03..26fda2a823c 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -72,6 +72,10 @@ export default { type: Boolean, required: true, }, + editable: { + type: Boolean, + required: true, + }, }, data() { return { @@ -252,6 +256,7 @@ export default { :users="assignees" :issuable-type="issuableType" :signed-in="signedIn" + :editable="editable" @assign-self="assignSelf" @expand-widget="expandWidget" /> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index 336c291d4f1..c44ce8b0057 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -88,10 +88,7 @@ export default { .then( ({ data: { - issuableSetConfidential: { - issuable: { confidential }, - errors, - }, + issuableSetConfidential: { errors }, }, }) => { if (errors.length) { @@ -99,7 +96,7 @@ export default { message: errors[0], }); } else { - this.$emit('closeForm', { confidential }); + this.$emit('closeForm'); } }, ) diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index eec083f23f3..f234c5ea3c9 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -95,10 +95,10 @@ export default { confidentialWidget.setConfidentiality = null; }, methods: { - closeForm({ confidential } = {}) { + closeForm() { this.$refs.editable.collapse(); this.$el.dispatchEvent(hideDropdownEvent); - this.$emit('closeForm', { confidential }); + this.$emit('closeForm'); }, // synchronizing the quick action with the sidebar widget // this is a temporary solution until we have confidentiality real-time updates diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue index 8528ad56ddb..fd652583f76 100644 --- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue +++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue @@ -16,7 +16,7 @@ export default { <template> <copyable-field - data-qa-selector="copy-forward-email" + data-testid="copy-forward-email" :name="s__('RightSidebar|Issue email')" :clipboard-tooltip-text="s__('RightSidebar|Copy email address')" :value="issueEmailAddress" diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue index aeaac76cff4..9c41db98c63 100644 --- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -62,7 +62,7 @@ export default { v-for="status in $options.STATUS_LIST" :key="status" data-testid="status-dropdown-item" - :is-check-item="true" + is-check-item :is-checked="status === value" @click="$emit('input', status)" > diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index 65b51169420..c9e651370f9 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,10 +1,10 @@ <script> import { GlSprintf } from '@gitlab/ui'; -import editFormButtons from './edit_form_buttons.vue'; +import EditFormButtons from './edit_form_buttons.vue'; export default { components: { - editFormButtons, + EditFormButtons, GlSprintf, }, props: { diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 5f1808ff4da..286bd50f6dd 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -6,7 +6,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import createFlash from '~/flash'; import eventHub from '~/sidebar/event_hub'; import toast from '~/vue_shared/plugins/global_toast'; -import editForm from './edit_form.vue'; +import EditForm from './edit_form.vue'; export default { issue: 'issue', @@ -23,7 +23,7 @@ export default { displayText: __('Unlocked'), }, components: { - editForm, + EditForm, GlIcon, }, directives: { diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index b8804de653f..2f25c2fd4b0 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; -import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { directives: { @@ -11,7 +11,7 @@ export default { GlButton, GlIcon, GlLoadingIcon, - userAvatarImage, + UserAvatarImage, }, props: { loading: { @@ -27,7 +27,7 @@ export default { numberOfLessParticipants: { type: Number, required: false, - default: 7, + default: 8, }, showParticipantLabel: { type: Boolean, @@ -123,7 +123,7 @@ export default { :size="24" :tooltip-text="participant.name" :img-alt="participant.name" - css-classes="avatar-inline" + css-classes="gl-mr-0!" tooltip-placement="bottom" /> </a> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue index a09138a708b..46a04725a49 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -49,6 +49,9 @@ export default { error, }); }, + context: { + isSingleRequest: true, + }, }, }, computed: { @@ -68,7 +71,7 @@ export default { <participants :loading="isLoading" :participants="participants" - :number-of-less-participants="7" + :number-of-less-participants="8" :lazy="false" class="block participants" @toggleSidebar="toggleSidebar" diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index bf4ba715f85..a562df4ecd6 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -179,7 +179,7 @@ export default { v-for="option in severitiesList" :key="option.value" data-testid="severityDropdownItem" - :is-check-item="true" + is-check-item :is-checked="option.value === severity" @click="updateSeverity(option.value)" > diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 3d8a2cd847c..6c615109bb8 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -9,6 +9,8 @@ import { GlLoadingIcon, GlIcon, GlTooltipDirective, + GlPopover, + GlButton, } from '@gitlab/ui'; import { kebabCase, snakeCase } from 'lodash'; import createFlash from '~/flash'; @@ -17,6 +19,7 @@ import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { dropdowni18nText, Tracking, @@ -47,7 +50,10 @@ export default { GlSearchBoxByType, GlIcon, GlLoadingIcon, + GlPopover, + GlButton, }, + mixins: [glFeatureFlagMixin()], inject: { isClassicSidebar: { default: false, @@ -66,6 +72,7 @@ export default { }, }, }, + props: { issuableAttribute: { type: String, @@ -111,6 +118,10 @@ export default { }; }, update(data) { + if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { + this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; + } + return data?.workspace?.issuable.attribute; }, error(error) { @@ -179,6 +190,8 @@ export default { updating: false, selectedTitle: null, currentAttribute: null, + hasCurrentAttribute: false, + editConfirmation: false, attributesList: [], tracking: { event: Tracking.editEvent, @@ -228,6 +241,15 @@ export default { snake: snakeCase(this.issuableAttribute), }; }, + shouldShowConfirmationPopover() { + if (!this.glFeatures?.epicWidgetEditConfirmation) { + return false; + } + + return this.isEpic && this.currentAttribute === null && this.hasCurrentAttribute + ? !this.editConfirmation + : false; + }, }, methods: { updateAttribute(attributeId) { @@ -299,6 +321,17 @@ export default { setFocus() { this.$refs.search.focusInput(); }, + handlePopoverClose() { + this.$refs.popover.$emit('close'); + }, + handlePopoverConfirm(cb) { + this.editConfirmation = true; + this.handlePopoverClose(); + setTimeout(cb, 0); + }, + handleEditConfirmation() { + this.$refs.popover.$emit('open'); + }, }, }; </script> @@ -308,10 +341,13 @@ export default { ref="editable" :title="attributeTypeTitle" :data-testid="`${formatIssuableAttribute.kebab}-edit`" + :button-id="`${formatIssuableAttribute.kebab}-edit`" :tracking="tracking" + :should-show-confirmation-popover="shouldShowConfirmationPopover" :loading="updating || loading" @open="handleOpen" @close="handleClose" + @edit-confirm="handleEditConfirmation" > <template #collapsed> <slot name="value-collapsed" :current-attribute="currentAttribute"> @@ -332,6 +368,10 @@ export default { :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" > <span v-if="updating">{{ selectedTitle }}</span> + <template v-else-if="!currentAttribute && hasCurrentAttribute"> + <gl-icon name="warning" class="gl-text-orange-500" /> + <span class="gl-text-gray-500">{{ i18n.noPermissionToView }}</span> + </template> <span v-else-if="!currentAttribute" class="gl-text-gray-500"> {{ $options.i18n.none }} </span> @@ -344,6 +384,7 @@ export default { > <gl-link v-gl-tooltip="tooltipText" + class="gl-reset-color gl-hover-text-blue-800" :href="attributeUrl" :data-qa-selector="`${formatIssuableAttribute.snake}_link`" > @@ -353,7 +394,40 @@ export default { </slot> </div> </template> - <template #default> + <template v-if="shouldShowConfirmationPopover" #default="{ toggle }"> + <gl-popover + ref="popover" + :target="`${formatIssuableAttribute.kebab}-edit`" + placement="bottomleft" + boundary="viewport" + triggers="click" + > + <div class="gl-mb-4 gl-font-base"> + {{ i18n.editConfirmation }} + </div> + <div class="gl-display-flex gl-align-items-center"> + <gl-button + size="small" + variant="confirm" + category="primary" + data-testid="confirm-edit-cta" + @click.prevent="() => handlePopoverConfirm(toggle)" + >{{ i18n.editConfirmationCta }}</gl-button + > + <gl-button + class="gl-ml-auto" + size="small" + name="cancel" + variant="default" + category="primary" + data-testid="confirm-edit-cancel" + @click.prevent="handlePopoverClose" + >{{ i18n.editConfirmationCancel }}</gl-button + > + </div> + </gl-popover> + </template> + <template v-else #default> <gl-dropdown ref="newDropdown" lazy @@ -368,7 +442,7 @@ export default { <gl-search-box-by-type ref="search" v-model="searchTerm" /> <gl-dropdown-item :data-testid="`no-${formatIssuableAttribute.kebab}-item`" - :is-check-item="true" + is-check-item :is-checked="isAttributeChecked($options.noAttributeId)" @click="updateAttribute($options.noAttributeId)" > @@ -395,7 +469,7 @@ export default { <gl-dropdown-item v-for="attrItem in attributesList" :key="attrItem.id" - :is-check-item="true" + is-check-item :is-checked="isAttributeChecked(attrItem.id)" :data-testid="`${formatIssuableAttribute.kebab}-items`" @click="updateAttribute(attrItem.id)" diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 7551b181a58..cc88812c7b0 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -14,6 +14,11 @@ export default { }, }, props: { + buttonId: { + type: String, + required: false, + default: '', + }, title: { type: String, required: false, @@ -48,6 +53,11 @@ export default { required: false, default: true, }, + shouldShowConfirmationPopover: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -97,6 +107,11 @@ export default { window.removeEventListener('keyup', this.collapseOnEscape); }, toggle({ emitEvent = true } = {}) { + if (this.shouldShowConfirmationPopover) { + this.$emit('edit-confirm'); + return; + } + if (this.edit) { this.collapse({ emitEvent }); } else { @@ -132,6 +147,7 @@ export default { <slot name="collapsed-right"></slot> <gl-button v-if="canUpdate && !initialLoading && canEdit" + :id="buttonId" category="tertiary" size="small" class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle" @@ -151,7 +167,7 @@ export default { <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> - <slot :edit="edit"></slot> + <slot :edit="edit" :toggle="toggle"></slot> </div> </template> </div> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 7662d645dd9..e5bee4df9b8 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -181,18 +181,18 @@ export default { </script> <template> - <li v-if="isMergeRequest" class="gl-new-dropdown-item"> - <button type="button" class="dropdown-item" @click="toggleSubscribed"> - <span class="gl-new-dropdown-item-text-wrapper"> - <template v-if="subscribed"> - {{ __('Turn off notifications') }} - </template> - <template v-else> - {{ __('Turn on notifications') }} - </template> - </span> - </button> - </li> + <div v-if="isMergeRequest" class="gl-new-dropdown-item"> + <div class="gl-px-5 gl-pb-2 gl-pt-1"> + <gl-toggle + :value="subscribed" + :label="__('Notifications')" + class="merge-request-notification-toggle" + label-position="left" + data-testid="notifications-toggle" + @change="toggleSubscribed" + /> + </div> + </div> <sidebar-editable-item v-else ref="editable" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js b/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js deleted file mode 100644 index 70177d84b1b..00000000000 --- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js +++ /dev/null @@ -1,20 +0,0 @@ -import produce from 'immer'; - -export function removeTimelogFromStore(store, deletedTimelogId, query, variables) { - const sourceData = store.readQuery({ - query, - variables, - }); - - const data = produce(sourceData, (draftData) => { - draftData.issuable.timelogs.nodes = draftData.issuable.timelogs.nodes.filter( - ({ id }) => id !== deletedTimelogId, - ); - }); - - store.writeQuery({ - query, - variables, - data, - }); -} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql index 17bbad1acb1..6e916893b5a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql @@ -1,5 +1,17 @@ +#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql" +#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql" + mutation deleteTimelog($input: TimelogDeleteInput!) { timelogDelete(input: $input) { errors + timelog { + id + issue { + ...IssueTimeTrackingFragment + } + mergeRequest { + ...MergeRequestTimeTrackingFragment + } + } } } diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 79ef5a32474..d751816bd94 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -7,7 +7,6 @@ import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_ut import { __, s__ } from '~/locale'; import { timelogQueries } from '~/sidebar/constants'; import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql'; -import { removeTimelogFromStore } from './graphql/cache_update'; const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; @@ -99,14 +98,6 @@ export default { .mutate({ mutation: deleteTimelogMutation, variables: { input: { id: timelogId } }, - update: (store) => { - removeTimelogFromStore( - store, - timelogId, - timelogQueries[this.issuableType].query, - this.getQueryVariables(), - ); - }, }) .then(({ data }) => { if (data.timelogDelete?.errors?.length) { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index e39d9f9fb49..13981c477c6 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,8 +1,16 @@ <script> -import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; +import { + GlIcon, + GlLink, + GlModal, + GlButton, + GlModalDirective, + GlLoadingIcon, + GlTooltipDirective, +} from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; import { s__, __ } from '~/locale'; -import { timeTrackingQueries } from '~/sidebar/constants'; +import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '~/sidebar/constants'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; @@ -31,6 +39,7 @@ export default { }, directives: { GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, }, inject: { issuableType: { @@ -162,6 +171,12 @@ export default { this.issuableId ); }, + timeTrackingIconTitle() { + return this.showHelpState ? '' : HOW_TO_TRACK_TIME; + }, + timeTrackingIconName() { + return this.showHelpState ? 'close' : 'question-o'; + }, }, watch: { /** @@ -188,11 +203,7 @@ export default { </script> <template> - <div - v-cloak - class="time-tracker time-tracking-component-wrap sidebar-help-wrap" - data-testid="time-tracker" - > + <div v-cloak class="time-tracker sidebar-help-wrap" data-testid="time-tracker"> <time-tracking-collapsed-state v-if="showCollapsed" :show-comparison-state="showComparisonState" @@ -216,7 +227,12 @@ export default { class="gl-ml-auto" @click="toggleHelpState(!showHelpState)" > - <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" /> + <gl-icon + v-gl-tooltip.left + :title="timeTrackingIconTitle" + :name="timeTrackingIconName" + class="gl-text-gray-900!" + /> </gl-button> </div> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> @@ -252,7 +268,6 @@ export default { size="lg" :title="__('Time tracking report')" :hide-footer="true" - @hide="refresh" > <time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" /> </gl-modal> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index 482b9343e70..42e16aae312 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -6,6 +6,7 @@ import { __, sprintf } from '~/locale'; import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils'; import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; const trackingMixin = Tracking.mixin(); @@ -19,7 +20,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [trackingMixin], + mixins: [glFeatureFlagsMixin(), trackingMixin], inject: { isClassicSidebar: { default: false, @@ -81,6 +82,9 @@ export default { }, }, computed: { + isMergeRequest() { + return this.glFeatures.movedMrSidebar && this.issuableType === 'merge_request'; + }, todoIdQuery() { return todoQueries[this.issuableType].query; }, @@ -183,12 +187,12 @@ export default { :issuable-id="issuableId" :is-todo="hasTodo" :loading="isLoading" - size="small" + :size="isMergeRequest ? 'medium' : 'small'" class="hide-collapsed" @click.stop.prevent="toggleTodo" /> <gl-button - v-if="isClassicSidebar" + v-if="isClassicSidebar && !isMergeRequest" v-gl-tooltip.left.viewport :title="tootltipTitle" category="tertiary" diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 989dc574bc3..60cb4cff727 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,4 +1,4 @@ -import { s__, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; @@ -313,8 +313,26 @@ export function dropdowni18nText(issuableAttribute, issuableType) { ), { issuableAttribute, issuableType }, ), + noPermissionToView: sprintf( + s__("DropdownWidget|You don't have permission to view this %{issuableAttribute}."), + { issuableAttribute }, + ), + editConfirmation: sprintf( + s__( + 'DropdownWidget|You do not have permission to view the currently assigned %{issuableAttribute} and will not be able to choose it again if you reassign it.', + ), + { + issuableAttribute, + }, + ), + editConfirmationCta: sprintf(s__('DropdownWidget|Edit %{issuableAttribute}'), { + issuableAttribute, + }), + editConfirmationCancel: s__('DropdownWidget|Cancel'), }; } export const escalationStatusQuery = getEscalationStatusQuery; export const escalationStatusMutation = updateEscalationStatusMutation; + +export const HOW_TO_TRACK_TIME = __('How to track time'); diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js deleted file mode 100644 index 127e3a3c610..00000000000 --- a/app/assets/javascripts/sidebar/graphql.js +++ /dev/null @@ -1,29 +0,0 @@ -import produce from 'immer'; -import VueApollo from 'vue-apollo'; -import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; -import createDefaultClient from '~/lib/graphql'; -import { temporaryConfig, resolvers as workItemResolvers } from '~/work_items/graphql/provider'; - -const resolvers = { - Mutation: { - updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => { - const sourceData = cache.readQuery({ query: getIssueStateQuery }); - const data = produce(sourceData, (draftData) => { - draftData.issueState = { issueType, isDirty }; - }); - cache.writeQuery({ query: getIssueStateQuery, data }); - }, - ...workItemResolvers.Mutation, - }, -}; - -export const defaultClient = createDefaultClient( - resolvers, - // should be removed with the rollout of work item assignees FF - // https://gitlab.com/gitlab-org/gitlab/-/issues/363030 - temporaryConfig, -); - -export const apolloProvider = new VueApollo({ - defaultClient, -}); diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index 2aacce2fb00..cc5de5e4083 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { IssuableType } from '~/issues/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; -import timeTracker from './components/time_tracking/time_tracker.vue'; +import TimeTracker from './components/time_tracking/time_tracker.vue'; export default class SidebarMilestone { constructor() { @@ -23,13 +23,13 @@ export default class SidebarMilestone { el, name: 'SidebarMilestoneRoot', components: { - timeTracker, + TimeTracker, }, provide: { issuableType: IssuableType.Milestone, }, render: (createElement) => - createElement('timeTracker', { + createElement('time-tracker', { props: { limitToHours: parseBoolean(limitToHours), issuableIid: iid.toString(), diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index fec4d0e346d..1cb3c30b9e0 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -22,7 +22,7 @@ import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; -import { apolloProvider } from '~/sidebar/graphql'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; @@ -39,7 +39,6 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin import { IssuableAttributeType } from './constants'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; -import SidebarEventHub from './event_hub'; Vue.use(Translate); Vue.use(VueApollo); @@ -163,6 +162,7 @@ function mountAssigneesComponent() { issuableType, issuableId: id, allowMultipleAssignees: !el.dataset.maxAssignees, + editable, }, scopedSlots: { collapsed: ({ users }) => @@ -360,13 +360,6 @@ function mountConfidentialComponent() { ? IssuableType.Issue : IssuableType.MergeRequest, }, - on: { - closeForm({ confidential }) { - if (confidential !== undefined) { - SidebarEventHub.$emit('confidentialityUpdated', confidential); - } - }, - }, }), }); } diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql index f4d0e9b5deb..41d45b486e8 100644 --- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql @@ -1,12 +1,12 @@ +#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql" + query issueTimeTracking($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { id issuable: issue(iid: $iid) { - id + ...IssueTimeTrackingFragment humanTimeEstimate - humanTotalTimeSpent timeEstimate - totalTimeSpent } } } diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql index 5d05cb2f34c..12ef78a6453 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql @@ -1,12 +1,12 @@ +#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql" + query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { id issuable: mergeRequest(iid: $iid) { - id + ...MergeRequestTimeTrackingFragment humanTimeEstimate - humanTotalTimeSpent timeEstimate - totalTimeSpent } } } diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index ee8b00c1f5d..853293e5eb6 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -6,7 +6,7 @@ import { SNIPPET_MEASURE_BLOBS_CONTENT, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; +import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants'; import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; import { getSnippetMixin } from '../mixins/snippets'; @@ -31,7 +31,7 @@ export default { mixins: [getSnippetMixin], computed: { embeddable() { - return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; + return this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING; }, canBeCloned() { return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo); diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index 2a9ecbc27dc..84a940ed1f8 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -1,22 +1,23 @@ import { __ } from '~/locale'; - -export const SNIPPET_VISIBILITY_PRIVATE = 'private'; -export const SNIPPET_VISIBILITY_INTERNAL = 'internal'; -export const SNIPPET_VISIBILITY_PUBLIC = 'public'; +import { + VISIBILITY_LEVEL_PRIVATE_STRING, + VISIBILITY_LEVEL_INTERNAL_STRING, + VISIBILITY_LEVEL_PUBLIC_STRING, +} from '~/visibility_level/constants'; export const SNIPPET_VISIBILITY = { - [SNIPPET_VISIBILITY_PRIVATE]: { + [VISIBILITY_LEVEL_PRIVATE_STRING]: { label: __('Private'), icon: 'lock', description: __('The snippet is visible only to me.'), description_project: __('The snippet is visible only to project members.'), }, - [SNIPPET_VISIBILITY_INTERNAL]: { + [VISIBILITY_LEVEL_INTERNAL_STRING]: { label: __('Internal'), icon: 'shield', description: __('The snippet is visible to any logged in user except external users.'), }, - [SNIPPET_VISIBILITY_PUBLIC]: { + [VISIBILITY_LEVEL_PUBLIC_STRING]: { label: __('Public'), icon: 'earth', description: __('The snippet can be accessed without any authentication.'), @@ -34,11 +35,6 @@ export const SNIPPET_BLOB_ACTION_DELETE = 'delete'; export const SNIPPET_MAX_BLOBS = 10; -export const SNIPPET_LEVELS_MAP = { - 0: SNIPPET_VISIBILITY_PRIVATE, - 10: SNIPPET_VISIBILITY_INTERNAL, - 20: SNIPPET_VISIBILITY_PUBLIC, -}; export const SNIPPET_LEVELS_RESTRICTED = __( 'Other visibility settings have been disabled by the administrator.', ); diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index 21f38c4d8c9..89dd5e586fb 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -2,7 +2,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants'; +import { + VISIBILITY_LEVEL_PRIVATE_STRING, + VISIBILITY_LEVELS_INTEGER_TO_STRING, +} from '~/visibility_level/constants'; import Translate from '~/vue_shared/translate'; Vue.use(VueApollo); @@ -36,7 +39,8 @@ export default function appFactory(el, Component) { apolloProvider, provide: { visibilityLevels: JSON.parse(visibilityLevels), - selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE, + selectedLevel: + VISIBILITY_LEVELS_INTEGER_TO_STRING[selectedLevel] ?? VISIBILITY_LEVEL_PRIVATE_STRING, multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset, reportAbusePath, canReportSpam, diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js index 2a3f590a803..a228d6111ce 100644 --- a/app/assets/javascripts/snippets/utils/blob.js +++ b/app/assets/javascripts/snippets/utils/blob.js @@ -1,12 +1,12 @@ import { uniqueId } from 'lodash'; import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; +import { VISIBILITY_LEVELS_INTEGER_TO_STRING } from '~/visibility_level/constants'; import { SNIPPET_BLOB_ACTION_CREATE, SNIPPET_BLOB_ACTION_UPDATE, SNIPPET_BLOB_ACTION_MOVE, SNIPPET_BLOB_ACTION_DELETE, - SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY, } from '../constants'; @@ -72,7 +72,7 @@ export const diffAll = (blobs, origBlobs) => { export const defaultSnippetVisibilityLevels = (arr) => { if (Array.isArray(arr)) { return arr.map((l) => { - const translatedLevel = SNIPPET_LEVELS_MAP[l]; + const translatedLevel = VISIBILITY_LEVELS_INTEGER_TO_STRING[l]; return { value: translatedLevel, ...SNIPPET_VISIBILITY[translatedLevel], diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue index 4e4ef49b1c6..df114c27908 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.vue +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -19,6 +19,8 @@ const steps = [ }, ]; +const MR_RENDER_LS_KEY = 'mr_survey_rendered'; + export default { name: 'MergeRequestExperienceSurveyApp', components: { @@ -68,9 +70,20 @@ export default { onQueryLoaded({ shouldShowCallout }) { this.visible = shouldShowCallout; if (!this.visible) this.$emit('close'); + else if (!localStorage?.getItem(MR_RENDER_LS_KEY)) { + this.track('survey:mr_experience', { + label: 'render', + extra: { + accountAge: this.accountAge, + }, + }); + localStorage?.setItem(MR_RENDER_LS_KEY, '1'); + } }, onRate(event) { + this.$refs.dismisser?.dismiss(); this.$emit('rate'); + localStorage?.removeItem(MR_RENDER_LS_KEY); this.track('survey:mr_experience', { label: this.step.label, value: event, @@ -87,8 +100,18 @@ export default { }, handleKeyup(e) { if (e.key !== 'Escape') return; - this.$emit('close'); + this.dismiss(); + }, + dismiss() { this.$refs.dismisser?.dismiss(); + this.$emit('close'); + this.track('survey:mr_experience', { + label: 'dismiss', + extra: { + accountAge: this.accountAge, + }, + }); + localStorage?.removeItem(MR_RENDER_LS_KEY); }, }, }; @@ -100,79 +123,71 @@ export default { feature-name="mr_experience_survey" @queryResult.once="onQueryLoaded" > - <template #default="{ dismiss }"> - <aside - class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5" - :aria-label="$options.i18n.survey" - > - <transition name="survey-slide-up"> + <aside + class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5" + :aria-label="$options.i18n.survey" + > + <transition name="survey-slide-up"> + <div + v-if="visible" + class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base" + > + <gl-button + v-tooltip="$options.i18n.close" + :aria-label="$options.i18n.close" + variant="default" + category="tertiary" + class="gl-top-4 gl-right-3 gl-absolute" + icon="close" + @click="dismiss" + /> <div - v-if="visible" - class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base" + v-if="stepIndex === 0" + class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm" + role="note" > - <gl-button - v-tooltip="$options.i18n.close" - :aria-label="$options.i18n.close" - variant="default" - category="tertiary" - class="gl-top-4 gl-right-3 gl-absolute" - icon="close" - @click=" - dismiss(); - $emit('close'); - " - /> - <div - v-if="stepIndex === 0" - class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm" - role="note" - > - <p class="gl-m-0"> - <gl-sprintf :message="$options.i18n.legal"> - <template #link="{ content }"> - <a - class="gl-text-decoration-underline gl-text-gray-500" - href="https://about.gitlab.com/privacy/" - target="_blank" - rel="noreferrer nofollow" - v-text="content" - ></a> - </template> - </gl-sprintf> - </p> - </div> - <div class="gl-relative"> - <div class="gl-absolute"> - <div - v-safe-html="$options.gitlabLogo" - aria-hidden="true" - class="mr-experience-survey-logo" - ></div> - </div> + <p class="gl-m-0"> + <gl-sprintf :message="$options.i18n.legal"> + <template #link="{ content }"> + <a + class="gl-text-decoration-underline gl-text-gray-500" + href="https://about.gitlab.com/privacy/" + target="_blank" + rel="noreferrer nofollow" + v-text="content" + ></a> + </template> + </gl-sprintf> + </p> + </div> + <div class="gl-relative"> + <div class="gl-absolute"> + <div + v-safe-html="$options.gitlabLogo" + aria-hidden="true" + class="mr-experience-survey-logo" + ></div> </div> - <section v-if="step"> - <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7"> - <gl-sprintf :message="step.question"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <satisfaction-rate - aria-labelledby="mr_survey_question" - class="gl-mt-5" - @rate=" - dismiss(); - onRate($event); - " - /> - </section> - <section v-else class="gl-px-7"> - {{ $options.i18n.thanks }} - </section> </div> - </transition> - </aside> - </template> + <section v-if="step"> + <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7"> + <gl-sprintf :message="step.question"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <satisfaction-rate + aria-labelledby="mr_survey_question" + class="gl-mt-5" + @rate="onRate" + /> + </section> + <section v-else class="gl-px-7"> + {{ $options.i18n.thanks }} + </section> + </div> + </transition> + </aside> </user-callout-dismisser> </template> diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue index de8cd856bf7..363a9d58d65 100644 --- a/app/assets/javascripts/token_access/components/token_access.vue +++ b/app/assets/javascripts/token_access/components/token_access.vue @@ -1,7 +1,16 @@ <script> -import { GlButton, GlCard, GlFormInput, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { + GlButton, + GlCard, + GlFormInput, + GlLink, + GlLoadingIcon, + GlSprintf, + GlToggle, +} from '@gitlab/ui'; import createFlash from '~/flash'; import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql'; @@ -13,7 +22,7 @@ export default { i18n: { toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'), toggleHelpText: s__( - `CICD|Select projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable.`, + `CICD|Select the projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`, ), cardHeaderTitle: s__('CICD|Add an existing project to the scope'), addProject: __('Add project'), @@ -26,7 +35,9 @@ export default { GlButton, GlCard, GlFormInput, + GlLink, GlLoadingIcon, + GlSprintf, GlToggle, TokenProjectsTable, }, @@ -76,6 +87,9 @@ export default { isProjectPathEmpty() { return this.targetProjectPath === ''; }, + ciJobTokenHelpPage() { + return helpPagePath('ci/jobs/ci_job_token'); + }, }, methods: { async updateCIJobTokenScope() { @@ -99,10 +113,6 @@ export default { } } catch (error) { createFlash({ message: error }); - } finally { - if (this.jobTokenScopeEnabled) { - this.getProjects(); - } } }, async addProject() { @@ -172,10 +182,20 @@ export default { <gl-toggle v-model="jobTokenScopeEnabled" :label="$options.i18n.toggleLabelTitle" - :help="$options.i18n.toggleHelpText" @change="updateCIJobTokenScope" - /> - <div v-if="jobTokenScopeEnabled" data-testid="token-section"> + > + <template #help> + <gl-sprintf :message="$options.i18n.toggleHelpText"> + <template #link="{ content }"> + <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-toggle> + + <div data-testid="token-section"> <gl-card class="gl-mt-5"> <template #header> <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5> diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c2892fb8dac..ee5d6a22fc3 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -46,6 +46,7 @@ const populateUserInfo = (user) => { pronouns: userData.pronouns, localTime: userData.local_time, isFollowed: userData.is_followed, + state: userData.state, loaded: true, }); } diff --git a/app/assets/javascripts/validators/input_validator.js b/app/assets/javascripts/validators/input_validator.js index f37373977b8..b799976a0ba 100644 --- a/app/assets/javascripts/validators/input_validator.js +++ b/app/assets/javascripts/validators/input_validator.js @@ -19,6 +19,7 @@ export default class InputValidator { setValidationMessage() { if (this.invalidInput) { this.inputDomElement.setCustomValidity(this.errorMessage); + // eslint-disable-next-line no-unsanitized/property this.inputErrorMessage.innerHTML = this.errorMessage; } else { this.resetValidationMessage(); @@ -28,6 +29,7 @@ export default class InputValidator { resetValidationMessage() { if (this.inputDomElement.validationMessage === this.errorMessage) { this.inputDomElement.setCustomValidity(''); + // eslint-disable-next-line no-unsanitized/property this.inputErrorMessage.innerHTML = this.inputDomElement.title; } } diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js index 65f0eceae55..77736fb6ef5 100644 --- a/app/assets/javascripts/visibility_level/constants.js +++ b/app/assets/javascripts/visibility_level/constants.js @@ -1,10 +1,20 @@ -export const VISIBILITY_LEVEL_PRIVATE = 'private'; -export const VISIBILITY_LEVEL_INTERNAL = 'internal'; -export const VISIBILITY_LEVEL_PUBLIC = 'public'; +export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private'; +export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal'; +export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public'; + +export const VISIBILITY_LEVEL_PRIVATE_INTEGER = 0; +export const VISIBILITY_LEVEL_INTERNAL_INTEGER = 10; +export const VISIBILITY_LEVEL_PUBLIC_INTEGER = 20; // Matches `lib/gitlab/visibility_level.rb` -export const VISIBILITY_LEVELS_ENUM = { - [VISIBILITY_LEVEL_PRIVATE]: 0, - [VISIBILITY_LEVEL_INTERNAL]: 10, - [VISIBILITY_LEVEL_PUBLIC]: 20, +export const VISIBILITY_LEVELS_STRING_TO_INTEGER = { + [VISIBILITY_LEVEL_PRIVATE_STRING]: VISIBILITY_LEVEL_PRIVATE_INTEGER, + [VISIBILITY_LEVEL_INTERNAL_STRING]: VISIBILITY_LEVEL_INTERNAL_INTEGER, + [VISIBILITY_LEVEL_PUBLIC_STRING]: VISIBILITY_LEVEL_PUBLIC_INTEGER, +}; + +export const VISIBILITY_LEVELS_INTEGER_TO_STRING = { + [VISIBILITY_LEVEL_PRIVATE_INTEGER]: VISIBILITY_LEVEL_PRIVATE_STRING, + [VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING, + [VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue index 38f40e8a3c8..30a0e7c383c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue @@ -63,6 +63,12 @@ export default { return btn.tooltipText; }, + actionButtonQaSelector(btn) { + if (btn.dataQaSelector) { + return btn.dataQaSelector; + } + return 'mr_widget_extension_actions_button'; + }, }, }; </script> @@ -105,7 +111,7 @@ export default { :target="btn.target" :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" :data-clipboard-text="btn.dataClipboardText" - :data-qa-selector="btn.dataQaSelector" + :data-qa-selector="actionButtonQaSelector(btn)" :data-method="btn.dataMethod" :icon="btn.icon" :data-testid="btn.testId || 'extension-actions-button'" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue index 254b280bf14..f377a185879 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import { escape } from 'lodash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { n__, s__, sprintf } from '~/locale'; @@ -9,6 +9,7 @@ const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} m export default { components: { GlSprintf, + GlLink, }, mixins: [glFeatureFlagMixin()], props: { @@ -40,6 +41,11 @@ export default { required: false, default: '', }, + mergeCommitPath: { + type: String, + required: false, + default: '', + }, }, computed: { isMerged() { @@ -124,7 +130,9 @@ export default { </template> </template> <template #mergeCommitSha> - <span class="label-branch">{{ mergeCommitSha }}</span> + <gl-link :href="mergeCommitPath" class="label-branch" data-testid="merge-commit-sha">{{ + mergeCommitSha + }}</gl-link> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index b1c4f7c5a7c..d7255eb6ad2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -103,7 +103,7 @@ export default { <span v-if="approvalLeftMessage">{{ message }}</span> <span v-else class="gl-font-weight-bold">{{ message }}</span> <user-avatar-list - class="gl-display-inline-block gl-vertical-align-middle" + class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" :img-size="24" :items="approvers" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index e115710b5d1..30098f7619a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -74,16 +74,12 @@ export default { <div class="js-deployment-info deployment-info"> <template v-if="hasDeploymentMeta"> <span>{{ deployedText }}</span> - <tooltip-on-truncate - :title="deployment.name" - truncate-target="child" - class="deploy-link label-truncate" - > + <tooltip-on-truncate :title="deployment.name" truncate-target="child" class="label-truncate"> <gl-link :href="deployment.url" target="_blank" rel="noopener noreferrer nofollow" - class="js-deploy-meta gl-font-sm" + class="js-deploy-meta gl-font-sm gl-pb-1" > {{ deployment.name }} </gl-link> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 414c5bf9691..300e2a672cb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -13,6 +13,7 @@ import Poll from '~/lib/utils/poll'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; import Actions from '../action_buttons.vue'; +import StateContainer from '../state_container.vue'; import StatusIcon from './status_icon.vue'; import ChildContent from './child_content.vue'; import { createTelemetryHub } from './telemetry'; @@ -36,6 +37,7 @@ export default { ChildContent, DynamicScroller, DynamicScrollerItem, + StateContainer, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -307,19 +309,20 @@ export default { </script> <template> - <section class="media-section" data-testid="widget-extension"> - <div + <section + class="media-section" + data-testid="widget-extension" + data-qa-selector="mr_widget_extension" + > + <state-container + :mr="mr" + :status="statusIconName" + :is-loading="isLoadingSummary" :class="{ 'gl-cursor-pointer': isCollapsible }" - class="media gl-p-5" + class="gl-p-5" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp" > - <status-icon - :level="1" - :name="$options.label || $options.name" - :is-loading="isLoadingSummary" - :icon-name="statusIconName" - /> <div class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" data-testid="widget-extension-top-level" @@ -352,12 +355,13 @@ export default { :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" category="tertiary" data-testid="toggle-button" + data-qa-selector="toggle_button" size="small" @click="toggleCollapsed" /> </div> </div> - </div> + </state-container> <div v-if="!isCollapsed" class="mr-widget-grouped-section gl-relative" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index 1eccc7de660..52c9f047b76 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -62,7 +62,9 @@ export default { <strong v-else v-safe-html="generateText(data.header)"></strong> </div> <div class="gl-display-flex"> - <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> + <div v-if="data.icon" class="report-block-child-icon gl-display-flex"> + <status-icon :icon-name="data.icon.name" :size="12" class="gl-m-auto" /> + </div> <div class="gl-w-full"> <div class="gl-display-flex gl-flex-nowrap"> <div class="gl-flex-wrap gl-display-flex gl-w-full"> @@ -109,6 +111,7 @@ export default { :modal-id="modalId" :level="3" data-testid="child-content" + data-qa-selector="child_content" @clickedAction="onClickedAction" /> </li> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue index dc748ba44f2..f9d0986d60d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -49,18 +49,28 @@ export default { <div :class="[ $options.EXTENSION_ICON_CLASS[iconName], - { 'mr-widget-extension-icon gl-w-6': !isLoading && level === 1 }, + { 'gl-w-6': !isLoading && level === 1 }, { 'gl-p-2': isLoading || level === 1 }, ]" - class="gl-rounded-full gl-mr-3 gl-relative gl-p-2" + class="gl-mr-3 gl-p-2" > - <gl-loading-icon v-if="isLoading" size="sm" inline class="gl-display-block" /> - <gl-icon - v-else - :name="$options.EXTENSION_ICON_NAMES[iconName]" - :size="size" - :aria-label="iconAriaLabel" - class="gl-display-block" - /> + <div + class="gl-rounded-full gl-relative gl-display-flex" + :class="{ 'mr-widget-extension-icon': !isLoading && level === 1 }" + > + <div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto"> + <div class="gl-display-flex gl-m-auto gl-translate-y-n50"> + <gl-loading-icon v-if="isLoading" size="md" inline /> + <gl-icon + v-else + :name="$options.EXTENSION_ICON_NAMES[iconName]" + :size="size" + :aria-label="iconAriaLabel" + :data-qa-selector="`status_${iconName}_icon`" + class="gl-display-block" + /> + </div> + </div> + </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js index bc84459e298..d67ff11f297 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js @@ -24,7 +24,7 @@ const nonStandardEvents = { }, issues: { uniqueUser: { - expand: ['i_testing_load_performance_widget_total'], + expand: ['i_testing_issues_widget_total'], }, counter: {}, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue index 437342bf438..0c36e1ccd7f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue @@ -13,7 +13,7 @@ export default { </script> <template> - <div class="circle-icon-container gl-mr-3 align-self-start"> + <div class="circle-icon-container gl-mr-3 align-self-start gl-mt-2"> <gl-icon :name="name" :size="24" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 1e1a2049414..fe69e96bd87 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -9,11 +9,10 @@ import { GlTooltipDirective, GlSafeHtmlDirective, } from '@gitlab/ui'; -import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; import { s__, n__ } from '~/locale'; -import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; -import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { MT_MERGE_STRATEGY } from '../constants'; @@ -31,14 +30,11 @@ export default { PipelineMiniGraph, TimeAgoTooltip, TooltipOnTruncate, - LinkedPipelinesMiniList: () => - import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, directives: { GlTooltip: GlTooltipDirective, SafeHtml: GlSafeHtmlDirective, }, - mixins: [mrWidgetPipelineMixin], props: { pipeline: { type: Object, @@ -172,7 +168,7 @@ export default { </p> </template> <template v-else-if="!hasPipeline"> - <gl-loading-icon size="lg" /> + <gl-loading-icon size="md" /> <p class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0" data-testid="monitoring-pipeline-message" @@ -276,17 +272,15 @@ export default { </div> </div> <div> - <span class="gl-align-items-center gl-display-inline-flex mr-widget-pipeline-graph"> - <span class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell"> - <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" /> - <pipeline-mini-graph - v-if="hasStages" - stages-class="mr-widget-pipeline-stages" - :stages="pipeline.details.stages" - :is-merge-train="isMergeTrain" - /> - </span> - <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> + <span class="gl-align-items-center gl-display-inline-flex"> + <pipeline-mini-graph + v-if="pipeline.details.stages" + :downstream-pipelines="pipeline.triggered" + :is-merge-train="isMergeTrain" + :stages="pipeline.details.stages" + :upstream-pipeline="pipeline.triggered_by" + stages-class="mr-widget-pipeline-stages" + /> <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" /> </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 5b8acb4ebf8..3239285e53e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,11 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import ciIcon from '~/vue_shared/components/ci_icon.vue'; +import { GlIcon } from '@gitlab/ui'; +import StatusIcon from './extensions/status_icon.vue'; export default { components: { - ciIcon, - GlLoadingIcon, + StatusIcon, + GlIcon, }, props: { status: { @@ -17,22 +17,20 @@ export default { isLoading() { return this.status === 'loading'; }, - statusObj() { - return { - group: this.status, - icon: `status_${this.status}`, - }; - }, }, }; </script> <template> - <div class="gl-display-flex gl-align-self-start"> - <div class="square s24 h-auto d-flex-center gl-mr-3"> - <div v-if="isLoading" class="mr-widget-icon gl-display-inline-flex"> - <gl-loading-icon size="md" class="mr-loading-icon gl-display-inline-flex" /> - </div> - <ci-icon v-else :status="statusObj" :size="24" /> + <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3"> + <div class="gl-display-flex gl-m-auto"> + <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" /> + <gl-icon + v-else-if="status === 'closed'" + name="merge-request-close" + :size="16" + class="gl-text-red-500" + /> + <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 4a5a03fb598..822c5a68093 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -1,13 +1,23 @@ <script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; import StatusIcon from './mr_widget_status_icon.vue'; import Actions from './action_buttons.vue'; export default { components: { + GlButton, StatusIcon, Actions, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { + mr: { + type: Object, + required: true, + }, isLoading: { type: Boolean, required: false, @@ -24,30 +34,67 @@ export default { default: () => [], }, }, + i18n: { + expandDetailsTooltip: __('Expand merge details'), + collapseDetailsTooltip: __('Collapse merge details'), + }, + computed: { + wrapperClasses() { + if (this.status === 'merged') return 'gl-bg-blue-50'; + if (this.status === 'closed') return 'gl-bg-red-50'; + return null; + }, + }, }; </script> <template> - <div class="mr-widget-body media"> + <div class="mr-widget-body media" :class="wrapperClasses" v-on="$listeners"> <div v-if="isLoading" class="gl-w-full mr-conflict-loader"> - <slot name="loading"></slot> + <slot name="loading"> + <div class="gl-display-flex"> + <status-icon status="loading" /> + <div class="media-body"> + <slot></slot> + </div> + </div> + </slot> </div> <template v-else> <slot name="icon"> <status-icon :status="status" /> </slot> - <div - :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }" - class="media-body" - > - <slot></slot> + <div class="gl-display-flex gl-w-full"> + <div + :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }" + class="media-body" + > + <slot></slot> + <div + :class="{ 'gl-flex-direction-column-reverse': !actions.length }" + class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto" + > + <slot name="actions"> + <actions v-if="actions.length" :tertiary-buttons="actions" /> + </slot> + </div> + </div> <div - :class="{ 'gl-flex-direction-column-reverse': !actions.length }" - class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1" + class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1" > - <slot name="actions"> - <actions v-if="actions.length" :tertiary-buttons="actions" /> - </slot> + <gl-button + v-gl-tooltip + :title=" + mr.mergeDetailsCollapsed + ? $options.i18n.expandDetailsTooltip + : $options.i18n.collapseDetailsTooltip + " + :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + category="tertiary" + size="small" + class="gl-vertical-align-top" + @click="() => mr.toggleMergeDetails()" + /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index a45823823f0..e2a9caf5419 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -34,7 +34,7 @@ export default { <template> <div class="mr-widget-body media gl-flex-wrap"> - <status-icon status="warning" /> + <status-icon status="failed" /> <p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!"> {{ failedText }} </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index f74826f95d3..79e878431ed 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -1,22 +1,24 @@ <script> -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetArchived', components: { - statusIcon, + StateContainer, + }, + props: { + mr: { + type: Object, + required: true, + }, }, }; </script> + <template> - <div class="mr-widget-body media"> - <div class="space-children"> - <status-icon status="warning" show-disabled-button /> - </div> - <div class="media-body"> - <span class="gl-ml-0! gl-text-body! bold"> - {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} - </span> - </div> - </div> + <state-container :mr="mr" status="failed"> + <span class="gl-font-weight-bold"> + {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} + </span> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 690acc9a6dc..3c6c2a44e70 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoader, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql'; import createFlash from '~/flash'; @@ -28,7 +28,6 @@ export default { components: { MrWidgetAuthor, GlSkeletonLoader, - GlIcon, GlSprintf, StateContainer, }, @@ -151,7 +150,7 @@ export default { }; </script> <template> - <state-container status="scheduled" :is-loading="loading" :actions="actions"> + <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions"> <template #loading> <gl-skeleton-loader :width="334" :height="30"> <rect x="0" y="3" width="24" height="24" rx="4" /> @@ -168,8 +167,5 @@ export default { </gl-sprintf> </h4> </template> - <template v-if="!loading" #icon> - <gl-icon name="status_scheduled" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> - </template> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index b0cda85f361..39c56cbb93d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -58,8 +58,8 @@ export default { }; </script> <template> - <state-container status="warning" :actions="actions"> - <span class="bold gl-ml-0!"> + <state-container :mr="mr" status="failed" :actions="actions"> + <span class="gl-font-weight-bold"> <template v-if="mergeError">{{ mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index e2d87d8d536..922075516f3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -1,20 +1,23 @@ <script> -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetChecking', components: { - statusIcon, + StateContainer, + }, + props: { + mr: { + type: Object, + required: true, + }, }, }; </script> <template> - <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="loading" /> - <div class="media-body space-children"> - <span class="gl-ml-0! gl-text-body! bold"> - {{ s__('mrWidget|Checking if merge request can be merged…') }} - </span> - </div> - </div> + <state-container :mr="mr" status="loading"> + <span class="gl-font-weight-bold"> + {{ s__('mrWidget|Checking if merge request can be merged…') }} + </span> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 61f7d26f51e..806f8f939a6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,16 +1,14 @@ <script> import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetClosed', components: { MrWidgetAuthorTime, - statusIcon, + StateContainer, }, props: { - /* TODO: This is providing all store and service down when it - only needs metrics and targetBranch */ mr: { type: Object, required: true, @@ -19,15 +17,12 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <status-icon status="warning" /> - <div class="media-body"> - <mr-widget-author-time - :action-text="s__('mrWidget|Closed by')" - :author="mr.metrics.closedBy" - :date-title="mr.metrics.closedAt" - :date-readable="mr.metrics.readableClosedAt" - /> - </div> - </div> + <state-container :mr="mr" status="closed"> + <mr-widget-author-time + :action-text="s__('mrWidget|Closed by')" + :author="mr.metrics.closedBy" + :date-title="mr.metrics.closedAt" + :date-readable="mr.metrics.readableClosedAt" + /> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 8abd915b93e..d60d3cfc9ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -86,7 +86,7 @@ export default { }; </script> <template> - <state-container status="warning" :is-loading="isLoading"> + <state-container :mr="mr" status="failed" :is-loading="isLoading"> <template #loading> <gl-skeleton-loader :width="334" :height="30"> <rect x="0" y="7" width="150" height="16" rx="4" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 18103ac4a0e..8a7f15d8d1a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -1,16 +1,14 @@ <script> -import { GlButton } from '@gitlab/ui'; import { stripHtml } from '~/lib/utils/text_utility'; import { sprintf, s__, n__ } from '~/locale'; import eventHub from '../../event_hub'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; export default { name: 'MRWidgetFailedToMerge', components: { - GlButton, - statusIcon, + StateContainer, }, props: { @@ -47,6 +45,16 @@ export default { this.timer, ); }, + actions() { + return [ + { + text: s__('mrWidget|Refresh now'), + onClick: () => this.refresh(), + testId: 'merge-request-failed-refresh-button', + dataQaSelector: 'merge_request_error_content', + }, + ]; + }, }, mounted() { @@ -87,30 +95,18 @@ export default { }; </script> <template> - <div class="mr-widget-body media"> - <template v-if="isRefreshing"> - <status-icon status="loading" /> - <span class="media-body bold js-refresh-label"> {{ s__('mrWidget|Refreshing now') }} </span> - </template> - <template v-else> - <status-icon :show-disabled-button="true" status="warning" /> - <div class="media-body space-children"> - <span class="bold"> - <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error"> - {{ mergeError }} - </span> - <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> - <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> - </span> - <gl-button - size="small" - data-testid="merge-request-failed-refresh-button" - data-qa-selector="merge_request_error_content" - @click="refresh" - > - {{ s__('mrWidget|Refresh now') }} - </gl-button> - </div> - </template> - </div> + <state-container v-if="isRefreshing" :mr="mr" status="loading"> + <span class="gl-font-weight-bold"> + {{ s__('mrWidget|Refreshing now') }} + </span> + </state-container> + <state-container v-else :mr="mr" status="failed" :actions="actions"> + <span class="gl-font-weight-bold"> + <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error"> + {{ mergeError }} + </span> + <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> + <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> + </span> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 4416123cd51..e9298b0c856 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective } from '@gitlab/ui'; import api from '~/api'; import createFlash from '~/flash'; import { s__, __ } from '~/locale'; @@ -16,7 +16,6 @@ export default { }, components: { MrWidgetAuthorTime, - GlIcon, StateContainer, }, props: { @@ -49,18 +48,6 @@ export default { const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr; return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest); }, - shouldShowMergedButtons() { - const { - canRevertInCurrentMR, - canCherryPickInCurrentMR, - revertInForkPath, - cherryPickInForkPath, - } = this.mr; - - return ( - canRevertInCurrentMR || canCherryPickInCurrentMR || revertInForkPath || cherryPickInForkPath - ); - }, revertTitle() { return s__('mrWidget|Revert this merge request in a new merge request'); }, @@ -163,10 +150,7 @@ export default { }; </script> <template> - <state-container :actions="actions"> - <template #icon> - <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> - </template> + <state-container :mr="mr" :actions="actions" status="merged"> <mr-widget-author-time :action-text="s__('mrWidget|Merged by')" :author="mr.metrics.mergedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index c7574a41bb8..51ac2576f75 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -4,7 +4,7 @@ import simplePoll from '~/lib/utils/simple_poll'; import MergeRequest from '~/merge_request'; import eventHub from '../../event_hub'; import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StatusIcon from '../mr_widget_status_icon.vue'; const { transitions } = STATE_MACHINE; const { MERGE_FAILURE } = transitions; @@ -12,7 +12,7 @@ const { MERGE_FAILURE } = transitions; export default { name: 'MRWidgetMerging', components: { - statusIcon, + StatusIcon, }, props: { mr: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 659d12d1160..214d1b49732 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -9,7 +9,7 @@ import { MR_WIDGET_MISSING_BRANCH_RESTORE, MR_WIDGET_MISSING_BRANCH_MANUALCLI, } from '../../i18n'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StatusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetMissingBranch', @@ -19,7 +19,7 @@ export default { components: { GlIcon, GlSprintf, - statusIcon, + StatusIcon, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], apollo: { @@ -71,10 +71,10 @@ export default { </script> <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="warning" /> + <status-icon :show-disabled-button="true" status="failed" /> <div class="media-body space-children"> - <span class="gl-ml-0! gl-text-body! bold js-branch-text" data-testid="widget-content"> + <span class="gl-font-weight-bold js-branch-text" data-testid="widget-content"> <gl-sprintf :message="warning"> <template #code="{ content }"> <code>{{ content }}</code> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue index c203d2824fa..d837551a813 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue @@ -11,9 +11,9 @@ export default { <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="success" /> + <status-icon status="success" /> <div class="media-body space-children"> - <span class="bold"> + <span class="gl-font-weight-bold"> {{ s__(`mrWidget|Ready to be merged automatically. Ask someone with write access to this repository to merge this request`) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue index e99ee59b877..13920daca15 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -12,9 +12,9 @@ export default { </script> <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="warning" /> + <status-icon status="failed" /> <div class="media-body space-children"> - <span class="gl-ml-0! gl-text-body! bold"> + <span class="gl-font-weight-bold"> {{ s__( `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 6c5fc916799..37c8d5d15f3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -81,16 +81,19 @@ export default { return 'loading'; } if (!this.canPushToSourceBranch && !this.rebaseInProgress) { - return 'warning'; + return 'failed'; } return 'success'; }, - showDisabledButton() { - return ['failed', 'loading'].includes(this.status); - }, fastForwardMergeText() { return __('Merge blocked: the source branch must be rebased onto the target branch.'); }, + showRebaseWithoutPipeline() { + return ( + !this.mr.onlyAllowMergeIfPipelineSucceeds || + (this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline) + ); + }, }, methods: { rebase({ skipCi = false } = {}) { @@ -149,7 +152,7 @@ export default { }; </script> <template> - <state-container :status="status" :is-loading="isLoading"> + <state-container :mr="mr" :status="status" :is-loading="isLoading"> <template #loading> <gl-skeleton-loader :width="334" :height="30"> <rect x="0" y="3" width="24" height="24" rx="4" /> @@ -192,6 +195,7 @@ export default { </template> <template v-if="!isLoading" #actions> <gl-button + v-if="showRebaseWithoutPipeline" :loading="isMakingRequest" variant="confirm" size="small" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index d507e5f232b..3cbd171a035 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -2,14 +2,14 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; -import statusIcon from '../mr_widget_status_icon.vue'; +import StatusIcon from '../mr_widget_status_icon.vue'; export default { name: 'PipelineFailed', components: { GlLink, GlSprintf, - statusIcon, + StatusIcon, }, computed: { troubleshootingDocsPath() { @@ -26,9 +26,9 @@ export default { <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="warning" /> + <status-icon status="failed" /> <div class="media-body space-children"> - <span class="gl-ml-0! gl-text-body! bold"> + <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.failedMessage"> <template #link="{ content }"> <gl-link :href="troubleshootingDocsPath" target="_blank"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index d2c85b14999..78430abcfe9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -680,6 +680,7 @@ export default { :is-fast-forward-enabled="!shouldShowMergeEdit" :commits-count="commitsCount" :target-branch="stateData.targetBranch" + :merge-commit-path="mr.mergeCommitPath" /> </li> <li v-if="mr.state !== 'closed'" class="gl-line-height-normal"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index d149f5208fc..27919f90cc3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -22,7 +22,7 @@ export default { </script> <template> - <state-container status="warning"> + <state-container :mr="mr" status="failed"> <span class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" data-qa-selector="head_mismatch_content" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 035d62eaa59..8f2e4eb2131 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -24,7 +24,7 @@ export default { </script> <template> - <state-container status="warning"> + <state-container :mr="mr" status="failed"> <span class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index cf7f83c014a..0458e9dfaf5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -163,7 +163,7 @@ export default { </script> <template> - <state-container status="warning"> + <state-container :mr="mr" status="failed"> <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1"> {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index f1c1bde256f..2f52ac70833 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -15,7 +15,12 @@ export default { </script> <template> - <section role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app"> + <section + v-if="widgets.length" + role="region" + :aria-label="__('Merge request reports')" + data-testid="mr-widget-app" + > <component :is="widget" v-for="(widget, index) in widgets" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index 9c8819327e6..c9fc2dde0bd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -1,21 +1,32 @@ <script> +import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { normalizeHeaders } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import { sprintf, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; import StatusIcon from '../extensions/status_icon.vue'; -import { EXTENSION_ICON_NAMES } from '../../constants'; +import ActionButtons from '../action_buttons.vue'; +import { EXTENSION_ICONS } from '../../constants'; +import ContentSection from './widget_content_section.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; +const FETCH_TYPE_EXPANDED = 'expanded'; export default { components: { + ActionButtons, StatusIcon, + GlButton, + GlLoadingIcon, + ContentSection, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { /** * @param {value.collapsed} Object - * @param {value.extended} Object + * @param {value.expanded} Object */ value: { type: Object, @@ -35,7 +46,7 @@ export default { type: Function, required: true, }, - fetchExtendedData: { + fetchExpandedData: { type: Function, required: false, default: undefined, @@ -61,7 +72,16 @@ export default { type: String, default: 'neutral', required: false, - validator: (value) => Object.keys(EXTENSION_ICON_NAMES).indexOf(value) > -1, + validator: (value) => Object.keys(EXTENSION_ICONS).indexOf(value) > -1, + }, + isCollapsible: { + type: Boolean, + required: true, + }, + actionButtons: { + type: Array, + required: false, + default: () => [], }, widgetName: { type: String, @@ -70,10 +90,22 @@ export default { }, data() { return { + isExpandedForTheFirstTime: true, + isCollapsed: true, isLoading: false, - error: null, + isLoadingExpandedContent: false, + summaryError: null, + contentError: null, }; }, + computed: { + collapseButtonLabel() { + return sprintf(this.isCollapsed ? __('Show details') : __('Hide details')); + }, + summaryStatusIcon() { + return this.summaryError ? this.$options.failedStatusIcon : this.statusIconName; + }, + }, watch: { isLoading(newValue) { this.$emit('is-loading', newValue); @@ -85,12 +117,36 @@ export default { try { await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED); } catch { - this.error = this.errorText; + this.summaryError = this.errorText; } this.isLoading = false; }, methods: { + toggleCollapsed() { + this.isCollapsed = !this.isCollapsed; + + if (this.isExpandedForTheFirstTime && typeof this.fetchExpandedData === 'function') { + this.isExpandedForTheFirstTime = false; + this.fetchExpandedContent(); + } + }, + async fetchExpandedContent() { + this.isLoadingExpandedContent = true; + this.contentError = null; + + try { + await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED); + } catch { + this.contentError = this.errorText; + + // Reset these values so that we allow refetching + this.isExpandedForTheFirstTime = true; + this.isCollapsed = true; + } + + this.isLoadingExpandedContent = false; + }, fetch(handler, dataType) { const requests = this.multiPolling ? handler() : [handler]; @@ -125,6 +181,7 @@ export default { }); }, }, + failedStatusIcon: EXTENSION_ICONS.failed, }; </script> @@ -135,24 +192,58 @@ export default { :level="1" :name="widgetName" :is-loading="isLoading" - :icon-name="statusIconName" + :icon-name="summaryStatusIcon" /> <div class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" data-testid="widget-extension-top-level" > <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> - <slot name="summary">{{ isLoading ? loadingText : summary }}</slot> + <span v-if="summaryError">{{ summaryError }}</span> + <slot v-else name="summary">{{ isLoading ? loadingText : summary }}</slot> + </div> + <action-buttons + v-if="actionButtons.length > 0" + :widget="widgetName" + :tertiary-buttons="actionButtons" + /> + <div + v-if="isCollapsible" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" + > + <gl-button + v-gl-tooltip + :title="collapseButtonLabel" + :aria-expanded="`${!isCollapsed}`" + :aria-label="collapseButtonLabel" + :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + category="tertiary" + data-testid="toggle-button" + size="small" + @click="toggleCollapsed" + /> </div> - <!-- actions will go here --> - <!-- toggle button will go here --> </div> </div> <div + v-if="!isCollapsed || contentError" class="mr-widget-grouped-section gl-relative" data-testid="widget-extension-collapsed-section" > - <slot name="content">{{ content }}</slot> + <div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center"> + <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} + </div> + <content-section + v-else-if="contentError" + class="report-block-container" + :status-icon-name="$options.failedStatusIcon" + :widget-name="widgetName" + > + {{ contentError }} + </content-section> + <slot v-else name="content"> + {{ content }} + </slot> </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue new file mode 100644 index 00000000000..61e3744b5dc --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue @@ -0,0 +1,35 @@ +<script> +import { EXTENSION_ICONS } from '../../constants'; +import StatusIcon from '../extensions/status_icon.vue'; + +export default { + components: { + StatusIcon, + }, + props: { + statusIconName: { + type: String, + default: '', + required: false, + validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value), + }, + widgetName: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-px-7"> + <div class="gl-pl-4 gl-display-flex"> + <status-icon + v-if="statusIconName" + :level="2" + :name="widgetName" + :icon-name="statusIconName" + /> + <slot name="default"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index c148a35209f..be4e34ffff0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -140,6 +140,7 @@ export const EXTENSION_ICON_NAMES = { neutral: 'status-neutral', error: 'status-alert', notice: 'status-alert', + scheduled: 'status-scheduled', severityCritical: 'severity-critical', severityHigh: 'severity-high', severityMedium: 'severity-medium', @@ -155,6 +156,7 @@ export const EXTENSION_ICON_CLASS = { neutral: 'gl-text-gray-400', error: 'gl-text-red-500', notice: 'gl-text-gray-500', + scheduled: 'gl-text-blue-500', severityCritical: 'gl-text-red-800', severityHigh: 'gl-text-red-600', severityMedium: 'gl-text-orange-400', diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js index c74445a5b80..97b9b59e2c3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js @@ -32,7 +32,7 @@ export default { }); }); - return fileNames.join(' '); + return fileNames.join(' ').trim(); }, summary(data) { if (data.parsingInProgress) { diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js deleted file mode 100644 index 7b77d7475bc..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js +++ /dev/null @@ -1,10 +0,0 @@ -export default { - computed: { - triggered() { - return []; - }, - triggeredBy() { - return []; - }, - }, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 1e25143e15c..c8a2a8d119b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -59,28 +59,28 @@ export default { Loading, ExtensionsContainer, WidgetContainer, - 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, + MrWidgetSuggestPipeline: WidgetSuggestPipeline, MrWidgetPipelineContainer, MrWidgetAlertMessage, - 'mr-widget-merged': MergedState, - 'mr-widget-closed': ClosedState, - 'mr-widget-merging': MergingState, - 'mr-widget-failed-to-merge': FailedToMerge, - 'mr-widget-wip': WorkInProgressState, - 'mr-widget-archived': ArchivedState, - 'mr-widget-conflicts': ConflictsState, - 'mr-widget-nothing-to-merge': NothingToMergeState, - 'mr-widget-not-allowed': NotAllowedState, - 'mr-widget-missing-branch': MissingBranchState, - 'mr-widget-ready-to-merge': () => import('./components/states/new_ready_to_merge.vue'), - 'sha-mismatch': ShaMismatch, - 'mr-widget-checking': CheckingState, - 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, - 'mr-widget-pipeline-blocked': PipelineBlockedState, - 'mr-widget-pipeline-failed': PipelineFailedState, + MrWidgetMerged: MergedState, + MrWidgetClosed: ClosedState, + MrWidgetMerging: MergingState, + MrWidgetFailedToMerge: FailedToMerge, + MrWidgetWip: WorkInProgressState, + MrWidgetArchived: ArchivedState, + MrWidgetConflicts: ConflictsState, + MrWidgetNothingToMerge: NothingToMergeState, + MrWidgetNotAllowed: NotAllowedState, + MrWidgetMissingBranch: MissingBranchState, + MrWidgetReadyToMerge: () => import('./components/states/new_ready_to_merge.vue'), + ShaMismatch, + MrWidgetChecking: CheckingState, + MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState, + MrWidgetPipelineBlocked: PipelineBlockedState, + MrWidgetPipelineFailed: PipelineFailedState, MrWidgetAutoMergeEnabled, - 'mr-widget-auto-merge-failed': AutoMergeFailed, - 'mr-widget-rebase': RebaseState, + MrWidgetAutoMergeFailed: AutoMergeFailed, + MrWidgetRebase: RebaseState, SourceBranchRemovalStatus, GroupedCodequalityReportsApp: () => import('../reports/codequality_report/grouped_codequality_reports_app.vue'), @@ -230,6 +230,11 @@ export default { shouldShowCodeQualityExtension() { return window.gon?.features?.refactorCodeQualityExtension; }, + shouldShowMergeDetails() { + if (this.mr.state === 'readyToMerge') return true; + + return !this.mr.mergeDetailsCollapsed; + }, }, watch: { 'mr.machineValue': { @@ -318,6 +323,12 @@ export default { this.initPolling(); this.bindEventHubListeners(); eventHub.$on('mr.discussion.updated', this.checkStatus); + + window.addEventListener('resize', () => { + if (window.innerWidth >= 768) { + this.mr.toggleMergeDetails(false); + } + }); }, getServiceEndpoints(store) { return { @@ -428,6 +439,7 @@ export default { .then((res) => { if (res.data) { const el = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property el.innerHTML = res.data; document.body.appendChild(el); document.dispatchEvent(new CustomEvent('merged:UpdateActions')); @@ -620,7 +632,12 @@ export default { <div class="mr-widget-section" data-qa-selector="mr_widget_content"> <component :is="componentName" :mr="mr" :service="service" /> - <ready-to-merge v-if="mr.commitsCount" :mr="mr" :service="service" /> + <ready-to-merge + v-if="mr.commitsCount" + v-show="shouldShowMergeDetails" + :mr="mr" + :service="service" + /> </div> </div> <mr-widget-pipeline-container diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index 981c667f27a..eac72ffb2f2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -3,6 +3,7 @@ query getState($projectPath: ID!, $iid: String!) { id archived onlyAllowMergeIfPipelineSucceeds + allowMergeOnSkippedPipeline mergeRequest(iid: $iid) { id autoMergeEnabled diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 146cf7e11a7..e6ff586892f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -28,6 +28,7 @@ export default class MergeRequestStore { this.stateMachine = machine(STATE_MACHINE.definition); this.machineValue = this.stateMachine.value; + this.mergeDetailsCollapsed = window.innerWidth < 768; this.setPaths(data); @@ -168,6 +169,7 @@ export default class MergeRequestStore { this.mergeError = data.merge_error; this.mergeStatus = data.merge_status; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; + this.allowMergeOnSkippedPipeline = data.allow_merge_on_skipped_pipeline || false; this.projectArchived = data.project_archived; this.isSHAMismatch = this.sha !== data.diff_head_sha; this.shouldBeRebased = Boolean(data.should_be_rebased); @@ -195,6 +197,7 @@ export default class MergeRequestStore { this.projectArchived = project.archived; this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds; + this.allowMergeOnSkippedPipeline = project.allowMergeOnSkippedPipeline; this.autoMergeEnabled = mergeRequest.autoMergeEnabled; this.canBeMerged = mergeRequest.mergeStatus === 'can_be_merged'; @@ -403,4 +406,8 @@ export default class MergeRequestStore { this.transitionStateMachine(transitionOptions); } + + toggleMergeDetails(val = !this.mergeDetailsCollapsed) { + this.mergeDetailsCollapsed = val; + } } diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index 6db18afe51c..c6c22f9c61f 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -77,7 +77,7 @@ export default { <template v-for="(action, index) in actions"> <gl-dropdown-item :key="action.key" - :is-check-item="true" + is-check-item :is-checked="action.key === selectedAction.key" :secondary-text="action.secondaryText" :data-qa-selector="`${action.key}_menu_item`" diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 5de71c35be9..84bd6bca601 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; import CiIcon from './ci_icon.vue'; /** * Renders CI Badge link with CI icon and status text based on @@ -57,13 +58,28 @@ export default { }, cssClass() { const className = this.status.group; - return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge'; + return className ? `ci-status ci-${className}` : 'ci-status'; + }, + }, + methods: { + navigateToPipeline() { + visitUrl(this.detailsPath); + + // event used for tracking + this.$emit('ciStatusBadgeClick'); }, }, }; </script> <template> - <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title"> + <a + v-gl-tooltip + :class="cssClass" + class="gl-cursor-pointer" + :title="title" + data-qa-selector="status_badge_link" + @click="navigateToPipeline" + > <ci-icon :status="status" :css-classes="iconClasses" /> <template v-if="showText"> diff --git a/app/assets/javascripts/vue_shared/components/code_block.stories.js b/app/assets/javascripts/vue_shared/components/code_block.stories.js new file mode 100644 index 00000000000..e02a346c1de --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/code_block.stories.js @@ -0,0 +1,18 @@ +import CodeBlock from './code_block.vue'; + +export default { + component: CodeBlock, + title: 'vue_shared/code_block', +}; + +const Template = (args, { argTypes }) => ({ + components: { CodeBlock }, + props: Object.keys(argTypes), + template: '<code-block v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + // eslint-disable-next-line @gitlab/require-i18n-strings + code: `git commit -a "Message"\ngit push`, +}; diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue index 9856f35c7f6..4a69845d3a4 100644 --- a/app/assets/javascripts/vue_shared/components/code_block.vue +++ b/app/assets/javascripts/vue_shared/components/code_block.vue @@ -4,7 +4,8 @@ export default { props: { code: { type: String, - required: true, + required: false, + default: '', }, maxHeight: { type: String, @@ -32,5 +33,5 @@ export default { class="code-block rounded code" :class="$options.userColorScheme" :style="styleObject" - ><code class="d-block">{{ code }}</code></pre> + ><slot><code class="d-block">{{ code }}</code></slot></pre> </template> diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js new file mode 100644 index 00000000000..bf81a811d16 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js @@ -0,0 +1,18 @@ +import CodeBlockHighlighted from './code_block_highlighted.vue'; + +export default { + component: CodeBlockHighlighted, + title: 'vue_shared/code_block_highlighted', +}; + +const Template = (args, { argTypes }) => ({ + components: { CodeBlockHighlighted }, + props: Object.keys(argTypes), + template: '<code-block-highlighted v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + code: `const foo = 1;\nconsole.log(foo + ' yay')`, + language: 'javascript', +}; diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue new file mode 100644 index 00000000000..65b08b608e8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue @@ -0,0 +1,72 @@ +<script> +import { GlSafeHtmlDirective } from '@gitlab/ui'; + +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import CodeBlock from './code_block.vue'; + +export default { + name: 'CodeBlockHighlighted', + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + components: { + CodeBlock, + }, + props: { + code: { + type: String, + required: true, + }, + language: { + type: String, + required: true, + }, + maxHeight: { + type: String, + required: false, + default: 'initial', + }, + }, + data() { + return { + hljs: null, + languageLoaded: false, + }; + }, + computed: { + highlighted() { + if (this.hljs && this.languageLoaded) { + return this.hljs.highlight(this.code, { language: this.language }).value; + } + + return this.code; + }, + }, + async mounted() { + this.hljs = await this.loadHighlightJS(); + if (this.language) { + await this.loadLanguage(); + } + }, + methods: { + async loadLanguage() { + try { + const { default: languageDefinition } = await languageLoader[this.language](); + + this.hljs.registerLanguage(this.language, languageDefinition); + this.languageLoaded = true; + } catch (e) { + this.$emit('error', e); + } + }, + loadHighlightJS() { + return import('highlight.js/lib/core'); + }, + }, +}; +</script> +<template> + <code-block :max-height="maxHeight" class="highlight"> + <span v-safe-html="highlighted"></span> + </code-block> +</template> diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue index 91906388049..22f3c35b9c3 100644 --- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue +++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue @@ -42,8 +42,8 @@ export default { v-for="color in colors" :key="color.color" :is-checked="isColorSelected(color)" - :is-check-centered="true" - :is-check-item="true" + is-check-centered + is-check-item @click.native.capture.stop="handleColorClick(color)" > <color-item :color="color.color" :title="color.title" /> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js index 8481280f25f..7ecc309db52 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js @@ -3,7 +3,7 @@ import ConfirmDanger from './confirm_danger.vue'; export default { component: ConfirmDanger, - title: 'vue_shared/components/modals/confirm_danger_modal', + title: 'vue_shared/modals/confirm_danger_modal', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js index aec67a18a05..38b1a587b34 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -1,4 +1,4 @@ -import dateformat from 'dateformat'; +import dateformat from '~/lib/dateformat'; import { __ } from '~/locale'; /** diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js index eeed5e9dc3a..8256d953466 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js @@ -5,7 +5,7 @@ import DropdownWidget from './dropdown_widget.vue'; export default { component: DropdownWidget, - title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget', + title: 'vue_shared/dropdown/dropdown_widget/dropdown_widget', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index 840911dc99c..faa50a50c69 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -149,8 +149,8 @@ export default { v-for="option in presetOptions" :key="option.id" :is-checked="isSelected(option)" - :is-check-centered="true" - :is-check-item="true" + is-check-centered + is-check-item @click.native.capture.stop="selectOption(option)" > <slot name="preset-item" :item="option"> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 5d7f4ae2a01..ffe09634a3b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -46,7 +46,7 @@ export const SortDirection = { export const FILTERED_SEARCH_LABELS = 'labels'; export const FILTERED_SEARCH_TERM = 'filtered-search-term'; -export const TOKEN_TITLE_ASSIGNEE = __('Assignee'); +export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_CONTACT = s__('Crm|Contact'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 33d507dad57..e311df6e66f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -369,7 +369,7 @@ export default { <gl-dropdown-item v-for="sortBy in sortOptions" :key="sortBy.id" - :is-check-item="true" + is-check-item :is-checked="sortBy.id === selectedSortOption.id" @click="handleSortOptionClick(sortBy)" >{{ sortBy.title }}</gl-dropdown-item diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js index cdd7a074f34..377f1e7c136 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js @@ -2,7 +2,7 @@ import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue'; export default { component: InputCopyToggleVisibility, - title: 'vue_shared/components/form/input_copy_toggle_visibility', + title: 'vue_shared/form/input_copy_toggle_visibility', }; const defaultProps = { diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 1d1b65aa1af..458dfe0ed23 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -182,7 +182,7 @@ export default { <div class="md-header"> <gl-tabs content-class="gl-display-none"> <gl-tab - title-link-class="gl-pt-3 gl-px-3 js-md-write-button" + title-link-class="gl-py-4 gl-px-3 js-md-write-button" :title="$options.i18n.writeTabTitle" :active="!previewMarkdown" data-testid="write-tab" @@ -190,7 +190,7 @@ export default { /> <gl-tab v-if="enablePreview" - title-link-class="gl-pt-3 gl-px-3 js-md-preview-button" + title-link-class="gl-py-4 gl-px-3 js-md-preview-button" :title="$options.i18n.previewTabTitle" :active="previewMarkdown" data-testid="preview-tab" @@ -201,7 +201,7 @@ export default { <div data-testid="md-header-toolbar" :class="{ 'gl-display-none!': previewMarkdown }" - class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center" + class="md-header-toolbar gl-ml-auto gl-py-2 gl-justify-content-center" > <template v-if="canSuggest"> <toolbar-button diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index de3eda6b04f..9b81444fc04 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -163,6 +163,7 @@ export default { // resets the container HTML (replaces it with the updated noteHTML) // calls `renderSuggestions` once the updated noteHTML is added to the DOM + // eslint-disable-next-line no-unsanitized/property this.$refs.container.innerHTML = this.noteHtml; this.isRendered = false; this.renderSuggestions(); diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index aa325862f06..b5640e12541 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -72,7 +72,7 @@ export default { </gl-sprintf> </template> </div> - <span v-if="canAttachFile" class="uploading-container"> + <span v-if="canAttachFile" class="uploading-container gl-line-height-32"> <span class="uploading-progress-container hide"> <gl-icon name="paperclip" /> <span class="attaching-file-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 3593ea16968..7e99f1b01b2 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -29,7 +29,7 @@ import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_ import '~/behaviors/markdown/render_gfm'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import noteHeader from '~/notes/components/note_header.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { spriteIcon } from '~/lib/utils/common_utils'; import TimelineEntryItem from './timeline_entry_item.vue'; @@ -43,7 +43,7 @@ export default { name: 'SystemNote', components: { GlIcon, - noteHeader, + NoteHeader, TimelineEntryItem, GlButton, GlSkeletonLoader, diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js index e31446f4bb8..f16afc77164 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js @@ -3,7 +3,7 @@ import PaginationBar from './pagination_bar.vue'; export default { component: PaginationBar, - title: 'vue_shared/components/pagination_bar/pagination_bar', + title: 'vue_shared/pagination_bar/pagination_bar', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js index 110c6c73bad..bfb30c74cb8 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js +++ b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js @@ -2,7 +2,7 @@ import ProjectAvatar from './project_avatar.vue'; export default { component: ProjectAvatar, - title: 'vue_shared/components/project_avatar', + title: 'vue_shared/project_avatar', }; const Template = (args, { argTypes }) => ({ @@ -13,8 +13,7 @@ const Template = (args, { argTypes }) => ({ export const Default = Template.bind({}); Default.args = { - projectAvatarUrl: - 'https://gitlab.com/uploads/-/system/project/avatar/278964/logo-extra-whitespace.png?width=64', + projectAvatarUrl: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png', projectName: 'GitLab', }; diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js index 9700117a3da..4021e23a3f6 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js @@ -2,7 +2,7 @@ import ProjectListItem from './project_list_item.vue'; export default { component: ProjectListItem, - title: 'vue_shared/components/project_selector/project_list_item', + title: 'vue_shared/project_selector/project_list_item', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue index 43a8e241d77..32d7cdad568 100644 --- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue +++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue @@ -49,7 +49,7 @@ export default { v-for="option in parsedOptions" :key="option.value" :is-checked="option.selected" - :is-check-item="true" + is-check-item @click="setSelected(option.value)" > {{ option.label }} diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index bfaf3b92c34..c5d3704ead9 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -253,7 +253,7 @@ export default { <gl-dropdown-item v-for="architecture in architectures" :key="architecture.name" - :is-check-item="true" + is-check-item :is-checked="selectedArchitecture === architecture.name" data-testid="architecture-dropdown-item" @click="selectArchitecture(architecture.name)" diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js index 5242743ad30..53e4a08e486 100644 --- a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js @@ -2,7 +2,7 @@ import SettingsBlock from './settings_block.vue'; export default { component: SettingsBlock, - title: 'vue_shared/components/settings/settings_block', + title: 'vue_shared/settings/settings_block', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue index dfa2ca2d20c..0f5560ff628 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue @@ -180,7 +180,7 @@ export default { <gl-dropdown-item v-for="project in projects" :key="project.id" - :is-check-item="true" + is-check-item :is-checked="isSelectedProject(project)" @click.stop.prevent="handleProjectSelect(project)" >{{ project.name_with_namespace }}</gl-dropdown-item diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index f595e635f2c..8d3d4d5f86a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -154,8 +154,8 @@ export default { v-for="(label, index) in visibleLabels" :key="label.id" :is-checked="isLabelSelected(label)" - :is-check-centered="true" - :is-check-item="true" + is-check-centered + is-check-item :active="shouldHighlightFirstItem && index === 0" active-class="is-focused" data-testid="labels-list" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue index aaddab43e2a..154a8e866d0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -80,6 +80,7 @@ export default { v-if="!showDropdownContentsCreateView" ref="searchInput" :value="searchKey" + :placeholder="__('Search labels')" :disabled="labelsFetchInProgress" data-qa-selector="dropdown_input_field" data-testid="dropdown-input-field" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js index 294e5bd9f90..8a2bab4cb9a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js @@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue'; export default { component: TodoButton, - title: 'vue_shared/components/sidebar/todo_toggle/todo_button', + title: 'vue_shared/sidebar/todo_toggle/todo_button', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index cc930d67fa4..30f57f506a6 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -81,6 +81,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = { protobuf: 'protobuf', puppet: 'puppet', python: 'python', + python3: 'python', q: 'q', qml: 'qml', r: 'r', diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js index 5be92af5b55..8b52df83fdf 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js @@ -3,6 +3,8 @@ import { HLJS_COMMENT_SELECTOR } from '../constants'; const createWrapper = (content) => { const span = document.createElement('span'); span.className = HLJS_COMMENT_SELECTOR; + + // eslint-disable-next-line no-unsanitized/property span.innerHTML = content; return span.outerHTML; }; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index f471db24889..9c6c12eac7d 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -42,7 +42,7 @@ export default { return { languageDefinition: null, content: this.blob.rawTextBlob, - language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language], + language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], hljs: null, firstChunk: null, chunks: {}, @@ -62,7 +62,7 @@ export default { const supportedLanguages = Object.keys(languageLoader); return ( !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language) + !supportedLanguages.includes(this.blob.language?.toLowerCase()) ); }, }, diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue index 994fa68fb1a..c0aef42b0f2 100644 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -68,7 +68,7 @@ export default { <template v-for="(item, itemIndex) in actionItems"> <gl-dropdown-item :key="item.eventName" - :is-check-item="true" + is-check-item :is-checked="selectedItem === item" @click="changeSelectedItem(item)" > diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue index 42334d80eec..ce65266cbc9 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue @@ -72,7 +72,7 @@ export default { v-for="timezone in filteredResults" :key="timezone.formattedTimezone" :is-checked="isSelected(timezone)" - :is-check-item="true" + is-check-item @click="selectTimezone(timezone)" > {{ timezone.formattedTimezone }} diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js index f27901a30a9..e621442e601 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js @@ -5,7 +5,7 @@ const defaultWidth = '250px'; export default { component: TooltipOnTruncate, - title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue', + title: 'vue_shared/tooltip_on_truncate/tooltip_on_truncate.vue', }; const createStory = ({ ...options }) => { diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index 424cab20c7e..a001b6bdf24 100644 --- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -149,7 +149,7 @@ export default { > <slot> <button - class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0" type="button" @click="openFileUpload" > diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue index cd610314292..6bd66981860 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue @@ -90,9 +90,8 @@ export default { </script> <template> - <span> + <span ref="userAvatar"> <gl-avatar - ref="userAvatar" :class="{ lazy: lazy, [cssClasses]: true, @@ -108,7 +107,7 @@ export default { tooltipText || $slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */ " - :target="() => $refs.userAvatar.$el" + :target="() => $refs.userAvatar" :placement="tooltipPlacement" boundary="window" > diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js index d2030c14029..1f0f4cde234 100644 --- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js @@ -5,7 +5,7 @@ import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue'; export default { component: UserDeletionObstaclesList, - title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list', + title: 'vue_shared/user_deletion_obstacles/user_deletion_obstacles_list', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/user_popover/constants.js b/app/assets/javascripts/vue_shared/components/user_popover/constants.js index 1d49aefd297..bcbe72b4b4f 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/constants.js +++ b/app/assets/javascripts/vue_shared/components/user_popover/constants.js @@ -1 +1,14 @@ +import { __ } from '~/locale'; + export const USER_POPOVER_DELAY = 200; +export const I18N_ERROR_FOLLOW = __( + 'An error occurred while trying to follow this user, please try again.', +); +export const I18N_ERROR_UNFOLLOW = __( + 'An error occurred while trying to unfollow this user, please try again.', +); +export const I18N_USER_BLOCKED = __('User is blocked'); +export const I18N_USER_BUSY = __('Busy'); +export const I18N_USER_LEARN = __('Learn more about %{name}'); +export const I18N_USER_FOLLOW = __('Follow'); +export const I18N_USER_UNFOLLOW = __('Unfollow'); diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 2b9804796ae..4b39a8e45bb 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -9,23 +9,31 @@ import { GlButton, GlAvatarLabeled, } from '@gitlab/ui'; -import { __ } from '~/locale'; import { glEmojiTag } from '~/emoji'; import createFlash from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; import { isUserBusy } from '~/set_status_modal/utils'; import Tracking from '~/tracking'; -import { USER_POPOVER_DELAY } from './constants'; +import { + I18N_ERROR_FOLLOW, + I18N_ERROR_UNFOLLOW, + I18N_USER_BLOCKED, + I18N_USER_BUSY, + I18N_USER_LEARN, + I18N_USER_FOLLOW, + I18N_USER_UNFOLLOW, + USER_POPOVER_DELAY, +} from './constants'; const MAX_SKELETON_LINES = 4; export default { name: 'UserPopover', maxSkeletonLines: MAX_SKELETON_LINES, + I18N_USER_BLOCKED, + I18N_USER_BUSY, + I18N_USER_LEARN, USER_POPOVER_DELAY, - i18n: { - busy: __('Busy'), - }, components: { GlIcon, GlLink, @@ -94,7 +102,7 @@ export default { toggleFollowButtonText() { if (this.toggleFollowLoading) return null; - return this.user?.isFollowed ? __('Unfollow') : __('Follow'); + return this.user?.isFollowed ? I18N_USER_UNFOLLOW : I18N_USER_FOLLOW; }, toggleFollowButtonVariant() { return this.user?.isFollowed ? 'default' : 'confirm'; @@ -102,6 +110,9 @@ export default { hasPronouns() { return Boolean(this.user?.pronouns?.trim()); }, + isBlocked() { + return this.user?.state === 'blocked'; + }, isBusy() { return isUserBusy(this.availabilityStatus); }, @@ -129,7 +140,7 @@ export default { this.$emit('follow'); } catch (error) { createFlash({ - message: __('An error occurred while trying to follow this user, please try again.'), + message: I18N_ERROR_FOLLOW, error, captureError: true, }); @@ -149,7 +160,7 @@ export default { this.$emit('unfollow'); } catch (error) { createFlash({ - message: __('An error occurred while trying to unfollow this user, please try again.'), + message: I18N_ERROR_UNFOLLOW, error, captureError: true, }); @@ -189,16 +200,21 @@ export default { :label="user.name" :sub-label="username" > - <gl-button - v-if="shouldRenderToggleFollowButton" - class="gl-mt-3 gl-align-self-start" - :variant="toggleFollowButtonVariant" - :loading="toggleFollowLoading" - size="small" - data-testid="toggle-follow-button" - @click="toggleFollow" - >{{ toggleFollowButtonText }}</gl-button - > + <template v-if="isBlocked"> + <span class="gl-mt-4 gl-font-style-italic">{{ $options.I18N_USER_BLOCKED }}</span> + </template> + <template v-else> + <gl-button + v-if="shouldRenderToggleFollowButton" + class="gl-mt-3 gl-align-self-start" + :variant="toggleFollowButtonVariant" + :loading="toggleFollowLoading" + size="small" + data-testid="toggle-follow-button" + @click="toggleFollow" + >{{ toggleFollowButtonText }}</gl-button + > + </template> <template #meta> <span @@ -208,7 +224,7 @@ export default { >({{ user.pronouns }})</span > <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1" - >({{ $options.i18n.busy }})</span + >({{ $options.I18N_USER_BUSY }})</span > </template> </gl-avatar-labeled> @@ -223,39 +239,41 @@ export default { /> </template> <template v-else> - <div class="gl-text-gray-500"> - <div v-if="user.bio" class="gl-display-flex gl-mb-2"> - <gl-icon name="profile" class="gl-flex-shrink-0" /> - <span ref="bio" class="gl-ml-2">{{ user.bio }}</span> + <template v-if="!isBlocked"> + <div class="gl-text-gray-500"> + <div v-if="user.bio" class="gl-display-flex gl-mb-2"> + <gl-icon name="profile" class="gl-flex-shrink-0" /> + <span ref="bio" class="gl-ml-2">{{ user.bio }}</span> + </div> + <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> + <gl-icon name="work" class="gl-flex-shrink-0" /> + <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> + </div> + <div v-if="user.location" class="gl-display-flex gl-mb-2"> + <gl-icon name="location" class="gl-flex-shrink-0" /> + <span class="gl-ml-2">{{ user.location }}</span> + </div> + <div + v-if="user.localTime && !user.bot" + class="gl-display-flex gl-mb-2" + data-testid="user-popover-local-time" + > + <gl-icon name="clock" class="gl-flex-shrink-0" /> + <span class="gl-ml-2">{{ user.localTime }}</span> + </div> </div> - <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> - <gl-icon name="work" class="gl-flex-shrink-0" /> - <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> + <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status"> + <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> - <div v-if="user.location" class="gl-display-flex gl-mb-2"> - <gl-icon name="location" class="gl-flex-shrink-0" /> - <span class="gl-ml-2">{{ user.location }}</span> + <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500"> + <gl-icon name="question" /> + <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> + <gl-sprintf :message="$options.I18N_USER_LEARN"> + <template #name>{{ user.name }}</template> + </gl-sprintf> + </gl-link> </div> - <div - v-if="user.localTime && !user.bot" - class="gl-display-flex gl-mb-2" - data-testid="user-popover-local-time" - > - <gl-icon name="clock" class="gl-flex-shrink-0" /> - <span class="gl-ml-2">{{ user.localTime }}</span> - </div> - </div> - <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status"> - <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> - </div> - <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500"> - <gl-icon name="question" /> - <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> - <gl-sprintf :message="__('Learn more about %{username}')"> - <template #username>{{ user.name }}</template> - </gl-sprintf> - </gl-link> - </div> + </template> </template> </div> </gl-popover> diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 43a590c2367..3180bd0d283 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -320,7 +320,7 @@ export default { <gl-dropdown-item v-if="isSearchEmpty" :is-checked="selectedIsEmpty" - :is-check-centered="true" + is-check-centered data-testid="unassign" @click.native.capture.stop="$emit('input', [])" > diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 38083327593..7e735f358eb 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -232,7 +232,11 @@ export default { </span> </div> <div class="issuable-info"> - <work-item-type-icon v-if="showWorkItemTypeIcon" :work-item-type="issuable.type" /> + <work-item-type-icon + v-if="showWorkItemTypeIcon" + :work-item-type="issuable.type" + show-tooltip-on-hover + /> <slot v-if="hasSlotContents('reference')" name="reference"></slot> <span v-else data-testid="issuable-reference" class="issuable-reference"> {{ reference }} diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue index d2fc2c66924..e42720bf1db 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue @@ -10,6 +10,7 @@ export default { mounted() { const legacyEntry = document.querySelector(this.selector); if (legacyEntry.tagName === 'TEMPLATE') { + // eslint-disable-next-line no-unsanitized/property this.$el.innerHTML = legacyEntry.innerHTML; } else { this.source = legacyEntry.parentNode; diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql index 2e80db30e9a..6a83669d206 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql @@ -14,6 +14,7 @@ query securityReportDownloadPaths( id name artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { downloadPath fileType diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql index e4f0c392b91..1f1e56a5876 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -4,6 +4,7 @@ query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityRe project(fullPath: $projectPath) { id pipeline(iid: $iid) { + # eslint-disable-next-line @graphql-eslint/require-id-when-available ...JobArtifacts } } diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js index af671e72129..c1baa7b8dd3 100644 --- a/app/assets/javascripts/webpack_non_compiled_placeholder.js +++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js @@ -20,6 +20,7 @@ const reloadMessage = LIVE_RELOAD ? 'You have live_reload enabled, the page will reload automatically when complete.' : 'You have live_reload disabled, the page will reload automatically in a few seconds.'; +// eslint-disable-next-line no-unsanitized/property div.innerHTML = ` <!-- https://github.com/webpack/media/blob/master/logo/icon-square-big.svg --> <svg height="50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200"> diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 551ebbadb21..b2c8b7ae1db 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -39,14 +39,14 @@ export default { :class="{ 'gl-cursor-text': disabled }" aria-labelledby="item-title" > - <div + <span id="item-title" ref="titleEl" role="textbox" :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base" + class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base gl-display-block" :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }" @blur="handleBlur" @keyup="handleInput" @@ -55,8 +55,7 @@ export default { @keydown.meta.u.prevent @keydown.ctrl.b.prevent @keydown.meta.b.prevent + >{{ title }}</span > - {{ title }} - </div> </h2> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 2753c3fa388..9f9d94ec3c2 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -8,10 +8,14 @@ import { } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { + sprintfWorkItem, + I18N_WORK_ITEM_DELETE, + I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, +} from '../constants'; export default { i18n: { - deleteTask: s__('WorkItem|Delete task'), enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), }, @@ -31,6 +35,11 @@ export default { required: false, default: null, }, + workItemType: { + type: String, + required: false, + default: null, + }, canUpdate: { type: Boolean, required: false, @@ -53,6 +62,14 @@ export default { }, }, emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'], + computed: { + i18n() { + return { + deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType), + areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType), + }; + }, + }, methods: { handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); @@ -75,6 +92,7 @@ export default { <div> <gl-dropdown icon="ellipsis_v" + data-testid="work-item-actions-dropdown" text-sr-only :text="__('More actions')" category="tertiary" @@ -97,20 +115,18 @@ export default { v-if="canDelete" v-gl-modal="'work-item-confirm-delete'" data-testid="delete-action" - >{{ $options.i18n.deleteTask }}</gl-dropdown-item + >{{ i18n.deleteWorkItem }}</gl-dropdown-item > </gl-dropdown> <gl-modal modal-id="work-item-confirm-delete" - :title="$options.i18n.deleteWorkItem" - :ok-title="$options.i18n.deleteWorkItem" + :title="i18n.deleteWorkItem" + :ok-title="i18n.deleteWorkItem" ok-variant="danger" @ok="handleDeleteWorkItem" @hide="handleCancelDeleteWorkItem" > - {{ - s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.') - }} + {{ i18n.areYouSureDelete }} </gl-modal> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 7342f215b5e..4585426edaa 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -8,6 +8,7 @@ import { GlButton, GlDropdownItem, GlDropdownDivider, + GlIntersectionObserver, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -19,7 +20,7 @@ import Tracking from '~/tracking'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants'; function isTokenSelectorElement(el) { return ( @@ -50,9 +51,9 @@ export default { InviteMembersTrigger, GlDropdownItem, GlDropdownDivider, + GlIntersectionObserver, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, @@ -80,6 +81,10 @@ export default { required: false, default: false, }, + fullPath: { + type: String, + required: true, + }, }, data() { return { @@ -87,12 +92,15 @@ export default { searchStarted: false, localAssignees: this.assignees.map(addClass), searchKey: '', - searchUsers: [], + users: { + nodes: [], + }, currentUser: null, + isLoadingMore: false, }; }, apollo: { - searchUsers: { + users: { query() { return userSearchQuery; }, @@ -100,13 +108,14 @@ export default { return { fullPath: this.fullPath, search: this.searchKey, + first: DEFAULT_PAGE_SIZE_ASSIGNEES, }; }, skip() { return !this.searchStarted; }, update(data) { - return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user })); + return data.workspace?.users; }, error() { this.$emit('error', i18n.fetchError); @@ -117,6 +126,12 @@ export default { }, }, computed: { + searchUsers() { + return this.users.nodes.map((node) => addClass({ ...node, ...node.user })); + }, + pageInfo() { + return this.users.pageInfo; + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -131,7 +146,7 @@ export default { return !this.isEditing ? 'gl-shadow-none!' : ''; }, isLoadingUsers() { - return this.$apollo.queries.searchUsers.loading; + return this.$apollo.queries.users.loading; }, assigneeText() { return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); @@ -159,6 +174,12 @@ export default { assigneeIds() { return this.localAssignees.map(({ id }) => id); }, + hasNextPage() { + return this.pageInfo?.hasNextPage; + }, + showIntersectionSkeletonLoader() { + return this.isLoadingMore && this.dropdownItems.length; + }, }, watch: { assignees: { @@ -221,6 +242,16 @@ export default { this.isEditing = true; this.searchStarted = true; }, + async fetchMoreAssignees() { + this.isLoadingMore = true; + await this.$apollo.queries.users.fetchMore({ + variables: { + after: this.pageInfo.endCursor, + first: DEFAULT_PAGE_SIZE_ASSIGNEES, + }, + }); + this.isLoadingMore = false; + }, async focusTokenSelector() { this.handleFocus(); await this.$nextTick(); @@ -263,7 +294,7 @@ export default { </script> <template> - <div class="form-row gl-mb-5 work-item-assignees gl-relative"> + <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap"> <span class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" data-testid="assignees-title" @@ -275,7 +306,7 @@ export default { :container-class="containerClass" :class="{ 'gl-hover-border-gray-200': canUpdate }" :dropdown-items="dropdownItems" - :loading="isLoadingUsers" + :loading="isLoadingUsers && !isLoadingMore" :view-only="!canUpdate" :allow-clear-all="isEditing" class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" @@ -326,17 +357,32 @@ export default { <rect width="280" height="20" x="10" y="130" rx="4" /> </gl-skeleton-loader> </template> - <template v-if="canInviteMembers" #dropdown-footer> - <gl-dropdown-divider /> - <gl-dropdown-item @click="closeDropdown"> - <invite-members-trigger - :display-text="__('Invite members')" - trigger-element="side-nav" - icon="plus" - trigger-source="work-item-assignees-dropdown" - classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" - /> - </gl-dropdown-item> + <template #dropdown-footer> + <gl-intersection-observer + v-if="hasNextPage && !isLoadingUsers" + @appear="fetchMoreAssignees" + /> + <gl-skeleton-loader + v-if="showIntersectionSkeletonLoader" + :height="100" + data-testid="next-page-loading" + class="gl-text-center gl-py-3" + > + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + </gl-skeleton-loader> + <div v-if="canInviteMembers"> + <gl-dropdown-divider /> + <gl-dropdown-item @click="closeDropdown"> + <invite-members-trigger + :display-text="__('Invite members')" + trigger-element="side-nav" + icon="plus" + trigger-source="work-item-assignees-dropdown" + classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" + /> + </gl-dropdown-item> + </div> </template> </gl-token-selector> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index cf59789ce2d..c2e4a50fe31 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -8,7 +8,7 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import workItemQuery from '../graphql/work_item.query.graphql'; -import updateWorkItemWidgetsMutation from '../graphql/update_work_item_widgets.mutation.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; export default { @@ -21,12 +21,15 @@ export default { MarkdownField, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, required: true, }, + fullPath: { + type: String, + required: true, + }, }, markdownDocsPath: helpPagePath('user/markdown'), data() { @@ -139,9 +142,9 @@ export default { this.track('updated_description'); const { - data: { workItemUpdateWidgets }, + data: { workItemUpdate }, } = await this.$apollo.mutate({ - mutation: updateWorkItemWidgetsMutation, + mutation: updateWorkItemMutation, variables: { input: { id: this.workItem.id, @@ -152,8 +155,8 @@ export default { }, }); - if (workItemUpdateWidgets.errors?.length) { - throw new Error(workItemUpdateWidgets.errors[0]); + if (workItemUpdate.errors?.length) { + throw new Error(workItemUpdate.errors[0]); } this.isEditing = false; diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index a5580c14a7a..3d25df9fcb8 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -16,12 +16,14 @@ import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_DESCRIPTION, + WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; +import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; @@ -30,9 +32,9 @@ import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; import WorkItemDescription from './work_item_description.vue'; +import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; -import WorkItemWeight from './work_item_weight.vue'; import WorkItemInformation from './work_item_information.vue'; export default { @@ -50,10 +52,11 @@ export default { WorkItemAssignees, WorkItemActions, WorkItemDescription, + WorkItemDueDate, WorkItemLabels, WorkItemTitle, WorkItemState, - WorkItemWeight, + WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemInformation, LocalStorageSync, WorkItemTypeIcon, @@ -98,14 +101,36 @@ export default { error() { this.error = this.$options.i18n.fetchError; }, - subscribeToMore: { - document: workItemTitleSubscription, - variables() { - return { - issuableId: this.workItemId, - }; - }, + result() { + if (!this.isModal) { + const path = this.workItem.project?.fullPath + ? ` · ${this.workItem.project.fullPath}` + : ''; + + document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`; + } }, + subscribeToMore: [ + { + document: workItemTitleSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + }, + { + document: workItemDatesSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + skip() { + return !this.workItemDueDate; + }, + }, + ], }, }, computed: { @@ -121,6 +146,9 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, + fullPath() { + return this.workItem?.project.fullPath; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, @@ -133,6 +161,11 @@ export default { workItemLabels() { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, + workItemDueDate() { + return this.workItem?.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE, + ); + }, workItemWeight() { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, @@ -276,11 +309,12 @@ export default { <work-item-actions v-if="canUpdate || canDelete" :work-item-id="workItem.id" + :work-item-type="workItemType" :can-delete="canDelete" :can-update="canUpdate" :is-confidential="workItem.confidential" :is-parent-confidential="parentWorkItemConfidentiality" - @deleteWorkItem="$emit('deleteWorkItem')" + @deleteWorkItem="$emit('deleteWorkItem', workItemType)" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="error = $event" /> @@ -317,21 +351,32 @@ export default { :can-update="canUpdate" @error="error = $event" /> + <work-item-assignees + v-if="workItemAssignees" + :can-update="canUpdate" + :work-item-id="workItem.id" + :assignees="workItemAssignees.assignees.nodes" + :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" + :work-item-type="workItemType" + :can-invite-members="workItemAssignees.canInviteMembers" + :full-path="fullPath" + @error="error = $event" + /> <template v-if="workItemsMvc2Enabled"> - <work-item-assignees - v-if="workItemAssignees" - :can-update="canUpdate" - :work-item-id="workItem.id" - :assignees="workItemAssignees.assignees.nodes" - :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" - :work-item-type="workItemType" - :can-invite-members="workItemAssignees.canInviteMembers" - @error="error = $event" - /> <work-item-labels v-if="workItemLabels" :work-item-id="workItem.id" :can-update="canUpdate" + :full-path="fullPath" + @error="error = $event" + /> + <work-item-due-date + v-if="workItemDueDate" + :can-update="canUpdate" + :due-date="workItemDueDate.dueDate" + :start-date="workItemDueDate.startDate" + :work-item-id="workItem.id" + :work-item-type="workItemType" @error="error = $event" /> </template> @@ -347,6 +392,7 @@ export default { <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" + :full-path="fullPath" class="gl-pt-5" @error="error = $event" /> diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue new file mode 100644 index 00000000000..05f8fa8f5e1 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -0,0 +1,257 @@ +<script> +import { GlButton, GlDatepicker, GlFormGroup } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + TRACKING_CATEGORY_SHOW, +} from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; + +const nullObjectDate = new Date(0); + +export default { + i18n: { + addDueDate: s__('WorkItem|Add due date'), + addStartDate: s__('WorkItem|Add start date'), + dates: s__('WorkItem|Dates'), + dueDate: s__('WorkItem|Due date'), + none: s__('WorkItem|None'), + startDate: s__('WorkItem|Start date'), + }, + dueDateInputId: 'due-date-input', + startDateInputId: 'start-date-input', + components: { + GlButton, + GlDatepicker, + GlFormGroup, + }, + mixins: [Tracking.mixin()], + props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + dueDate: { + type: String, + required: false, + default: null, + }, + startDate: { + type: String, + required: false, + default: null, + }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + dirtyDueDate: null, + dirtyStartDate: null, + isUpdating: false, + showDueDateInput: false, + showStartDateInput: false, + }; + }, + computed: { + datesUnchanged() { + const dirtyDueDate = this.dirtyDueDate || nullObjectDate; + const dirtyStartDate = this.dirtyStartDate || nullObjectDate; + const dueDate = this.dueDate ? newDateAsLocaleTime(this.dueDate) : nullObjectDate; + const startDate = this.startDate ? newDateAsLocaleTime(this.startDate) : nullObjectDate; + return ( + dirtyDueDate.getTime() === dueDate.getTime() && + dirtyStartDate.getTime() === startDate.getTime() + ); + }, + isDatepickerDisabled() { + return !this.canUpdate || this.isUpdating; + }, + isReadonlyWithOnlyDueDate() { + return !this.canUpdate && this.dueDate && !this.startDate; + }, + isReadonlyWithOnlyStartDate() { + return !this.canUpdate && !this.dueDate && this.startDate; + }, + isReadonlyWithNoDates() { + return !this.canUpdate && !this.dueDate && !this.startDate; + }, + labelClass() { + return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!'; + }, + showDueDateButton() { + return this.canUpdate && !this.showDueDateInput; + }, + showStartDateButton() { + return this.canUpdate && !this.showStartDateInput; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_dates', + property: `type_${this.workItemType}`, + }; + }, + }, + watch: { + dueDate: { + handler(newDueDate) { + this.dirtyDueDate = newDateAsLocaleTime(newDueDate); + this.showDueDateInput = Boolean(newDueDate); + }, + immediate: true, + }, + startDate: { + handler(newStartDate) { + this.dirtyStartDate = newDateAsLocaleTime(newStartDate); + this.showStartDateInput = Boolean(newStartDate); + }, + immediate: true, + }, + }, + methods: { + clearDueDatePicker() { + this.dirtyDueDate = null; + this.showDueDateInput = false; + this.updateDates(); + }, + clearStartDatePicker() { + this.dirtyStartDate = null; + this.showStartDateInput = false; + this.updateDates(); + }, + async clickShowDueDate() { + this.showDueDateInput = true; + await this.$nextTick(); + this.$refs.dueDatePicker.calendar.show(); + }, + async clickShowStartDate() { + this.showStartDateInput = true; + await this.$nextTick(); + this.$refs.startDatePicker.calendar.show(); + }, + handleStartDateInput() { + if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) { + this.dirtyDueDate = this.dirtyStartDate; + this.clickShowDueDate(); + return; + } + + this.updateDates(); + }, + updateDates() { + if (!this.canUpdate || this.datesUnchanged) { + return; + } + + this.track('updated_dates'); + + this.isUpdating = true; + + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + startAndDueDateWidget: { + dueDate: getDateWithUTC(this.dirtyDueDate), + startDate: getDateWithUTC(this.dirtyStartDate), + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('; ')); + } + }) + .catch((error) => { + const message = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', message); + Sentry.captureException(error); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + }, +}; +</script> + +<template> + <gl-form-group + class="work-item-due-date" + :label="$options.i18n.dates" + :label-class="labelClass" + label-cols="3" + label-cols-lg="2" + > + <span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4"> + {{ $options.i18n.none }} + </span> + <div v-else class="gl-display-flex gl-flex-wrap gl-gap-5"> + <gl-form-group + class="gl-display-flex gl-align-items-center gl-m-0" + :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }" + :label="$options.i18n.startDate" + :label-for="$options.startDateInputId" + :label-sr-only="!showStartDateInput" + label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3" + > + <gl-datepicker + v-if="showStartDateInput" + ref="startDatePicker" + v-model="dirtyStartDate" + container="body" + :disabled="isDatepickerDisabled" + :input-id="$options.startDateInputId" + show-clear-button + :target="null" + @clear="clearStartDatePicker" + @close="handleStartDateInput" + /> + <gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate"> + {{ $options.i18n.addStartDate }} + </gl-button> + </gl-form-group> + <gl-form-group + v-if="!isReadonlyWithOnlyStartDate" + class="gl-display-flex gl-align-items-center gl-m-0" + :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }" + :label="$options.i18n.dueDate" + :label-for="$options.dueDateInputId" + :label-sr-only="!showDueDateInput" + label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3" + > + <gl-datepicker + v-if="showDueDateInput" + ref="dueDatePicker" + v-model="dirtyDueDate" + container="body" + :disabled="isDatepickerDisabled" + :input-id="$options.dueDateInputId" + :min-date="dirtyStartDate" + show-clear-button + :target="null" + @clear="clearDueDatePicker" + @close="updateDates" + /> + <gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate"> + {{ $options.i18n.addDueDate }} + </gl-button> + </gl-form-group> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue index 2ff7ba169ea..ce75cc98a75 100644 --- a/app/assets/javascripts/work_items/components/work_item_information.vue +++ b/app/assets/javascripts/work_items/components/work_item_information.vue @@ -5,16 +5,14 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export default { i18n: { - learnTasksButtonText: s__('WorkItem|Learn about tasks'), - workItemsText: s__('WorkItem|work items'), + learnTasksLinkText: s__('WorkItem|Learn about tasks.'), tasksInformationTitle: s__('WorkItem|Introducing tasks'), tasksInformationBody: s__( - 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.', + 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}', ), }, helpPageLinks: { tasksDocLinkPath: helpPagePath('user/tasks'), - workItemsLinkPath: helpPagePath(`development/work_items`), }, components: { GlAlert, @@ -38,16 +36,14 @@ export default { v-if="showInfoBanner" variant="tip" :title="$options.i18n.tasksInformationTitle" - :primary-button-link="$options.helpPageLinks.tasksDocLinkPath" - :primary-button-text="$options.i18n.learnTasksButtonText" data-testid="work-item-information" class="gl-mt-3" @dismiss="$emit('work-item-banner-dismissed')" > <gl-sprintf :message="$options.i18n.tasksInformationBody"> - <template #workItemsLink> - <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{ - $options.i18n.workItemsText + <template #learnMoreLink> + <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{ + $options.i18n.learnTasksLinkText }}</gl-link> </template> ></gl-sprintf diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index e73488bbd70..b8b5198be57 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -31,7 +31,6 @@ export default { LabelItem, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, @@ -41,6 +40,10 @@ export default { type: Boolean, required: true, }, + fullPath: { + type: String, + required: true, + }, }, data() { return { @@ -189,7 +192,7 @@ export default { </script> <template> - <div class="form-row gl-mb-5 work-item-labels gl-relative"> + <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap"> <span class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" data-testid="labels-title" @@ -216,7 +219,7 @@ export default { class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2" data-testid="empty-state" > - <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span> + <span v-if="canUpdate" class="gl-ml-2">{{ __('Add labels') }}</span> <span v-else class="gl-ml-2">{{ __('None') }}</span> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 86f03583ea3..8f31b07b6a3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import { createApolloProvider } from '../../graphql/provider'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import WorkItemLinks from './work_item_links.vue'; Vue.use(GlToast); @@ -16,18 +16,19 @@ export default function initWorkItemLinks() { return; } - const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset; + const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, name: 'WorkItemLinksRoot', - apolloProvider: createApolloProvider(), + apolloProvider, components: { - workItemLinks: WorkItemLinks, + WorkItemLinks, }, provide: { projectPath, + iid, fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, }, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue new file mode 100644 index 00000000000..34874908f9b --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -0,0 +1,109 @@ +<script> +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; + +import { STATE_OPEN } from '../../constants'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; + +export default { + components: { + GlButton, + GlIcon, + RichTimestampTooltip, + WorkItemLinksMenu, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectPath: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + issuableGid: { + type: String, + required: true, + }, + childItem: { + type: Object, + required: true, + }, + }, + computed: { + isItemOpen() { + return this.childItem.state === STATE_OPEN; + }, + iconClass() { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + }, + iconName() { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + }, + stateTimestamp() { + return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; + }, + stateTimestampTypeText() { + return this.isItemOpen ? __('Created') : __('Closed'); + }, + childPath() { + return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; + }, + }, +}; +</script> + +<template> + <div + class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + data-testid="links-child" + > + <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> + <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon"> + <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <gl-icon + v-if="childItem.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + <gl-button + :href="childPath" + category="tertiary" + variant="link" + class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" + @click="$emit('click', childItem.id, $event)" + @mouseover="$emit('mouseover', childItem.id, $event)" + @mouseout="$emit('mouseout', childItem.id, $event)" + > + {{ childItem.title }} + </gl-button> + </div> + <div + v-if="canUpdate" + class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + > + <work-item-links-menu + :work-item-id="childItem.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="$emit('remove', childItem.id)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 534ebabee08..840fd910272 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -5,22 +5,17 @@ import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; -import SidebarEventHub from '~/sidebar/event_hub'; -import { - STATE_OPEN, - WIDGET_ICONS, - WORK_ITEM_STATUS_TEXT, - WIDGET_TYPE_HIERARCHY, -} from '../../constants'; +import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemQuery from '../../graphql/work_item.query.graphql'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; +import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; -import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { @@ -28,14 +23,14 @@ export default { GlIcon, GlAlert, GlLoadingIcon, + WorkItemLinkChild, WorkItemLinksForm, - WorkItemLinksMenu, WorkItemDetailModal, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['projectPath'], + inject: ['projectPath', 'iid'], props: { workItemId: { type: String, @@ -63,6 +58,18 @@ export default { this.error = e.message || this.$options.i18n.fetchError; }, }, + parentIssue: { + query: issueConfidentialQuery, + variables() { + return { + fullPath: this.projectPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable; + }, + }, }, data() { return { @@ -72,9 +79,13 @@ export default { activeToast: null, prefetchedWorkItem: null, error: undefined, + parentIssue: null, }; }, computed: { + confidential() { + return this.parentIssue?.confidential || this.workItem?.confidential || false; + }, children() { return ( this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children @@ -84,9 +95,6 @@ export default { canUpdate() { return this.workItem?.userPermissions.updateWorkItem || false; }, - confidential() { - return this.workItem?.confidential || false; - }, // Only used for children for now but should be extended later to support parents and siblings isChildrenEmpty() { return this.children?.length === 0; @@ -95,9 +103,7 @@ export default { return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; }, toggleLabel() { - return this.isOpen - ? s__('WorkItem|Collapse child items') - : s__('WorkItem|Expand child items'); + return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks'); }, issuableGid() { return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; @@ -112,22 +118,7 @@ export default { return this.isLoading && this.children.length === 0 ? '...' : this.children.length; }, }, - mounted() { - SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems); - }, - destroyed() { - SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems); - }, methods: { - refetchWorkItems() { - this.$apollo.queries.workItem.refetch(); - }, - iconClass(state) { - return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500'; - }, - iconName(state) { - return state === STATE_OPEN ? 'issue-open-m' : 'issue-close'; - }, toggle() { this.isOpen = !this.isOpen; }, @@ -169,9 +160,6 @@ export default { replace: true, }); }, - childPath(childItemId) { - return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`; - }, toggleChildFromCache(workItem, childId, store) { const sourceData = store.readQuery({ query: getWorkItemLinksQuery, @@ -242,14 +230,12 @@ export default { }, }, i18n: { - title: s__('WorkItem|Child items'), - fetchError: s__( - 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.', - ), + title: s__('WorkItem|Tasks'), + fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'), emptyStateMessage: s__( - 'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!', + 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', ), - addChildButtonLabel: s__('WorkItem|Add a task'), + addChildButtonLabel: s__('WorkItem|Add'), }, WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK, WORK_ITEM_STATUS_TEXT, @@ -257,7 +243,10 @@ export default { </script> <template> - <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10"> + <div + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5" + data-testid="work-item-links" + > <div class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" @@ -319,48 +308,18 @@ export default { @cancel="hideAddForm" @addWorkItemChild="addChild" /> - <div + <work-item-link-child v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" - data-testid="links-child" - > - <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> - <gl-icon - :name="iconName(child.state)" - class="gl-mr-3" - :class="iconClass(child.state)" - /> - <gl-icon - v-if="child.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :title="__('Confidential')" - /> - <gl-button - :href="childPath(child.id)" - category="tertiary" - variant="link" - class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" - @click="openChild(child.id, $event)" - @mouseover="prefetchWorkItem(child.id)" - @mouseout="clearPrefetching" - > - {{ child.title }} - </gl-button> - </div> - <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"> - <work-item-links-menu - v-if="canUpdate" - :work-item-id="child.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="removeChild(child.id)" - /> - </div> - </div> + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="issuableGid" + :child-item="child" + @click="openChild" + @mouseover="prefetchWorkItem" + @mouseout="clearPrefetching" + @remove="removeChild" + /> <work-item-detail-modal ref="modal" :work-item-id="activeChildId" diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue index 080d4025cc3..3880ae25c8c 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -2,7 +2,8 @@ import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; import { - i18n, + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_UPDATING, STATE_OPEN, STATE_CLOSED, STATE_EVENT_CLOSE, @@ -93,7 +94,9 @@ export default { throw new Error(errors[0]); } } catch (error) { - this.$emit('error', i18n.updateError); + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + + this.$emit('error', msg); Sentry.captureException(error); } diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue index cd5cc3894f6..c52a6854fad 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -1,7 +1,11 @@ <script> import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import { + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_UPDATING, + TRACKING_CATEGORY_SHOW, +} from '../constants'; import { getUpdateWorkItemMutation } from './update_work_item'; import ItemTitle from './item_title.vue'; @@ -78,7 +82,8 @@ export default { throw new Error(errors[0]); } } catch (error) { - this.$emit('error', i18n.updateError); + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', msg); Sentry.captureException(error); } diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index fd914fa350b..31e75663055 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -1,11 +1,14 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { WORK_ITEMS_TYPE_MAP } from '../constants'; export default { components: { GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { workItemType: { type: String, @@ -22,6 +25,11 @@ export default { required: false, default: '', }, + showTooltipOnHover: { + type: Boolean, + required: false, + default: false, + }, }, computed: { iconName() { @@ -32,13 +40,21 @@ export default { workItemTypeName() { return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name; }, + workItemTooltipTitle() { + return this.showTooltipOnHover ? this.workItemTypeName : ''; + }, }, }; </script> <template> <span> - <gl-icon :name="iconName" class="gl-mr-2" /> + <gl-icon + v-gl-tooltip.hover="showTooltipOnHover" + :name="iconName" + :title="workItemTooltipTitle" + class="gl-mr-2 gl-text-gray-500" + /> <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> </span> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue deleted file mode 100644 index b0ad7c97bb1..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_weight.vue +++ /dev/null @@ -1,162 +0,0 @@ -<script> -import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { __ } from '~/locale'; -import Tracking from '~/tracking'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; - -/* eslint-disable @gitlab/require-i18n-strings */ -const allowedKeys = [ - 'Alt', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'Backspace', - 'Control', - 'Delete', - 'End', - 'Enter', - 'Home', - 'Meta', - 'PageDown', - 'PageUp', - 'Tab', - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', -]; -/* eslint-enable @gitlab/require-i18n-strings */ - -export default { - inputId: 'weight-widget-input', - components: { - GlForm, - GlFormGroup, - GlFormInput, - }, - mixins: [Tracking.mixin()], - inject: ['hasIssueWeightsFeature'], - props: { - canUpdate: { - type: Boolean, - required: false, - default: false, - }, - weight: { - type: Number, - required: false, - default: undefined, - }, - workItemId: { - type: String, - required: true, - }, - workItemType: { - type: String, - required: true, - }, - }, - data() { - return { - isEditing: false, - }; - }, - computed: { - placeholder() { - return this.canUpdate && this.isEditing ? __('Enter a number') : __('None'); - }, - tracking() { - return { - category: TRACKING_CATEGORY_SHOW, - label: 'item_weight', - property: `type_${this.workItemType}`, - }; - }, - type() { - return this.canUpdate && this.isEditing ? 'number' : 'text'; - }, - }, - methods: { - blurInput() { - this.$refs.input.$el.blur(); - }, - handleFocus() { - this.isEditing = true; - }, - handleKeydown(event) { - if (!allowedKeys.includes(event.key)) { - event.preventDefault(); - } - }, - updateWeight(event) { - if (!this.canUpdate) return; - this.isEditing = false; - - const weight = Number(event.target.value); - if (this.weight === weight) { - return; - } - - this.track('updated_weight'); - this.$apollo - .mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: this.workItemId, - weightWidget: { - weight: event.target.value === '' ? null : weight, - }, - }, - }, - }) - .then(({ data }) => { - if (data.workItemUpdate.errors.length) { - throw new Error(data.workItemUpdate.errors.join('\n')); - } - }) - .catch((error) => { - this.$emit('error', i18n.updateError); - Sentry.captureException(error); - }); - }, - }, -}; -</script> - -<template> - <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput"> - <gl-form-group - class="gl-align-items-center" - :label="__('Weight')" - :label-for="$options.inputId" - label-class="gl-pb-0! gl-overflow-wrap-break" - label-cols="3" - label-cols-lg="2" - > - <gl-form-input - :id="$options.inputId" - ref="input" - min="0" - :placeholder="placeholder" - :readonly="!canUpdate" - size="sm" - :type="type" - :value="weight" - @blur="updateWeight" - @focus="handleFocus" - @keydown="handleKeydown" - @keydown.exact.esc.stop="blurInput" - /> - </gl-form-group> - </gl-form> -</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index a2aea3cd327..78219e62d01 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -1,4 +1,5 @@ -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; export const STATE_OPEN = 'OPEN'; export const STATE_CLOSED = 'CLOSED'; @@ -13,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; export const WIDGET_TYPE_LABELS = 'LABELS'; +export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; @@ -31,6 +33,30 @@ export const i18n = { ), }; +export const I18N_WORK_ITEM_ERROR_CREATING = s__( + 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.', +); +export const I18N_WORK_ITEM_ERROR_UPDATING = s__( + 'WorkItem|Something went wrong while updating the %{workItemType}. Please try again.', +); +export const I18N_WORK_ITEM_ERROR_DELETING = s__( + 'WorkItem|Something went wrong when deleting the %{workItemType}. Please try again.', +); +export const I18N_WORK_ITEM_DELETE = s__('WorkItem|Delete %{workItemType}'); +export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__( + 'WorkItem|Are you sure you want to delete the %{workItemType}? This action cannot be reversed.', +); +export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted'); + +export const sprintfWorkItem = (msg, workItemTypeArg) => { + const workItemType = workItemTypeArg || s__('WorkItem|Work item'); + return capitalizeFirstCharacter( + sprintf(msg, { + workItemType: workItemType.toLocaleLowerCase(), + }), + ); +}; + export const WIDGET_ICONS = { TASK: 'issue-type-task', }; @@ -62,3 +88,5 @@ export const WORK_ITEMS_TYPE_MAP = { name: s__('WorkItem|Requirements'), }, }; + +export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql index 4cc23fa0071..1228c876a55 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" mutation createWorkItem($input: WorkItemCreateInput!) { workItemCreate(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql index 1f98cd4fa2b..ccfe62cc585 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { workItemCreateFromTask(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql index 790b8e60b6a..43c92cf89ec 100644 --- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) { localUpdateWorkItem(input: $input) @client { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index 0a887fcfc00..25eb8099251 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" mutation workItemUpdate($input: WorkItemUpdateInput!) { workItemUpdate(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql index fad5a9fa5bc..ad861a60d15 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql @@ -1,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { workItemUpdate: workItemUpdateTask(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql deleted file mode 100644 index 6a94c96b347..00000000000 --- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql +++ /dev/null @@ -1,10 +0,0 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" - -mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) { - workItemUpdateWidgets(input: $input) { - workItem { - ...WorkItem - } - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index e8ef27ec778..f4c77ed2ec0 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql" fragment WorkItem on WorkItem { id @@ -6,6 +7,12 @@ fragment WorkItem on WorkItem { state description confidential + createdAt + closedAt + project { + id + fullPath + } workItemType { id name @@ -16,34 +23,6 @@ fragment WorkItem on WorkItem { updateWorkItem } widgets { - ... on WorkItemWidgetDescription { - type - description - descriptionHtml - } - ... on WorkItemWidgetAssignees { - type - allowsMultipleAssignees - canInviteMembers - assignees { - nodes { - ...User - } - } - } - ... on WorkItemWidgetHierarchy { - type - parent { - id - iid - title - confidential - } - children { - nodes { - id - } - } - } + ...WorkItemWidgets } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index a9f7b714551..276061af193 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql new file mode 100644 index 00000000000..7e045fdf431 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql @@ -0,0 +1,13 @@ +subscription issuableDatesUpdated($issuableId: IssuableID!) { + issuableDatesUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + widgets { + ... on WorkItemWidgetStartAndDueDate { + dueDate + startDate + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index df62ca1c143..7b63d9c7ca3 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -1,4 +1,4 @@ -query workItemQuery($id: WorkItemID!) { +query workItemLinksQuery($id: WorkItemID!) { workItem(id: $id) { id workItemType { @@ -26,6 +26,8 @@ query workItemQuery($id: WorkItemID!) { } title state + createdAt + closedAt } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql new file mode 100644 index 00000000000..3005069f59a --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -0,0 +1,36 @@ +fragment WorkItemWidgets on WorkItemWidget { + ... on WorkItemWidgetDescription { + type + description + descriptionHtml + } + ... on WorkItemWidgetAssignees { + type + allowsMultipleAssignees + canInviteMembers + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetStartAndDueDate { + type + dueDate + startDate + } + ... on WorkItemWidgetHierarchy { + type + parent { + id + iid + title + confidential + } + children { + nodes { + id + } + } + } +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 6437df597b4..bb4c7052238 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import App from './components/app.vue'; import { createRouter } from './router'; -import { createApolloProvider } from './graphql/provider'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); @@ -12,7 +12,7 @@ export const initWorkItemsRoot = () => { el, name: 'WorkItemsRoot', router: createRouter(el.dataset.fullPath), - apolloProvider: createApolloProvider(), + apolloProvider, provide: { fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 482da5419c6..3b7257591e2 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -1,7 +1,9 @@ <script> import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { getPreferredLocales, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; @@ -10,7 +12,6 @@ import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query. import ItemTitle from '../components/item_title.vue'; export default { - createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'), fetchTypesErrorText: s__( 'WorkItem|Something went wrong when fetching work item types. Please try again', ), @@ -69,7 +70,7 @@ export default { update(data) { return data.workspace?.workItemTypes?.nodes.map((node) => ({ value: node.id, - text: node.name, + text: capitalizeFirstCharacter(node.name.toLocaleLowerCase(getPreferredLocales()[0])), })); }, error() { @@ -78,15 +79,19 @@ export default { }, }, computed: { - dropdownButtonText() { - return this.selectedWorkItemType?.name || s__('WorkItem|Type'); - }, formOptions() { return [{ value: null, text: s__('WorkItem|Select type') }, ...this.workItemTypes]; }, isButtonDisabled() { return this.title.trim().length === 0 || !this.selectedWorkItemType; }, + createErrorText() { + const workItemType = this.workItemTypes.find( + (item) => item.value === this.selectedWorkItemType, + )?.text; + + return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType); + }, }, methods: { async createWorkItem() { @@ -128,7 +133,7 @@ export default { } = response; this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); } catch { - this.error = this.$options.createErrorText; + this.error = this.createErrorText; } }, async createWorkItemFromTask() { @@ -150,7 +155,7 @@ export default { }); this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml); } catch { - this.error = this.$options.createErrorText; + this.error = this.createErrorText; } }, handleTitleInput(title) { diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index e9840889bdb..a2cacd8bd7a 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -3,9 +3,13 @@ import { GlAlert } from '@gitlab/ui'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; import ZenMode from '~/zen_mode'; import WorkItemDetail from '../components/work_item_detail.vue'; +import { + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_DELETING, + I18N_WORK_ITEM_DELETED, +} from '../constants'; import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; export default { @@ -34,7 +38,7 @@ export default { this.ZenMode = new ZenMode(); }, methods: { - deleteWorkItem() { + deleteWorkItem(workItemType) { this.$apollo .mutate({ mutation: deleteWorkItemMutation, @@ -53,13 +57,12 @@ export default { throw new Error(workItemDelete.errors[0]); } - this.$toast.show(s__('WorkItem|Work item deleted')); + const msg = sprintfWorkItem(I18N_WORK_ITEM_DELETED, workItemType); + this.$toast.show(msg); visitUrl(this.issuesListPath); }) .catch((e) => { - this.error = - e.message || - s__('WorkItem|Something went wrong when deleting the work item. Please try again.'); + this.error = e.message || sprintfWorkItem(I18N_WORK_ITEM_ERROR_DELETING, workItemType); }); }, }, @@ -69,6 +72,6 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem" /> + <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" /> </div> </template> diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 004dc22c9b8..9e81e1d4771 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -4,7 +4,6 @@ @import './pages/commits'; @import './pages/deploy_keys'; @import './pages/detail_page'; -@import './pages/editor'; @import './pages/environment_logs'; @import './pages/events'; @import './pages/groups'; @@ -21,7 +20,6 @@ @import './pages/notifications'; @import './pages/pipelines'; @import './pages/profile'; -@import './pages/profiles/preferences'; @import './pages/projects'; @import './pages/prometheus'; @import './pages/registry'; diff --git a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss index f6be241d644..324c23022ca 100644 --- a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss +++ b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss @@ -7,14 +7,14 @@ @return $string; } -@mixin dropzone-background($stroke-color, $stroke-width: 4, $stroke-linecap: 'butt') { - background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='#{encodecolor($stroke-color)}' stroke-width='#{$stroke-width}' stroke-dasharray='6%2c4' stroke-dashoffset='0' stroke-linecap='#{encodecolor($stroke-linecap)}'/%3e%3c/svg%3e"); +@mixin dropzone-background($stroke-color, $stroke-width: 4) { + background-image: url("data:image/svg+xml, %3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='#{$border-radius-default}' ry='#{$border-radius-default}' stroke='#{encodecolor($stroke-color)}' stroke-width='#{$stroke-width}' stroke-dasharray='6%2c4' stroke-dashoffset='0' stroke-linecap='butt' /%3e %3c/svg%3e"); } .upload-dropzone-border { border: 0; - @include dropzone-background($gray-400, 2, 'round'); - border-radius: 8px; + @include dropzone-background($gray-400, 2); + border-radius: $border-radius-default; } .upload-dropzone-card { diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index ad0036df607..34c7ffa58fe 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -149,7 +149,7 @@ margin: $sidebar-top-item-tb-margin $sidebar-top-item-lr-margin; &:hover { - background-color: $indigo-900-alpha-008; + background-color: $nav-active-bg; } } @@ -275,7 +275,7 @@ &:not(.fly-out-top-item) { > a:not(.has-sub-items) { - background-color: $indigo-900-alpha-008; + background-color: $nav-active-bg; } } } diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 904d041fdc9..8d1fb5eb55f 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -34,19 +34,28 @@ @media (min-width: map-get($grid-breakpoints, md)) { // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header - $mr-file-header-top: calc(#{$header-height} + #{$mr-tabs-height}); + --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); + --top: var(--initial-top); position: -webkit-sticky; position: sticky; - top: $mr-file-header-top; + top: var(--top); z-index: 120; + &.is-sidebar-moved { + --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px}); + } + .with-system-header & { - top: calc(#{$mr-file-header-top} + #{$system-header-height}); + --top: calc(var(--initial-top) + #{$system-header-height}); } .with-system-header.with-performance-bar & { - top: calc(#{$mr-file-header-top} + #{$system-header-height} + #{$performance-bar-height}); + --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height}); + } + + .with-performance-bar & { + top: calc(var(--initial-top) + #{$performance-bar-height}); } &::before { @@ -60,10 +69,6 @@ pointer-events: none; } - .with-performance-bar & { - top: calc(#{$mr-file-header-top} + #{$performance-bar-height}); - } - &.is-commit { top: calc(#{$header-height} + #{$commit-stat-summary-height}); @@ -788,11 +793,13 @@ table.code { } .diff-comments-more-count, -.diff-notes-collapse { +.diff-notes-collapse, +.diff-codequality-collapse { @include avatar-counter(50%); } -.diff-notes-collapse { +.diff-notes-collapse, +.diff-codequality-collapse { border: 0; border-radius: 50%; padding: 0; @@ -977,7 +984,8 @@ table.code { position: relative; } - .diff-notes-collapse { + .diff-notes-collapse, + .diff-codequality-collapse { position: absolute; left: -12px; } @@ -1107,6 +1115,7 @@ table.code { } .diff-notes-collapse, + .diff-codequality-collapse, .note, .discussion-reply-holder { display: none; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index b980d7fdaa7..07516275e58 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -39,8 +39,8 @@ .file-title { position: relative; - background-color: $gray-light; - border-bottom: 1px solid $border-color; + background-color: var(--gray-10, $gray-10); + border-bottom: 1px solid var(--border-color, $border-color); margin: 0; text-align: left; padding: 10px $gl-padding; @@ -471,11 +471,6 @@ span.idiff { } } -.jupyter-notebook-scrolled { - overflow-y: auto; - max-height: 20rem; -} - #js-openapi-viewer { pre.version, code { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 37f92d3cf3d..ed41d10f3b2 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -48,7 +48,7 @@ opacity: 0; } - a { + a:not(.canary-badge) { display: flex; align-items: center; padding: 2px 8px; @@ -61,10 +61,6 @@ } } - .canary-badge { - margin-left: -8px; - } - .project-item-select { right: auto; left: 0; @@ -564,15 +560,11 @@ } .frequent-items-list-item-container > a:hover { - background-color: $nav-active-bg; + background-color: $nav-active-bg !important; } } .top-nav-toggle { - .dropdown-icon { - @include gl-mr-3; - } - .dropdown-chevron { top: 0; } @@ -581,7 +573,7 @@ .top-nav-menu-item { &.active, &:hover { - background-color: $nav-active-bg; + background-color: $nav-active-bg !important; } .gl-icon { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index ab426f388c6..a63ce66e681 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -31,8 +31,7 @@ width: 100%; padding-left: 10px; padding-right: 10px; - white-space: break-spaces; - word-break: break-word; + white-space: pre; &:empty::before { content: '\200b'; diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index fb05f8575ef..02b76b89482 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -7,9 +7,6 @@ html { } body { - // Improves readability for dyslexic users; supported only in Chrome/Safari so far - text-decoration-skip: ink; - &.navless { background-color: $white !important; } @@ -139,6 +136,13 @@ body { } } +.gl--flex-full { + @include gl-display-flex; + @include gl-align-items-stretch; + @include gl-overflow-hidden; +} + + .with-performance-bar .layout-page { margin-top: calc(#{$header-height} + #{$performance-bar-height}); } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 7522f791b8e..b623f18c4ae 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -104,7 +104,6 @@ li.md-header-toolbar { margin-left: auto; visibility: hidden; - padding-bottom: $gl-padding-8; &.active { visibility: visible; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 92ca8654287..7e0a601223d 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -13,9 +13,9 @@ a, button { - padding: $gl-padding-8; - font-size: 14px; - line-height: 28px; + padding: $gl-spacing-scale-5 $gl-spacing-scale-4; + font-size: $gl-font-size; + line-height: $gl-line-height-16; color: $gl-text-color-secondary; border: 0; white-space: nowrap; @@ -88,10 +88,6 @@ float: left; } - li a { - padding: 16px 15px 11px; - } - /* Small devices (phones, 768px and lower) */ @include media-breakpoint-down(sm) { width: 100%; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index ae0f18753ad..7878e08e549 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -208,7 +208,7 @@ position: relative; top: -3px; padding: $gl-padding-4 0; - background-color: $gray-light; + background-color: $body-bg; &.opened { color: $green-500; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 031f5dc45ca..e79fb843967 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -333,7 +333,7 @@ font-size: 13px; line-height: 1.6em; overflow-x: auto; - border-radius: 2px; + border-radius: $border-radius-default; // Multi-line code blocks should scroll horizontally code { @@ -427,10 +427,11 @@ padding-inline-start: 28px; margin-inline-start: 0 !important; + > p > input.task-list-item-checkbox, > input.task-list-item-checkbox { position: absolute; - inset-inline-start: 8px; - top: 5px; + inset-inline-start: $gl-padding-8; + inset-block-start: 5px; } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index e9ad930ef2b..bd32a817d5d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -364,7 +364,7 @@ $well-expand-item: #e8f2f7 !default; $well-inner-border: #eef0f2 !default; $well-light-border: #f1f1f1; $well-light-text-color: #5b6169; -$nav-active-bg: var(--nav-active-bg, rgba($black, 0.08)) !important; +$nav-active-bg: rgba($black, 0.08); /* * Text diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 197073412e8..d45bc865da5 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -5,34 +5,6 @@ pointer-events: none; } -.dropdown-projects { - .dropdown-content { - max-height: 200px; - } -} - -.issue-board-dropdown-content { - margin: 0; - padding: $gl-padding-4 $gl-padding $gl-padding; - border-bottom: 0; - color: var(--gray-500, $gray-500); -} - -[data-page$='epic_boards:index'], -[data-page$='epic_boards:show'], -.issue-boards-page { - .content-wrapper { - padding-bottom: 0; - } -} - -[data-page$='epic_boards:index'], -[data-page$='epic_boards:show'] { - .filtered-search-wrapper { - display: none !important; - } -} - .boards-app { @include media-breakpoint-up(sm) { transition: width $gl-transition-duration-medium; @@ -87,33 +59,7 @@ width: 400px; } - .board-title-caret { - border-radius: $border-radius-default; - line-height: $gl-spacing-scale-5; - - &.btn svg { - top: 0; - } - - &:hover { - background-color: var(--gray-50, $gray-50); - transition: background-color 0.1s linear; - } - } - - &:not(.is-collapsed) { - .board-title-caret { - margin-right: $gl-padding-4; - } - } - &.is-collapsed { - width: 50px; - - .board-title-caret { - margin-top: 1px; - } - .board-title-text > span, .issue-count-badge > span { height: 16px; @@ -124,17 +70,11 @@ // rotated element has square dimensions so it won't overlap with its siblings. margin: calc(50% - 8px) 0; - transform: rotate(90deg); transform-origin: center; } } } -.board-inner { - font-size: $issue-boards-font-size; - background: var(--gray-50, $gray-50); -} - // to highlight columns we have animated pulse of box-shadow // we don't want to actually animate the box-shadow property // because that causes costly repaints. Instead we can add a @@ -169,103 +109,45 @@ } } -.board-title { - height: 3rem; - - .max-issue-size::before { - content: '/'; - } -} - -.board-list-component { - min-height: 0; // firefox fix -} - -.board-list { - overflow-y: auto; - overflow-x: hidden; -} - -.board-list-loading { - margin-top: 10px; - font-size: (26px / $issue-boards-font-size) * 1em; -} - .board-card { background: var(--gray-10, $white); box-shadow: 0 1px 2px rgba(var(--black, $black), 0.1); - line-height: $gl-padding; - list-style: none; - position: relative; - - &:not(:last-child) { - margin-bottom: $gl-padding-8; - } - - &.is-active, - &.is-active .board-card-assignee:hover a { - background-color: var(--blue-50, $blue-50); - } - - &.multi-select { - border-color: var(--blue-200, $blue-200); - background-color: var(--blue-50, $blue-50); - } - - &.sortable-chosen { - box-shadow: 0 2px 4px 0 rgba($black, 0.16); - } - .gl-label { - margin-top: 4px; - margin-right: 4px; + &:last-child { + @include gl-mb-0; } - .confidential-icon, - .hidden-icon { - color: var(--orange-500, $orange-500); - cursor: help; + .move-to-position { + visibility: hidden; } - .issue-blocked-icon { - color: var(--red-500, $red-500); + &:hover .move-to-position { + visibility: visible; } - @include media-breakpoint-down(md) { - padding: $gl-padding-8; + @include media-breakpoint-down(sm) { + .move-to-position { + visibility: visible; + } } } .board-card-title { - @include overflow-break-word(); - font-size: 1em; + width: 95%; a { - color: var(--gray-900, $gray-900); - } - - @include media-breakpoint-down(md) { - font-size: $label-font-size; + @include media-breakpoint-down(md) { + font-size: $gl-font-size-sm; + } } } .board-card-assignee { - margin-top: -$gl-padding-4; - margin-bottom: -$gl-padding-4; - .avatar-counter { - vertical-align: middle; - line-height: $gl-padding-24; min-width: $gl-padding-24; height: $gl-padding-24; border-radius: $gl-padding-24; - background-color: var(--gray-400, $gray-400); font-size: $gl-font-size-xs; - cursor: help; - font-weight: $gl-font-weight-bold; - margin-left: -$gl-padding-4; - border: 0; - padding: 0 $gl-padding-4; @include media-breakpoint-down(md) { min-width: auto; @@ -275,12 +157,8 @@ } } - img { - vertical-align: top; - } - .user-avatar-link:not(:only-child) { - margin-left: -$gl-padding-4; + margin-left: -$gl-padding; &:nth-of-type(1) { z-index: 2; @@ -299,89 +177,26 @@ } @include media-breakpoint-down(md) { - margin-top: 0; - margin-bottom: 0; + margin-bottom: 0 !important; } } .board-card-number { - font-size: $gl-font-size-xs; - color: var(--gray-500, $gray-500); - - @include media-breakpoint-up(md) { - font-size: $label-font-size; + @include media-breakpoint-down(md) { + font-size: $gl-font-size-sm; } } .board-list-count { - padding: 10px 0; - color: var(--gray-500, $gray-500); font-size: 13px; } -.board-new-issue-form { - z-index: 4; - margin: 5px; -} - -.right-sidebar.boards-sidebar { - .gutter-toggle { - bottom: 15px; - width: 22px; - padding-left: $gl-padding-32; - - svg { - position: absolute; - top: 50%; - right: 0; - margin-top: (-11px / 2); - height: $gl-font-size-12; - width: $gl-font-size-12; - } - } - - .issuable-header-text { - @include overflow-break-word(); - padding-right: 35px; - } -} - -.right-sidebar.right-sidebar-expanded { - &.boards-sidebar-slide-enter-active, - &.boards-sidebar-slide-leave-active { - transition: width $gl-transition-duration-medium, padding $gl-transition-duration-medium; - } - - &.boards-sidebar-slide-enter, - &.boards-sidebar-slide-leave-active { - width: 0; - padding-left: 0; - padding-right: 0; - } -} - .board-card-info { - color: var(--gray-500, $gray-500); - white-space: nowrap; - margin-right: $gl-padding-8; - - &:not(.board-card-weight) { - cursor: help; - } - - &.board-card-weight { - color: var(--gray-500, $gray-500); - cursor: pointer; - - &:hover { - color: initial; - text-decoration: underline; - } + &.board-card-weight:hover { + color: initial; } .board-card-info-icon { - color: var(--gray-500, $gray-500); - margin-right: $gl-padding-4; vertical-align: text-top; } @@ -394,15 +209,6 @@ cursor: help; } -.board-labels-toggle-wrapper, -.board-swimlanes-toggle-wrapper { - /** - * Make the wrapper the same height as a button so it aligns properly when the - * filtered-search-box input element increases in size on Linux smaller breakpoints - */ - height: $input-height; -} - .issue-boards-content:not(.breadcrumbs) { isolation: isolate; } @@ -422,7 +228,6 @@ .boards-list { height: calc(100vh - #{$issue-boards-filter-height}); - overflow-x: scroll; } .boards-sidebar { @@ -433,15 +238,7 @@ .boards-sidebar { .sidebar-collapsed-icon { - display: none; - } - - .gl-drawer-header { - align-items: flex-start; - } - - .labels-select-wrapper.is-embedded .labels-select-wrapper.is-embedded { - width: auto; + @include gl-display-none; } .show.dropdown .dropdown-menu { @@ -449,10 +246,6 @@ } } -.board-header-collapsed-info-icon:hover { - color: var(--gray-900, $gray-900); -} - .board-card-skeleton { height: 110px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss index c177d0b74a2..b7b698b2128 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/page_bundles/editor.scss @@ -1,11 +1,13 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + .file-editor { .nav-links { - border-top: 1px solid $border-color; - border-right: 1px solid $border-color; - border-left: 1px solid $border-color; + border-top: 1px solid var(--border-color, $border-color); + border-right: 1px solid var(--border-color, $border-color); + border-left: 1px solid var(--border-color, $border-color); border-bottom: 0; border-radius: $border-radius-small $border-radius-small 0 0; - background: $gray-normal; + background: var(--gray-50, $gray-50); } #editor, @@ -23,10 +25,6 @@ } } - .ace_gutter-cell { - background-color: $gray-light; - } - .cancel-btn { color: $red-600; @@ -40,9 +38,9 @@ } .editor-ref { - background: $gray-light; + background: var(--gray-10, $gray-10); padding-right: $gl-padding; - border-right: 1px solid $border-color; + border-right: 1px solid var(--border-color, $border-color); display: block; float: left; margin-right: 10px; diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss index 71dbb855103..5086cdbf9bc 100644 --- a/app/assets/stylesheets/page_bundles/group.scss +++ b/app/assets/stylesheets/page_bundles/group.scss @@ -1,35 +1,16 @@ @import 'page_bundles/mixins_and_variables_and_functions'; .group-home-panel { - margin-top: $gl-padding; - margin-bottom: $gl-padding; - .home-panel-avatar { width: $home-panel-title-row-height; height: $home-panel-title-row-height; - flex-shrink: 0; flex-basis: $home-panel-title-row-height; } .home-panel-title { - font-size: 20px; - line-height: $gl-line-height-24; - font-weight: bold; - .icon { vertical-align: -1px; } - - .home-panel-topic-list { - font-size: $gl-font-size; - font-weight: $gl-font-weight-normal; - - .icon { - position: relative; - top: 3px; - margin-right: $gl-padding-4; - } - } } .home-panel-title-row { @@ -52,7 +33,7 @@ line-height: $gl-font-size-large; } - .home-panel-topic-list, + .home-panel-metadata { font-size: $gl-font-size-small; } @@ -60,8 +41,6 @@ } .home-panel-metadata { - font-weight: normal; - font-size: 14px; line-height: $gl-btn-line-height; } diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss index c664e0a734e..26d694f7421 100644 --- a/app/assets/stylesheets/page_bundles/issues_show.scss +++ b/app/assets/stylesheets/page_bundles/issues_show.scss @@ -1,77 +1,24 @@ @import 'mixins_and_variables_and_functions'; .description { - ul, - ol { - /* We're changing list-style-position to inside because the default of - * outside doesn't move negative margin to the left of the bullet. */ - list-style-position: inside; - } - li { position: relative; - /* In the browser, the li element comes after (to the right of) the bullet point, so hovering - * over the left of the bullet point doesn't trigger a row hover. To trigger hovering on the - * left, we're applying negative margin here to shift the li element left. */ - margin-inline-start: -1rem; - padding-inline-start: 2.5rem; + margin-inline-start: 2.25rem; .drag-icon { position: absolute; inset-block-start: 0.3rem; - inset-inline-start: 1rem; - } - - /* The inside bullet aligns itself to the bottom, which we see when text to the right of - * a multi-line list item wraps. We fix this by aligning it to the top, and excluding - * other elements. Targeting ::marker doesn't seem to work, instead we exclude custom elements - * or anything with a class */ - > *:not(gl-emoji, code, [class]) { - vertical-align: top; - } - - /* The inside bullet is treated like an element inside the li element, so when we have a - * multi-paragraph list item, the text doesn't start on the right of the bullet because - * it is a block level p element. We make it inline to fix this. */ - > p:first-of-type { - display: inline-block; - max-width: calc(100% - 1.5rem); - } - - /* We fix the other paragraphs not indenting to the - * right of the bullet due to the inside bullet. */ - p ~ a, - p ~ blockquote, - p ~ code, - p ~ details, - p ~ dl, - p ~ h1, - p ~ h2, - p ~ h3, - p ~ h4, - p ~ h5, - p ~ h6, - p ~ hr, - p ~ ol, - p ~ p, - p ~ table:not(.code), /* We need :not(.code) to override typography.scss */ - p ~ ul, - p ~ .markdown-code-block { - margin-inline-start: 1rem; + inset-inline-start: -2.3rem; + padding-inline-end: 1rem; + width: 2rem; } } - ul.task-list { - > li.task-list-item { - /* We're using !important to override the same selector in typography.scss */ - margin-inline-start: -1rem !important; - padding-inline-start: 2.5rem; + ul.task-list > li.task-list-item { + margin-inline-start: 0.5rem !important; /* Override typography.scss */ - > input.task-list-item-checkbox { - position: static; - vertical-align: middle; - margin-block-start: -2px; - } + > .drag-icon { + inset-inline-start: -0.6rem; } } } diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index b7a75884425..463c8f74342 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -41,17 +41,21 @@ $tabs-holder-z-index: 250; // If they don't match, the file tree and the diff files stick // to the top at different heights, which is a bad-looking defect $diff-file-header-top: 11px; - $top-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); + --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); + --top-pos: var(--initial-pos); position: -webkit-sticky; position: sticky; - // Unitless zero values are not allowed in calculations - top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px)); - max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); + top: var(--top-pos); + max-height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); .drag-handle { bottom: 16px; } + + &.is-sidebar-moved { + --top-pos: calc(var(--initial-pos) + 26px); + } } .tree-list-holder { @@ -196,12 +200,8 @@ $tabs-holder-z-index: 250; background-color: var(--gray-50, $gray-50); } -.mr-conflict-loader { - max-width: 334px; - - > svg { - vertical-align: middle; - } +.mr-widget-body-loading svg { + vertical-align: middle; } .mr-info-list { @@ -398,12 +398,6 @@ $tabs-holder-z-index: 250; display: block; } - .mr-widget-pipeline-graph { - .dropdown-menu { - z-index: $zindex-dropdown-menu; - } - } - .normal { flex: 1; flex-basis: auto; @@ -440,7 +434,7 @@ $tabs-holder-z-index: 250; .mr-widget-body { &:not(.mr-widget-body-line-height-1) { - line-height: 28px; + line-height: 24px; } @include clearfix; @@ -475,12 +469,6 @@ $tabs-holder-z-index: 250; margin: 0 0 0 10px; } - .bold { - font-weight: $gl-font-weight-bold; - color: var(--gray-600, $gray-600); - margin-left: 10px; - } - .state-label { font-weight: $gl-font-weight-bold; padding-right: 10px; @@ -490,11 +478,6 @@ $tabs-holder-z-index: 250; color: var(--red-500, $red-500); } - .spacing, - .bold { - vertical-align: middle; - } - .dropdown-menu { li a { padding: 5px; @@ -621,8 +604,8 @@ $tabs-holder-z-index: 250; .mr-widget-extension-icon::before { @include gl-content-empty; @include gl-absolute; - @include gl-left-0; - @include gl-top-0; + @include gl-left-50p; + @include gl-top-half; @include gl-opacity-3; @include gl-border-solid; @include gl-border-4; @@ -630,24 +613,20 @@ $tabs-holder-z-index: 250; width: 24px; height: 24px; + transform: translate(-50%, -50%); } .mr-widget-extension-icon::after { @include gl-content-empty; @include gl-absolute; @include gl-rounded-full; + @include gl-left-50p; + @include gl-top-half; - top: 4px; - left: 4px; width: 16px; height: 16px; - border: 4px solid currentColor; -} - -.mr-widget-extension-icon svg { - position: relative; - top: 2px; - left: 2px; + border: 4px solid; + transform: translate(-50%, -50%); } .mr-widget-heading { @@ -777,6 +756,7 @@ $tabs-holder-z-index: 250; &.show .dropdown-menu { width: calc(100vw - 20px); max-width: 650px; + max-height: calc(100vh - 50px); .gl-new-dropdown-inner { max-height: none !important; @@ -818,8 +798,7 @@ $tabs-holder-z-index: 250; } .md-preview-holder { - max-height: 180px; - height: 180px; + max-height: 172px; } } @@ -840,3 +819,29 @@ $tabs-holder-z-index: 250; } } } + +.merge-request-sticky-header { + z-index: 204; + box-shadow: 0 1px 2px $issue-boards-card-shadow; + --width: calc(100% - #{$contextual-sidebar-width}); + + @include media-breakpoint-down(lg) { + --width: calc(100% - #{$contextual-sidebar-collapsed-width}); + } +} + +.detail-page-header-actions { + .gl-toggle { + @include gl-ml-auto; + } +} + +.page-with-icon-sidebar .issue-sticky-header { + --width: calc(100% - #{$contextual-sidebar-collapsed-width}); +} + +.merge-request-notification-toggle { + .gl-toggle-label { + @include gl-font-weight-normal; + } +} diff --git a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss index 412971253ca..0c73bece035 100644 --- a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss +++ b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss @@ -28,13 +28,6 @@ .pipeline-schedule-table-row { .branch-name-cell { max-width: 300px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .next-run-cell { - color: var(--gray-500, $gray-500); } a { @@ -50,7 +43,6 @@ .bordered-box.content-block { border: 1px solid var(--border-color, $border-color); background-color: transparent; - padding: $gl-spacing-scale-5; } } diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss index c7d7aacceec..c9c78a70163 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss @@ -1,3 +1,5 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + .application-theme { $ui-gray-bg: #303030; $ui-light-gray-bg: #f0f0f0; diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss index d0748779f47..03c9fc7508d 100644 --- a/app/assets/stylesheets/page_bundles/reports.scss +++ b/app/assets/stylesheets/page_bundles/reports.scss @@ -16,6 +16,10 @@ line-height: 20px; } +.report-block-child-icon { + height: 20px; +} + .report-block-list { list-style: none; padding: 0 1px; diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index e7813e3b56e..3eacf98688e 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -9,12 +9,6 @@ // workaround because we cannot use border-collapse border-top: 1px solid transparent; - &:hover { - background-color: var(--blue-50, $blue-50); - border-color: var(--blue-200, $blue-200); - cursor: pointer; - } - // overwrite border style of .content-list &:last-child { border-bottom: 1px solid transparent; @@ -26,8 +20,6 @@ &.todo-pending.done-reversible { &:hover { - border-color: var(--border-color, $border-color); - background-color: var(--gray-50, $gray-50); border-top: 1px solid transparent; .todo-avatar, @@ -40,20 +32,12 @@ .todo-item { opacity: 0.2; } - - .btn { - background-color: var(--gray-50, $gray-50); - } } } .todo-item { @include transition(opacity); - .status-box { - line-height: inherit; - } - .todo-label, .todo-project { a { @@ -66,22 +50,6 @@ color: var(--gl-text-color, $gl-text-color); } - pre { - border: 0; - background: var(--gray-50, $gray-50); - border-radius: 0; - color: var(--gray-500, $gray-500); - margin: 0 20px; - overflow: hidden; - } - - .note-image-attach { - margin-top: 4px; - margin-left: 0; - max-width: 200px; - float: none; - } - .gl-label-scoped { --label-inset-border: inset 0 0 0 1px currentColor; } diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 9220fa82b46..d0fc011dde7 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -7,7 +7,7 @@ #weight-widget-input:not(:hover, :focus), #weight-widget-input[readonly] { - box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white); + box-shadow: none; } #weight-widget-input[readonly] { @@ -19,8 +19,38 @@ display: none; } - .assignees-selector:hover .assign-myself { - display: block; + @include media-breakpoint-up(sm) { + .assignees-selector:hover .assign-myself { + display: block; + } + } +} + +.work-item-due-date { + .gl-datepicker-input.gl-form-input.form-control { + width: 10rem; + + &:not(:focus, :hover) { + box-shadow: none; + + ~ .gl-datepicker-actions { + display: none; + } + } + + &[disabled] { + background-color: var(--white, $white); + box-shadow: none; + + ~ .gl-datepicker-actions { + display: none; + } + } + } + + .gl-datepicker-actions:focus, + .gl-datepicker-actions:hover { + display: flex !important; } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c96d8ecc782..19318d87731 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -33,12 +33,6 @@ height: 22px; } } - - .mr-widget-pipeline-graph { - .dropdown-menu { - margin-top: 11px; - } - } } .branch-info .commit-icon { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 69797c6b303..85205f4d5ac 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -297,7 +297,7 @@ padding: 0; .issuable-context-form { - --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); + --initial-top: calc(#{$header-height} + 76px); --top: var(--initial-top); @include gl-sticky; @@ -613,7 +613,7 @@ } .participants-author { - &:nth-of-type(7n) { + &:nth-of-type(8n) { padding-right: 0; } @@ -962,40 +962,26 @@ border-left: 2px solid $gray-50; position: absolute; left: 39px; - height: 80%; + height: calc(100% + #{$gl-spacing-scale-5}); + top: -#{$gl-spacing-scale-5}; } - &:first-child::before, - &:last-child::after { + &:first-child::before { content: none; } &:first-child { &::after { - top: 50%; + top: $gl-spacing-scale-5; + height: calc(100% + #{$gl-spacing-scale-5}); } } - &:last-child { + &:last-child, + &.create-timeline-event { &::before { - bottom: 50%; - } - } - - &:not(:first-child):not(:last-child) { - &::before { - top: -10%; - } - - &::after { - bottom: -10%; - } - } - - &.timeline-event-note-form { - &::before { - top: -15% !important; // Override default positioning - height: 20%; + top: - #{$gl-spacing-scale-5} !important; // Override default positioning + @include gl-h-8; } &::after { @@ -1007,3 +993,22 @@ .timeline-event-note-form { padding-left: 20px; } + +.timeline-entry:not(:last-child) { + .timeline-event-border { + @include gl-pb-5; + @include gl-border-gray-50; + @include gl-border-1; + @include gl-border-b-solid; + } +} + +.timeline-group:last-child { + .timeline-entry:last-child, + .create-timeline-event { + .timeline-event-bottom-border { + @include gl-border-b; + @include gl-pt-5; + } + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index a151c28fe93..843daec8cda 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -259,13 +259,15 @@ ul.related-merge-requests > li gl-emoji { } .issue-sticky-header { + --width: 100%; + @include gl-left-0; - @include gl-w-full; + width: var(--width); top: $header-height; // collapsed right sidebar @include media-breakpoint-up(sm) { - width: calc(100% - #{$gutter-collapsed-width}); + --width: calc(100% - #{$gutter-collapsed-width}); } } @@ -283,12 +285,12 @@ ul.related-merge-requests > li gl-emoji { // collapsed left sidebar + collapsed right sidebar .issue-sticky-header { left: $contextual-sidebar-collapsed-width; - width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width}); + --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width}); } // collapsed left sidebar + expanded right sidebar .right-sidebar-expanded .issue-sticky-header { - width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width}); + --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width}); } } @@ -296,23 +298,23 @@ ul.related-merge-requests > li gl-emoji { // expanded left sidebar + collapsed right sidebar .issue-sticky-header { left: $contextual-sidebar-width; - width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width}); + --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width}); } // collapsed left sidebar + collapsed right sidebar .page-with-icon-sidebar .issue-sticky-header { left: $contextual-sidebar-collapsed-width; - width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width}); + --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width}); } // expanded left sidebar + expanded right sidebar .right-sidebar-expanded .issue-sticky-header { - width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width}); + --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width}); } // collapsed left sidebar + expanded right sidebar .right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header { - width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width}); + --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width}); } } diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 1beb9f05b6c..d4ad6da7f4d 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -1,3 +1,4 @@ +@import 'framework/variables'; /* Login Page */ .login-page { .container { @@ -41,6 +42,13 @@ font-size: 13px; } + .signin-text { + p { + margin-bottom: 0; + line-height: 1.5; + } + } + .borderless { .login-box, .omniauth-container { @@ -118,6 +126,18 @@ } .new-session-tabs { + &.nav-links-unboxed { + border-color: transparent; + box-shadow: none; + + .nav-item { + border-left: 0; + border-right: 0; + border-bottom: 1px solid $gray-100; + background-color: transparent; + } + } + display: flex; box-shadow: 0 0 0 1px $border-color; border-top-right-radius: $border-radius-default; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 9692becef4f..cb77c31d59a 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -48,7 +48,7 @@ .common-note-form { .md-area { - padding: $gl-padding-8 $gl-padding; + padding: 0 $gl-padding; border: 1px solid $border-color; border-radius: $border-radius-base; transition: border-color ease-in-out 0.15s, @@ -305,7 +305,6 @@ table { } .comment-toolbar { - padding-top: $gl-padding-top; color: $gl-text-color-secondary; border-top: 1px solid $border-color; } @@ -336,8 +335,7 @@ table { .toolbar-text { font-size: 14px; - line-height: 16px; - margin-top: 2px; + line-height: $gl-spacing-scale-7; @include media-breakpoint-up(md) { float: left; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index db07f16dfd0..fc1b78bf730 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -164,7 +164,7 @@ $system-note-svg-size: 16px; } .note-body { - padding: $gl-padding-4; + padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding-8; overflow-x: auto; overflow-y: hidden; @@ -305,7 +305,7 @@ $system-note-svg-size: 16px; height: $system-note-icon-size; border: 1px solid $gray-10; border-radius: $system-note-icon-size; - margin: -6px 20px 0 0; + margin: -6px 0 0; svg { width: $system-note-svg-size; @@ -334,10 +334,14 @@ $system-note-svg-size: 16px; border-radius: 0; @media (min-width: map-get($grid-breakpoints, md)) { - top: calc(#{$mr-tabs-height} + #{$header-height}); + --initial-top: calc(#{$header-height} + #{$mr-tabs-height}); + + &.is-sidebar-moved { + --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px}); + } .with-performance-bar & { - top: 123px; + --top: 123px; } } @@ -551,6 +555,7 @@ $system-note-svg-size: 16px; .note-header { display: flex; justify-content: space-between; + align-items: center; flex-wrap: wrap; > .note-header-info, @@ -581,7 +586,7 @@ $system-note-svg-size: 16px; .note-header-info { min-width: 0; - padding-left: $gl-padding-4; + padding-left: $gl-padding-8; &.discussion { padding-bottom: 0; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 6c909b8d9fa..e8f71c8a21c 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -49,7 +49,7 @@ input[type='checkbox']:hover { } &.header-search-is-active { - .navbar-collapse { + .global-search-container { flex-grow: 1; } @@ -59,12 +59,6 @@ input[type='checkbox']:hover { overflow: hidden; } } - - @include media-breakpoint-up(xl) { - .navbar-nav { - padding-left: 1rem; - } - } } } @@ -383,6 +377,10 @@ input[type='checkbox']:hover { .line_holder { pre { padding: 0; // This overrides the existing style that will add space between each line. + .line { + @include gl-word-break-word; + white-space: break-spaces; + } } svg { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 935595d1b3b..56acf6de828 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -349,3 +349,9 @@ } } } + +.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap { + @include gl-media-breakpoint-up(md) { + @include gl-flex-wrap-nowrap; + } +} diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index ffe4d5dde9d..0450b3d9a44 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -10,7 +10,6 @@ body.gl-dark { --gray-50: #303030; --gray-100: #404040; --gray-200: #525252; - --gray-600: #bfbfbf; --gray-700: #dbdbdb; --gray-900: #fafafa; --green-100: #0d532a; @@ -18,7 +17,6 @@ body.gl-dark { --gl-text-color: #fafafa; --border-color: #4f4f4f; --black: #fff; - --nav-active-bg: rgba(255, 255, 255, 0.08); } :root { --white: #333; @@ -332,9 +330,6 @@ kbd kbd { color: #fff; background-color: #c17d10; } -.bg-transparent { - background-color: transparent !important; -} .rounded-circle { border-radius: 50% !important; } @@ -459,7 +454,7 @@ a.gl-badge.badge-warning:active { .gl-form-input:disabled, .gl-form-input.form-control:disabled { cursor: not-allowed; - color: #868686; + color: #999; } .gl-form-input::placeholder, .gl-form-input.form-control::placeholder { @@ -594,9 +589,6 @@ svg { html { overflow-y: scroll; } -body { - text-decoration-skip: ink; -} .btn { border-radius: 4px; font-size: 0.875rem; @@ -815,20 +807,17 @@ kbd { .navbar-gitlab .header-content .title img { height: 24px; } -.navbar-gitlab .header-content .title a { +.navbar-gitlab .header-content .title a:not(.canary-badge) { display: flex; align-items: center; padding: 2px 8px; margin: 4px 2px 4px -8px; border-radius: 4px; } -.navbar-gitlab .header-content .title a:active { +.navbar-gitlab .header-content .title a:not(.canary-badge):active { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 0 3px #1068bf; outline: none; } -.navbar-gitlab .header-content .title .canary-badge { - margin-left: -8px; -} .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } @@ -1012,9 +1001,6 @@ kbd { visibility: hidden; top: 3px; } -.top-nav-toggle .dropdown-icon { - margin-right: 0.5rem; -} .tanuki-logo .tanuki { fill: #e24329; } @@ -1137,7 +1123,7 @@ kbd { font-weight: 600; } .nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: rgba(41, 41, 97, 0.08); + background-color: rgba(255, 255, 255, 0.08); } .nav-sidebar ul { padding-left: 0; @@ -1790,7 +1776,6 @@ body.gl-dark { --white: #333; --black: #fff; --svg-status-bg: #333; - --nav-active-bg: rgba(255, 255, 255, 0.08); } .nav-sidebar, .toggle-sidebar-button, @@ -1802,15 +1787,6 @@ body.gl-dark { .avatar { background: rgba(255, 255, 255, 0.04); } -.nav-sidebar li a { - color: var(--gray-600); -} -.nav-sidebar li.active { - box-shadow: none; -} -.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: var(--nav-active-bg); -} body.gl-dark { --gl-theme-accent: #868686; } @@ -2038,7 +2014,6 @@ body.gl-dark { --white: #333; --black: #fff; --svg-status-bg: #333; - --nav-active-bg: rgba(255, 255, 255, 0.08); } .tab-width-8 { tab-size: 8; @@ -2113,6 +2088,12 @@ body.gl-dark { .gl-pt-0 { padding-top: 0; } +.gl-mr-auto { + margin-right: auto; +} +.gl-mr-3 { + margin-right: 0.5rem; +} .gl-ml-n2 { margin-left: -0.25rem; } diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 00ca98bfd27..356fb58b4c8 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -311,9 +311,6 @@ kbd kbd { color: #fff; background-color: #ab6100; } -.bg-transparent { - background-color: transparent !important; -} .rounded-circle { border-radius: 50% !important; } @@ -438,7 +435,7 @@ a.gl-badge.badge-warning:active { .gl-form-input:disabled, .gl-form-input.form-control:disabled { cursor: not-allowed; - color: #868686; + color: #666; } .gl-form-input::placeholder, .gl-form-input.form-control::placeholder { @@ -573,9 +570,6 @@ svg { html { overflow-y: scroll; } -body { - text-decoration-skip: ink; -} .btn { border-radius: 4px; font-size: 0.875rem; @@ -794,20 +788,17 @@ kbd { .navbar-gitlab .header-content .title img { height: 24px; } -.navbar-gitlab .header-content .title a { +.navbar-gitlab .header-content .title a:not(.canary-badge) { display: flex; align-items: center; padding: 2px 8px; margin: 4px 2px 4px -8px; border-radius: 4px; } -.navbar-gitlab .header-content .title a:active { +.navbar-gitlab .header-content .title a:not(.canary-badge):active { box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 0 0 3px #63a6e9; outline: none; } -.navbar-gitlab .header-content .title .canary-badge { - margin-left: -8px; -} .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } @@ -991,9 +982,6 @@ kbd { visibility: hidden; top: 3px; } -.top-nav-toggle .dropdown-icon { - margin-right: 0.5rem; -} .tanuki-logo .tanuki { fill: #e24329; } @@ -1116,7 +1104,7 @@ kbd { font-weight: 600; } .nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: rgba(41, 41, 97, 0.08); + background-color: rgba(0, 0, 0, 0.08); } .nav-sidebar ul { padding-left: 0; @@ -1751,6 +1739,12 @@ svg.s16 { .gl-pt-0 { padding-top: 0; } +.gl-mr-auto { + margin-right: auto; +} +.gl-mr-3 { + margin-right: 0.5rem; +} .gl-ml-n2 { margin-left: -0.25rem; } diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index c0e2d8d44d4..edc579f48f6 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -11,6 +11,9 @@ html { font-family: sans-serif; line-height: 1.15; } +header { + display: block; +} body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, @@ -28,7 +31,8 @@ hr { height: 0; overflow: visible; } -h1 { +h1, +h3 { margin-top: 0; margin-bottom: 0.25rem; } @@ -49,26 +53,49 @@ img { vertical-align: middle; border-style: none; } +svg { + overflow: hidden; + vertical-align: middle; +} label { display: inline-block; margin-bottom: 0.5rem; } -input { +button { + border-radius: 0; +} +input, +button { margin: 0; font-family: inherit; font-size: inherit; line-height: inherit; } +button, input { overflow: visible; } +button { + text-transform: none; +} +[role="button"] { + cursor: pointer; +} +button:not(:disabled), +[type="button"]:not(:disabled), [type="submit"]:not(:disabled) { cursor: pointer; } +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { padding: 0; border-style: none; } +input[type="checkbox"] { + box-sizing: border-box; + padding: 0; +} fieldset { min-width: 0; padding: 0; @@ -78,7 +105,8 @@ fieldset { [hidden] { display: none !important; } -h1 { +h1, +h3 { margin-bottom: 0.25rem; font-weight: 600; line-height: 1.2; @@ -87,6 +115,9 @@ h1 { h1 { font-size: 2.1875rem; } +h3 { + font-size: 1.53125rem; +} hr { margin-top: 0.5rem; margin-bottom: 0.5rem; @@ -120,23 +151,42 @@ hr { max-width: 1140px; } } -.col-sm-12, -.col { +.row { + display: flex; + flex-wrap: wrap; + margin-right: -15px; + margin-left: -15px; +} +.col-md-6, +.col-sm-12 { position: relative; width: 100%; padding-right: 15px; padding-left: 15px; } -.col { - flex-basis: 0; - flex-grow: 1; - max-width: 100%; +.order-1 { + order: 1; +} +.order-12 { + order: 12; } @media (min-width: 576px) { .col-sm-12 { flex: 0 0 100%; max-width: 100%; } + .order-sm-1 { + order: 1; + } + .order-sm-12 { + order: 12; + } +} +@media (min-width: 768px) { + .col-md-6 { + flex: 0 0 50%; + max-width: 50%; + } } .form-control { display: block; @@ -169,16 +219,6 @@ hr { .form-group { margin-bottom: 1rem; } -.form-row { - display: flex; - flex-wrap: wrap; - margin-right: -5px; - margin-left: -5px; -} -.form-row > .col { - padding-right: 5px; - padding-left: 5px; -} .btn { display: inline-block; font-weight: 400; @@ -204,6 +244,137 @@ hr { fieldset:disabled a.btn { pointer-events: none; } +.btn-block { + display: block; + width: 100%; +} +.btn-block + .btn-block { + margin-top: 0.5rem; +} +input.btn-block[type="submit"], +input.btn-block[type="button"] { + width: 100%; +} +.custom-control { + position: relative; + z-index: 1; + display: block; + min-height: 1.5rem; + padding-left: 1.5rem; + color-adjust: exact; +} +.custom-control-input { + position: absolute; + left: 0; + z-index: -1; + width: 1rem; + height: 1.25rem; + opacity: 0; +} +.custom-control-input:checked ~ .custom-control-label::before { + color: #fff; + border-color: #007bff; + background-color: #007bff; +} +.custom-control-input:not(:disabled):active ~ .custom-control-label::before { + color: #fff; + background-color: #b3d7ff; + border-color: #b3d7ff; +} +.custom-control-input:disabled ~ .custom-control-label { + color: #5e5e5e; +} +.custom-control-input:disabled ~ .custom-control-label::before { + background-color: #fafafa; +} +.custom-control-label { + position: relative; + margin-bottom: 0; + vertical-align: top; +} +.custom-control-label::before { + position: absolute; + top: 0.25rem; + left: -1.5rem; + display: block; + width: 1rem; + height: 1rem; + pointer-events: none; + content: ""; + background-color: #fff; + border: #666 solid 1px; +} +.custom-control-label::after { + position: absolute; + top: 0.25rem; + left: -1.5rem; + display: block; + width: 1rem; + height: 1rem; + content: ""; + background: no-repeat 50% / 50% 50%; +} +.custom-checkbox .custom-control-label::before { + border-radius: 0.25rem; +} +.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e"); +} +.custom-checkbox + .custom-control-input:indeterminate + ~ .custom-control-label::before { + border-color: #007bff; + background-color: #007bff; +} +.custom-checkbox + .custom-control-input:indeterminate + ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e"); +} +.custom-checkbox + .custom-control-input:disabled:checked + ~ .custom-control-label::before { + background-color: rgba(0, 123, 255, 0.5); +} +.custom-checkbox + .custom-control-input:disabled:indeterminate + ~ .custom-control-label::before { + background-color: rgba(0, 123, 255, 0.5); +} +@media (prefers-reduced-motion: reduce) { +} +.tab-content > .tab-pane { + display: none; +} +.tab-content > .active { + display: block; +} +.navbar { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: 0.25rem 0.5rem; +} +.navbar .container { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} +.clearfix::after { + display: block; + clear: both; + content: ""; +} +.fixed-top { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 1030; +} .mt-3 { margin-top: 1rem !important; } @@ -213,8 +384,8 @@ fieldset:disabled a.btn { .text-nowrap { white-space: nowrap !important; } -.text-center { - text-align: center !important; +.font-weight-normal { + font-weight: 400 !important; } .gl-form-input, .gl-form-input.form-control { @@ -245,19 +416,109 @@ fieldset:disabled a.btn { .gl-form-input:disabled, .gl-form-input.form-control:disabled { cursor: not-allowed; - color: #868686; + color: #666; } .gl-form-input::placeholder, .gl-form-input.form-control::placeholder { color: #868686; } +.gl-form-checkbox { + font-size: 0.875rem; + line-height: 1rem; + color: #303030; +} +.gl-form-checkbox .custom-control-input:disabled, +.gl-form-checkbox .custom-control-input:disabled ~ .custom-control-label { + cursor: not-allowed; + color: #868686; +} +.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label { + cursor: pointer; +} +.gl-form-checkbox.custom-control + .custom-control-input + ~ .custom-control-label::before, +.gl-form-checkbox.custom-control + .custom-control-input + ~ .custom-control-label::after { + top: 0; +} +.gl-form-checkbox.custom-control + .custom-control-input + ~ .custom-control-label::before { + background-color: #fff; + border-color: #868686; +} +.gl-form-checkbox.custom-control + .custom-control-input:checked + ~ .custom-control-label::before { + background-color: #1f75cb; + border-color: #1f75cb; +} +.gl-form-checkbox.custom-control + .custom-control-input[type="checkbox"]:checked + ~ .custom-control-label::after, +.gl-form-checkbox.custom-control + .custom-control-input[type="checkbox"]:indeterminate + ~ .custom-control-label::after { + background: none; + background-color: #fff; + mask-repeat: no-repeat; + mask-position: center center; +} +.gl-form-checkbox.custom-control + .custom-control-input[type="checkbox"]:checked + ~ .custom-control-label::after { + mask-image: url('data:image/svg+xml,%3Csvg width="8" height="7" viewBox="0 0 8 7" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 3.05299L2.99123 5L7 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A'); +} +.gl-form-checkbox.custom-control + .custom-control-input[type="checkbox"]:indeterminate + ~ .custom-control-label::after { + mask-image: url('data:image/svg+xml,%3Csvg width="8" height="2" viewBox="0 0 8 2" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 1L8 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A'); +} +.gl-form-checkbox.custom-control.custom-checkbox + .custom-control-input:indeterminate + ~ .custom-control-label::before { + background-color: #1f75cb; + border-color: #1f75cb; +} +.gl-form-checkbox.custom-control + .custom-control-input:disabled + ~ .custom-control-label { + cursor: not-allowed; +} +.gl-form-checkbox.custom-control + .custom-control-input:disabled + ~ .custom-control-label::before { + background-color: #f0f0f0; + border-color: #dbdbdb; + pointer-events: auto; +} +.gl-form-checkbox.custom-control + .custom-control-input:checked:disabled + ~ .custom-control-label::before, +.gl-form-checkbox.custom-control + .custom-control-input:indeterminate:disabled + ~ .custom-control-label::before { + background-color: #dbdbdb; + border-color: #dbdbdb; +} +.gl-form-checkbox.custom-control + .custom-control-input:checked:disabled + ~ .custom-control-label::after, +.gl-form-checkbox.custom-control + .custom-control-input:indeterminate:disabled + ~ .custom-control-label::after { + background-color: #5e5e5e; +} .gl-button { display: inline-flex; } .gl-button:not(.btn-link):active { text-decoration: none; } -.gl-button.gl-button { +.gl-button.gl-button, +.gl-button.gl-button.btn-block { border-width: 0; padding-top: 0.5rem; padding-bottom: 0.5rem; @@ -273,7 +534,8 @@ fieldset:disabled a.btn { font-size: 0.875rem; border-radius: 0.25rem; } -.gl-button.gl-button .gl-button-text { +.gl-button.gl-button .gl-button-text, +.gl-button.gl-button.btn-block .gl-button-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -282,29 +544,39 @@ fieldset:disabled a.btn { margin-top: -1px; margin-bottom: -1px; } -.gl-button.gl-button .gl-button-icon { +.gl-button.gl-button .gl-button-icon, +.gl-button.gl-button.btn-block .gl-button-icon { height: 1rem; width: 1rem; flex-shrink: 0; margin-right: 0.25rem; top: auto; } -.gl-button.gl-button.btn-default { +.gl-button.gl-button.btn-default, +.gl-button.gl-button.btn-block.btn-default { background-color: #fff; } -.gl-button.gl-button.btn-default:active { +.gl-button.gl-button.btn-default:active, +.gl-button.gl-button.btn-default.active, +.gl-button.gl-button.btn-block.btn-default:active, +.gl-button.gl-button.btn-block.btn-default.active { box-shadow: inset 0 0 0 1px #5e5e5e, 0 0 0 1px #fff, 0 0 0 3px #428fdc; outline: none; background-color: #dbdbdb; } -.gl-button.gl-button.btn-confirm { +.gl-button.gl-button.btn-confirm, +.gl-button.gl-button.btn-block.btn-confirm { color: #fff; } -.gl-button.gl-button.btn-confirm { +.gl-button.gl-button.btn-confirm, +.gl-button.gl-button.btn-block.btn-confirm { background-color: #1f75cb; box-shadow: inset 0 0 0 1px #1068bf; } -.gl-button.gl-button.btn-confirm:active { +.gl-button.gl-button.btn-confirm:active, +.gl-button.gl-button.btn-confirm.active, +.gl-button.gl-button.btn-block.btn-confirm:active, +.gl-button.gl-button.btn-block.btn-confirm.active { box-shadow: inset 0 0 0 1px #033464, 0 0 0 1px #fff, 0 0 0 3px #428fdc; outline: none; background-color: #0b5cad; @@ -312,10 +584,14 @@ fieldset:disabled a.btn { body { font-size: 0.875rem; } -[type="submit"] { +button, +html [type="button"], +[type="submit"], +[role="button"] { cursor: pointer; } -h1 { +h1, +h3 { margin-top: 20px; margin-bottom: 10px; } @@ -325,6 +601,9 @@ a { hr { overflow: hidden; } +svg { + vertical-align: baseline; +} .form-control { font-size: 0.875rem; } @@ -332,15 +611,9 @@ hr { display: none !important; visibility: hidden !important; } -.hide { - display: none; -} html { overflow-y: scroll; } -body { - text-decoration-skip: ink; -} body.navless { background-color: #fff !important; } @@ -375,13 +648,34 @@ body.navless { background-color: #f0f0f0; box-shadow: none; } -.btn:active { +.btn:active, +.btn.active { background-color: #eaeaea; border-color: #e3e3e3; color: #303030; } -.light { - color: #303030; +.btn svg { + height: 15px; + width: 15px; +} +.btn svg:not(:last-child) { + margin-right: 5px; +} +.btn-block { + width: 100%; + margin: 0; + margin-bottom: 1rem; +} +.btn-block.btn { + padding: 6px 0; +} +.tab-content { + overflow: visible; +} +@media (max-width: 767.98px) { + .tab-content { + isolation: isolate; + } } hr { margin: 1.5rem 0; @@ -419,6 +713,9 @@ input { label { font-weight: 600; } +label.custom-control-label { + font-weight: 400; +} label.label-bold { font-weight: 600; } @@ -432,8 +729,25 @@ label.label-bold { .gl-show-field-errors .form-control:not(textarea) { height: 34px; } -.gl-show-field-errors .gl-field-hint { - color: #303030; +.navbar-empty { + justify-content: center; + height: var(--header-height, 48px); + background: #fff; + border-bottom: 1px solid #dbdbdb; +} +.navbar-empty .tanuki-logo, +.navbar-empty .brand-header-logo { + max-height: 100%; +} +.tanuki-logo .tanuki { + fill: #e24329; +} +.tanuki-logo .left-cheek, +.tanuki-logo .right-cheek { + fill: #fc6d26; +} +.tanuki-logo .chin { + fill: #fca326; } input::-moz-placeholder { color: #868686; @@ -445,6 +759,9 @@ input::-ms-input-placeholder { input:-ms-input-placeholder { color: #868686; } +svg { + fill: currentColor; +} .login-page .container { max-width: 960px; } @@ -477,6 +794,10 @@ input:-ms-input-placeholder { .login-page p { font-size: 13px; } +.login-page .signin-text p { + margin-bottom: 0; + line-height: 1.5; +} .login-page .borderless .login-box, .login-page .borderless .omniauth-container { box-shadow: none; @@ -549,6 +870,16 @@ input:-ms-input-placeholder { border-top-right-radius: 4px; border-top-left-radius: 4px; } +.login-page .new-session-tabs.nav-links-unboxed { + border-color: transparent; + box-shadow: none; +} +.login-page .new-session-tabs.nav-links-unboxed .nav-item { + border-left: 0; + border-right: 0; + border-bottom: 1px solid #dbdbdb; + background-color: transparent; +} .login-page .new-session-tabs.custom-provider-tabs { flex-wrap: wrap; } @@ -648,14 +979,20 @@ input:-ms-input-placeholder { } } -.gl-text-green-600 { - color: #217645; +.gl-display-flex { + display: flex; } -.gl-text-red-500 { - color: #dd2b0e; +.gl-display-inline-block { + display: inline-block; } -.gl-display-block { - display: block; +.gl-flex-wrap { + flex-wrap: wrap; +} +.gl-justify-content-center { + justify-content: center; +} +.gl-float-right { + float: right; } .gl-w-10 { width: 3.5rem; @@ -674,14 +1011,18 @@ input:-ms-input-placeholder { width: 100%; } } -.gl-p-4 { - padding: 0.75rem; +.gl-p-5 { + padding: 1rem; +} +.gl-px-5 { + padding-left: 1rem; + padding-right: 1rem; } .gl-pt-5 { padding-top: 1rem; } -.gl-mt-2 { - margin-top: 0.25rem; +.gl-mt-3 { + margin-top: 0.5rem; } .gl-mt-5 { margin-top: 1rem; @@ -701,15 +1042,17 @@ input:-ms-input-placeholder { .gl-mb-3 { margin-bottom: 0.5rem; } -.gl-mb-5 { - margin-bottom: 1rem; -} .gl-ml-auto { margin-left: auto; } .gl-ml-2 { margin-left: 0.25rem; } +@media (min-width: 576px) { + .gl-sm-mt-0 { + margin-top: 0; + } +} .gl-text-center { text-align: center; } @@ -719,6 +1062,9 @@ input:-ms-input-placeholder { .gl-font-weight-normal { font-weight: 400; } +.gl-font-weight-bold { + font-weight: 600; +} @import "startup/cloaking"; @include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index eeb4604f32a..4b74e449e06 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -101,7 +101,6 @@ $white-dark: #444; $theme-indigo-50: #1a1a40; $border-color: #4f4f4f; -$nav-active-bg: rgba(255, 255, 255, 0.08); :root { color-scheme: dark; @@ -206,7 +205,6 @@ body.gl-dark { --black: #{$black}; --svg-status-bg: #{$white}; - --nav-active-bg: #{$nav-active-bg}; .gl-button.gl-button, .gl-button.gl-button.btn-block { diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 92740aaf89e..e1ba2a69420 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -60,26 +60,6 @@ } .nav-sidebar { - li { - a { - color: var(--gray-600); - } - - > a:hover { - background-color: var(--nav-active-bg); - } - - &.active { - box-shadow: none; - - &:not(.fly-out-top-item) { - > a:not(.has-sub-items) { - background-color: var(--nav-active-bg); - } - } - } - } - .sidebar-sub-level-items.fly-out-list { box-shadow: none; border: 1px solid $border-color; @@ -92,7 +72,7 @@ aside.right-sidebar:not(.right-sidebar-merge-requests) { } body.gl-dark { - @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $gray-900, $white); + @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $white); .terms { .logo-text { diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss index 817557f37cd..90122cec31f 100644 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ b/app/assets/stylesheets/themes/theme_blue.scss @@ -6,7 +6,6 @@ body { $theme-blue-200, $theme-blue-500, $theme-blue-700, - $gray-900, $theme-blue-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss index 75b111f90c7..a6cdfb36a7c 100644 --- a/app/assets/stylesheets/themes/theme_gray.scss +++ b/app/assets/stylesheets/themes/theme_gray.scss @@ -7,7 +7,6 @@ body { $gray-300, $gray-500, $gray-900, - $gray-900, $white ); } diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss index 7e387e97452..0300f261d64 100644 --- a/app/assets/stylesheets/themes/theme_green.scss +++ b/app/assets/stylesheets/themes/theme_green.scss @@ -6,7 +6,6 @@ body { $theme-green-200, $theme-green-500, $theme-green-700, - $gray-900, $theme-green-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 042e21cebd6..d644d8acc98 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -6,18 +6,22 @@ $search-and-nav-links, $accent, $border-and-box-shadow, - $sidebar-text, - $nav-svg-color, - $color-alternate + $navbar-theme-color, + $navbar-theme-contrast-color ) { // Set custom properties --gl-theme-accent: #{$accent}; + $search-and-nav-links-a20: rgba($search-and-nav-links, 0.2); + $search-and-nav-links-a30: rgba($search-and-nav-links, 0.3); + $search-and-nav-links-a40: rgba($search-and-nav-links, 0.4); + $search-and-nav-links-a80: rgba($search-and-nav-links, 0.8); + // Header .navbar-gitlab { - background-color: $nav-svg-color; + background-color: $navbar-theme-color; .navbar-collapse { color: $search-and-nav-links; @@ -37,7 +41,7 @@ > button { &:hover, &:focus { - background-color: rgba($search-and-nav-links, 0.2); + background-color: $search-and-nav-links-a20; } } @@ -45,13 +49,13 @@ &.dropdown.show { > a, > button { - color: $nav-svg-color; - background-color: $color-alternate; + color: $navbar-theme-color; + background-color: $navbar-theme-contrast-color; } } &.line-separator { - border-left: 1px solid rgba($search-and-nav-links, 0.2); + border-left: 1px solid $search-and-nav-links-a20; } } } @@ -65,12 +69,12 @@ color: $search-and-nav-links; &.header-search-new { - color: $sidebar-text; + color: $gray-900; } > a { .notification-dot { - border: 2px solid $nav-svg-color; + border: 2px solid $navbar-theme-color; } &.header-help-dropdown-toggle { @@ -88,7 +92,7 @@ &:hover, &:focus { @include media-breakpoint-up(sm) { - background-color: rgba($search-and-nav-links, 0.2); + background-color: $search-and-nav-links-a20; } svg { @@ -97,7 +101,7 @@ .notification-dot { will-change: border-color, background-color; - border-color: adjust-color($nav-svg-color, $red: 33, $green: 33, $blue: 33); + border-color: adjust-color($navbar-theme-color, $red: 33, $green: 33, $blue: 33); } &.header-help-dropdown-toggle .notification-dot { @@ -108,12 +112,12 @@ &.active > a, &.dropdown.show > a { - color: $nav-svg-color; - background-color: $color-alternate; + color: $navbar-theme-color; + background-color: $navbar-theme-contrast-color; &:hover { svg { - fill: $nav-svg-color; + fill: $navbar-theme-color; } } @@ -123,7 +127,7 @@ &.header-help-dropdown-toggle { .notification-dot { - background-color: $nav-svg-color; + background-color: $navbar-theme-color; } } } @@ -131,7 +135,7 @@ .impersonated-user, .impersonated-user:hover { svg { - fill: $nav-svg-color; + fill: $navbar-theme-color; } } } @@ -142,30 +146,30 @@ > a { &:hover, &:focus { - background-color: rgba($search-and-nav-links, 0.2); + background-color: $search-and-nav-links-a20; } } } .header-search { - background-color: rgba($search-and-nav-links, 0.2) !important; + background-color: $search-and-nav-links-a20 !important; border-radius: $border-radius-default; &:hover { - background-color: rgba($search-and-nav-links, 0.3) !important; + background-color: $search-and-nav-links-a30 !important; } svg.gl-search-box-by-type-search-icon { - color: rgba($search-and-nav-links, 0.8); + color: $search-and-nav-links-a80; } input { background-color: transparent; - color: rgba($search-and-nav-links, 0.8); - box-shadow: inset 0 0 0 1px rgba($search-and-nav-links, 0.4); + color: $search-and-nav-links-a80; + box-shadow: inset 0 0 0 1px $search-and-nav-links-a40; &::placeholder { - color: rgba($search-and-nav-links, 0.8); + color: $search-and-nav-links-a80; } &:focus, @@ -178,27 +182,27 @@ .keyboard-shortcut-helper { color: $search-and-nav-links; - background-color: rgba($search-and-nav-links, 0.2); + background-color: $search-and-nav-links-a20; } } .search { form { - background-color: rgba($search-and-nav-links, 0.2); + background-color: $search-and-nav-links-a20; &:hover { - background-color: rgba($search-and-nav-links, 0.3); + background-color: $search-and-nav-links-a30; } } .search-input::placeholder { - color: rgba($search-and-nav-links, 0.8); + color: $search-and-nav-links-a80; } .search-input-wrap { .search-icon, .clear-icon { - fill: rgba($search-and-nav-links, 0.8); + fill: $search-and-nav-links-a80; } } @@ -209,7 +213,7 @@ .search-input-wrap { .search-icon { - fill: rgba($search-and-nav-links, 0.8); + fill: $search-and-nav-links-a80; } } } @@ -217,7 +221,7 @@ // Sidebar .nav-sidebar li.active > a { - color: $sidebar-text; + color: $gray-900; } .nav-sidebar { diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss index 3bf6cfea650..5a27a9cfdc5 100644 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ b/app/assets/stylesheets/themes/theme_indigo.scss @@ -6,7 +6,6 @@ body { $indigo-200, $indigo-500, $indigo-700, - $gray-900, $indigo-900, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss index 771a84911b3..7cb0d98802e 100644 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ b/app/assets/stylesheets/themes/theme_light_blue.scss @@ -6,7 +6,6 @@ body { $theme-light-blue-200, $theme-light-blue-500, $theme-light-blue-500, - $gray-900, $theme-light-blue-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss index ad19438d79a..a0cbec9a92b 100644 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ b/app/assets/stylesheets/themes/theme_light_gray.scss @@ -6,7 +6,6 @@ body { $gray-500, $gray-700, $gray-500, - $gray-900, $gray-50, $gray-500 ); diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index 8c991a7bfb3..797279cc37b 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -6,7 +6,6 @@ body { $theme-green-200, $theme-green-500, $theme-green-500, - $gray-900, $theme-light-green-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss index 6c220e0459a..3632c5ad45a 100644 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ b/app/assets/stylesheets/themes/theme_light_indigo.scss @@ -6,7 +6,6 @@ body { $indigo-200, $indigo-500, $indigo-500, - $gray-900, $indigo-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss index e1a715293b4..6c10d9178f1 100644 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ b/app/assets/stylesheets/themes/theme_light_red.scss @@ -6,7 +6,6 @@ body { $theme-light-red-200, $theme-light-red-500, $theme-light-red-500, - $gray-900, $theme-light-red-700, $white ); diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss index 19fd150727d..140e27de6e2 100644 --- a/app/assets/stylesheets/themes/theme_red.scss +++ b/app/assets/stylesheets/themes/theme_red.scss @@ -6,7 +6,6 @@ body { $theme-red-200, $theme-red-500, $theme-red-700, - $gray-900, $theme-red-900, $white ); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 6bd05f90f26..bdb8f758137 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -370,3 +370,13 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 .gl-flex-flow-row-wrap { flex-flow: row wrap; } + +/* + * The below style will be moved to @gitlab/ui by + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1963 + */ +.gl-gap-y-3 { + > * + * { + margin-top: $gl-spacing-scale-3; + } +} diff --git a/app/components/layouts/horizontal_section_component.haml b/app/components/layouts/horizontal_section_component.haml new file mode 100644 index 00000000000..4b5b4f1d0df --- /dev/null +++ b/app/components/layouts/horizontal_section_component.haml @@ -0,0 +1,10 @@ +%div{ formatted_options } + .row + .col-lg-4 + %h4.gl-mt-0 + = title + - if description? + %p + = description + .col-lg-8 + = body diff --git a/app/components/layouts/horizontal_section_component.rb b/app/components/layouts/horizontal_section_component.rb new file mode 100644 index 00000000000..48c960f17d9 --- /dev/null +++ b/app/components/layouts/horizontal_section_component.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Layouts + class HorizontalSectionComponent < ViewComponent::Base + # @param [Boolean] border + # @param [Hash] options + def initialize(border: true, options: {}) + @border = border + @options = options + end + + private + + renders_one :title + renders_one :description + renders_one :body + + def formatted_options + @options.merge({ class: [('gl-border-b' if @border), @options[:class]].flatten.compact }) + end + end +end diff --git a/app/components/pajamas/badge_component.html.haml b/app/components/pajamas/badge_component.html.haml new file mode 100644 index 00000000000..eaadc681f0e --- /dev/null +++ b/app/components/pajamas/badge_component.html.haml @@ -0,0 +1,6 @@ +- if link? + %a{ href: @href, **html_options }>< + = badge_content +- else + %span{ **html_options }>< + = badge_content diff --git a/app/components/pajamas/badge_component.rb b/app/components/pajamas/badge_component.rb new file mode 100644 index 00000000000..244064b0e1e --- /dev/null +++ b/app/components/pajamas/badge_component.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Pajamas + class BadgeComponent < Pajamas::Component + def initialize( + text = nil, + icon: nil, + icon_classes: [], + icon_only: false, + href: nil, + size: :md, + variant: :muted, + **html_options + ) + @text = text.presence + @icon = icon.to_s.presence + @icon_classes = Array.wrap(icon_classes) + @icon_only = @icon && icon_only + @href = href.presence + @size = filter_attribute(size.to_sym, SIZE_OPTIONS, default: :md) + @variant = filter_attribute(variant.to_sym, VARIANT_OPTIONS, default: :muted) + @html_options = html_options + end + + private + + SIZE_OPTIONS = [:sm, :md, :lg].freeze + VARIANT_OPTIONS = [:muted, :neutral, :info, :success, :warning, :danger].freeze + + delegate :sprite_icon, to: :helpers + + def badge_classes + ["gl-badge", "badge", "badge-pill", "badge-#{@variant}", @size.to_s] + end + + def icon_classes + classes = %w[gl-icon gl-badge-icon] + @icon_classes + classes.push("gl-mr-2") unless icon_only? + classes.join(" ") + end + + def icon_only? + @icon_only + end + + def link? + @href.present? + end + + # Determines the rendered text content. + # The content slot takes presedence over the text param. + def text + content || @text + end + + def badge_content + if icon_only? + sprite_icon(@icon, css_class: icon_classes) + elsif @icon.present? + sprite_icon(@icon, css_class: icon_classes) + text + else + text + end + end + + def html_options + options = format_options(options: @html_options, css_classes: badge_classes) + options.merge!({ aria: { label: text }, role: "img" }) if icon_only? + options + end + end +end diff --git a/app/components/pajamas/button_component.rb b/app/components/pajamas/button_component.rb index 4233e446d5b..b2dd798b718 100644 --- a/app/components/pajamas/button_component.rb +++ b/app/components/pajamas/button_component.rb @@ -112,7 +112,7 @@ module Pajamas def base_attributes attributes = {} - attributes['disabled'] = '' if @disabled || @loading + attributes['disabled'] = 'disabled' if @disabled || @loading attributes['aria-disabled'] = true if @disabled || @loading attributes['type'] = @type unless @href diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 206a5b11e4b..0de2115d4d6 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -30,10 +30,7 @@ class AbuseReportsController < ApplicationController private def report_params - params.require(:abuse_report).permit(%i( - message - user_id - )) + params.require(:abuse_report).permit(:message, :user_id) end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb index 67a39d8870b..4a7706db94e 100644 --- a/app/controllers/acme_challenges_controller.rb +++ b/app/controllers/acme_challenges_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Rails/ApplicationController class AcmeChallengesController < ActionController::Base def show if acme_order @@ -15,3 +16,4 @@ class AcmeChallengesController < ActionController::Base @acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token]) end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 6f21b123eb0..b75a7c4a2dd 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -18,23 +18,23 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned - :general, :reporting, :metrics_and_profiling, :network, - :preferences, :update, :reset_health_check_token - ] + :general, :reporting, :metrics_and_profiling, :network, + :preferences, :update, :reset_health_check_token + ] feature_category :metrics, [ - :create_self_monitoring_project, - :status_create_self_monitoring_project, - :delete_self_monitoring_project, - :status_delete_self_monitoring_project - ] + :create_self_monitoring_project, + :status_create_self_monitoring_project, + :delete_self_monitoring_project, + :status_delete_self_monitoring_project + ] urgency :low, [ - :create_self_monitoring_project, - :status_create_self_monitoring_project, - :delete_self_monitoring_project, - :status_delete_self_monitoring_project, - :reset_error_tracking_access_token - ] + :create_self_monitoring_project, + :status_create_self_monitoring_project, + :delete_self_monitoring_project, + :status_delete_self_monitoring_project, + :reset_error_tracking_access_token + ] feature_category :source_code_management, [:repository, :clear_repository_check_states] feature_category :continuous_integration, [:ci_cd, :reset_registration_token] diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index b0d7c8cb8f2..d66b3cb4366 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -14,7 +14,7 @@ class Admin::ApplicationsController < Admin::ApplicationController end def show - @created = get_created_session + @created = get_created_session if Feature.disabled?('hash_oauth_secrets') end def new @@ -30,9 +30,14 @@ class Admin::ApplicationsController < Admin::ApplicationController if @application.persisted? flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) - set_created_session + if Feature.enabled?('hash_oauth_secrets') + @created = true + render :show + else + set_created_session - redirect_to admin_application_url(@application) + redirect_to admin_application_url(@application) + end else render :new end diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index a53e832329f..251ba9e29f2 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -57,14 +57,15 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController end def broadcast_message_params - params.require(:broadcast_message).permit(%i( - theme - ends_at - message - starts_at - target_path - broadcast_type - dismissable - ), target_access_levels: []).reverse_merge!(target_access_levels: []) + params.require(:broadcast_message) + .permit(%i( + theme + ends_at + message + starts_at + target_path + broadcast_type + dismissable + ), target_access_levels: []).reverse_merge!(target_access_levels: []) end end diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index 468a1077694..ce3d769f35e 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -1,15 +1,20 @@ # frozen_string_literal: true class Admin::CohortsController < Admin::ApplicationController - include RedisTracking + include ProductAnalyticsTracking feature_category :devops_reports urgency :low + track_custom_event :index, + name: 'i_analytics_cohorts', + action: 'perform_analytics_usage_action', + label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', + destinations: %i[redis_hll snowplow] + def index @cohorts = load_cohorts - track_cohorts_visit end private @@ -22,7 +27,11 @@ class Admin::CohortsController < Admin::ApplicationController CohortsSerializer.new.represent(cohorts_results) end - def track_cohorts_visit - track_unique_redis_hll_event('i_analytics_cohorts') if trackable_html_request? + def tracking_namespace_source + nil + end + + def tracking_project_source + nil end end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 8fe106249c3..37dde065e70 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -14,14 +14,7 @@ class Admin::DashboardController < Admin::ApplicationController @groups = Group.order_id_desc.with_route.limit(10) @notices = Gitlab::ConfigChecker::PumaRuggedChecker.check @notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check - @redis_versions = [ - Gitlab::Redis::Queues, - Gitlab::Redis::SharedState, - Gitlab::Redis::Cache, - Gitlab::Redis::TraceChunks, - Gitlab::Redis::RateLimiting, - Gitlab::Redis::Sessions - ].map(&:version).uniq + @redis_versions = Gitlab::Redis::ALL_CLASSES.map(&:version).uniq end def stats diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb index aa13673095d..a283d3abb0b 100644 --- a/app/controllers/admin/hook_logs_controller.rb +++ b/app/controllers/admin/hook_logs_controller.rb @@ -1,34 +1,17 @@ # frozen_string_literal: true -class Admin::HookLogsController < Admin::ApplicationController - include ::Integrations::HooksExecution +module Admin + class HookLogsController < Admin::ApplicationController + include WebHooks::HookLogActions - before_action :hook, only: [:show, :retry] - before_action :hook_log, only: [:show, :retry] + private - respond_to :html + def hook + @hook ||= SystemHook.find(params[:hook_id]) + end - feature_category :integrations - urgency :low, [:retry] - - def show - end - - def retry - result = hook.execute(hook_log.request_data, hook_log.trigger) - - set_hook_execution_notice(result) - - redirect_to edit_admin_hook_path(@hook) - end - - private - - def hook - @hook ||= SystemHook.find(params[:hook_id]) - end - - def hook_log - @hook_log ||= hook.web_hook_logs.find(params[:id]) + def after_retry_redirect_path + edit_admin_hook_path(hook) + end end end diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index 810801d4209..1dc6c68d8ca 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::HooksController < Admin::ApplicationController - include ::Integrations::HooksExecution + include ::WebHooks::HookActions before_action :hook_logs, only: :edit @@ -27,7 +27,7 @@ class Admin::HooksController < Admin::ApplicationController end def hook_logs - @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]) + @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count end def hook_param_names diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb index 7bfbabe8dfc..2cebc059830 100644 --- a/app/controllers/admin/plan_limits_controller.rb +++ b/app/controllers/admin/plan_limits_controller.rb @@ -28,24 +28,25 @@ class Admin::PlanLimitsController < Admin::ApplicationController end def plan_limits_params - params.require(:plan_limits).permit(%i[ - plan_id - conan_max_file_size - helm_max_file_size - maven_max_file_size - npm_max_file_size - nuget_max_file_size - pypi_max_file_size - terraform_module_max_file_size - generic_packages_max_file_size - ci_pipeline_size - ci_active_jobs - ci_active_pipelines - ci_project_subscriptions - ci_pipeline_schedules - ci_needs_size_limit - ci_registered_group_runners - ci_registered_project_runners - ]) + params.require(:plan_limits) + .permit(%i[ + plan_id + conan_max_file_size + helm_max_file_size + maven_max_file_size + npm_max_file_size + nuget_max_file_size + pypi_max_file_size + terraform_module_max_file_size + generic_packages_max_file_size + ci_pipeline_size + ci_active_jobs + ci_active_pipelines + ci_project_subscriptions + ci_pipeline_schedules + ci_needs_size_limit + ci_registered_group_runners + ci_registered_project_runners + ]) end end diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 24d7bd9ca7b..a0f72f5e58c 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -8,6 +8,10 @@ class Admin::RunnersController < Admin::ApplicationController push_frontend_feature_flag(:admin_runners_bulk_delete) end + before_action only: [:show] do + push_frontend_feature_flag(:enforce_runner_token_expires_at) + end + feature_category :runner urgency :low @@ -22,7 +26,7 @@ class Admin::RunnersController < Admin::ApplicationController end def update - if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success? respond_to do |format| format.html { redirect_to edit_admin_runner_path(@runner) } end @@ -39,7 +43,7 @@ class Admin::RunnersController < Admin::ApplicationController end def resume - if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: true).success? redirect_to admin_runners_path, notice: _('Runner was successfully updated.') else redirect_to admin_runners_path, alert: _('Runner was not updated.') @@ -47,7 +51,7 @@ class Admin::RunnersController < Admin::ApplicationController end def pause - if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: false).success? redirect_to admin_runners_path, notice: _('Runner was successfully updated.') else redirect_to admin_runners_path, alert: _('Runner was not updated.') diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index e4e866a8b60..3a55fc4b951 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::SpamLogsController < Admin::ApplicationController - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned + feature_category :instance_resiliency # rubocop: disable CodeReuse/ActiveRecord def index diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb index 69bcfdf4791..e97ead12f71 100644 --- a/app/controllers/admin/topics_controller.rb +++ b/app/controllers/admin/topics_controller.rb @@ -49,16 +49,12 @@ class Admin::TopicsController < Admin::ApplicationController source_topic = Projects::Topic.find(merge_params[:source_topic_id]) target_topic = Projects::Topic.find(merge_params[:target_topic_id]) - begin - ::Topics::MergeService.new(source_topic, target_topic).execute - rescue ArgumentError => e - return render status: :bad_request, json: { type: :alert, message: e.message } - end + response = ::Topics::MergeService.new(source_topic, target_topic).execute + return render status: :bad_request, json: { type: :alert, message: response.message } if response.error? message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.') - redirect_to admin_topics_path, - status: :found, - notice: message % { source_topic: source_topic.name, target_topic: target_topic.name } + flash[:toast] = message % { source_topic: source_topic.name, target_topic: target_topic.name } + redirect_to admin_topics_path, status: :found end private diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 5cc0c8f3970..1a57d271271 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -105,7 +105,7 @@ class Admin::UsersController < Admin::ApplicationController return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked? return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated? return redirect_back_or_admin_user(notice: _("Internal users cannot be deactivated")) if user.internal? - return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated? + return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: Gitlab::CurrentSettings.deactivate_dormant_users_period }) unless user.can_be_deactivated? user.deactivate redirect_back_or_admin_user(notice: _("Successfully deactivated")) diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 11377df7a10..5028544795c 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -77,10 +77,10 @@ module Boards :milestone, :assignees, project: [ - :route, - { - namespace: [:route] - } + :route, + { + namespace: [:route] + } ], labels: [:priorities], notes: [:award_emoji, :author] diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index 4e5af1945a4..6139168d29f 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Rails/ApplicationController class ChaosController < ActionController::Base before_action :validate_chaos_secret, unless: :development_or_test? @@ -93,3 +94,4 @@ class ChaosController < ActionController::Base Rails.env.development? || Rails.env.test? end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/concerns/accepts_pending_invitations.rb b/app/controllers/concerns/accepts_pending_invitations.rb index 53dec698fa0..1723058c217 100644 --- a/app/controllers/concerns/accepts_pending_invitations.rb +++ b/app/controllers/concerns/accepts_pending_invitations.rb @@ -8,7 +8,6 @@ module AcceptsPendingInvitations if user.pending_invitations.load.any? user.accept_pending_invitations! - clear_stored_location_for(user: user) after_pending_invitations_hook end end @@ -16,10 +15,4 @@ module AcceptsPendingInvitations def after_pending_invitations_hook # no-op end - - def clear_stored_location_for(user:) - session_key = stored_location_key_for(user) - - session.delete(session_key) - end end diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb index 45392625e45..e9fb2563e42 100644 --- a/app/controllers/concerns/dependency_proxy/group_access.rb +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -20,3 +20,5 @@ module DependencyProxy end end end + +DependencyProxy::GroupAccess.prepend_mod_with('DependencyProxy::GroupAccess') diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb index 70de72f15fc..211566aeda7 100644 --- a/app/controllers/concerns/harbor/access.rb +++ b/app/controllers/concerns/harbor/access.rb @@ -17,7 +17,7 @@ module Harbor private def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration) + render_404 unless Feature.enabled?(:harbor_registry_integration, defined?(group) ? group : project) end def authorize_read_harbor_registry! diff --git a/app/controllers/concerns/integrations/hooks_execution.rb b/app/controllers/concerns/integrations/hooks_execution.rb deleted file mode 100644 index fb26840168f..00000000000 --- a/app/controllers/concerns/integrations/hooks_execution.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -module Integrations::HooksExecution - extend ActiveSupport::Concern - - included do - attr_writer :hooks, :hook - end - - def index - self.hooks = relation.select(&:persisted?) - self.hook = relation.new - end - - def create - self.hook = relation.new(hook_params) - hook.save - - unless hook.valid? - self.hooks = relation.select(&:persisted?) - flash[:alert] = hook.errors.full_messages.join.html_safe - end - - redirect_to action: :index - end - - def update - if hook.update(hook_params) - flash[:notice] = _('Hook was successfully updated.') - redirect_to action: :index - else - render 'edit' - end - end - - def destroy - destroy_hook(hook) - - redirect_to action: :index, status: :found - end - - def edit - redirect_to(action: :index) unless hook - end - - private - - def hook_params - permitted = hook_param_names + trigger_values - permitted << { url_variables: [:key, :value] } - - ps = params.require(:hook).permit(*permitted).to_h - - ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables) - - if action_name == 'update' && ps.key?(:url_variables) - supplied = ps[:url_variables] - ps[:url_variables] = hook.url_variables.merge(supplied).compact - end - - ps - end - - def hook_param_names - %i[enable_ssl_verification token url push_events_branch_filter] - end - - def destroy_hook(hook) - result = WebHooks::DestroyService.new(current_user).execute(hook) - - if result[:status] == :success - flash[:notice] = - if result[:async] - _("%{hook_type} was scheduled for deletion") % { hook_type: hook.model_name.human } - else - _("%{hook_type} was deleted") % { hook_type: hook.model_name.human } - end - else - flash[:alert] = result[:message] - end - end - - def set_hook_execution_notice(result) - http_status = result[:http_status] - message = result[:message] - - if http_status && http_status >= 200 && http_status < 400 - flash[:notice] = "Hook executed successfully: HTTP #{http_status}" - elsif http_status - flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}" - else - flash[:alert] = "Hook execution failed: #{message}" - end - end -end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index f1d80e37674..7c3401a7e90 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -193,7 +193,10 @@ module IssuableActions end def render_cached_discussions(discussions, serializer, cache_context) - render_cached(discussions, with: serializer, cache_context: -> (_) { cache_context }, context: self) + render_cached(discussions, + with: serializer, + cache_context: -> (_) { cache_context }, + context: self) end def paginated_discussions diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index fb11bece79c..8a67b62f28b 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -150,7 +150,11 @@ module MembershipActions when 'only' [:inherited] else - [:inherited, :direct] + if Feature.enabled?(:webui_members_inherited_users, current_user) + [:inherited, :direct, :shared_from_groups] + else + [:inherited, :direct] + end end end end diff --git a/app/controllers/concerns/packages_access.rb b/app/controllers/concerns/packages_access.rb index 6df2e064bb2..a7d16a5bc88 100644 --- a/app/controllers/concerns/packages_access.rb +++ b/app/controllers/concerns/packages_access.rb @@ -15,6 +15,6 @@ module PackagesAccess end def verify_read_package! - authorize_read_package!(project) + access_denied! unless can?(current_user, :read_package, project&.packages_policy_subject) end end diff --git a/app/controllers/concerns/product_analytics_tracking.rb b/app/controllers/concerns/product_analytics_tracking.rb index 260b433cc6f..8e936782e5a 100644 --- a/app/controllers/concerns/product_analytics_tracking.rb +++ b/app/controllers/concerns/product_analytics_tracking.rb @@ -66,7 +66,17 @@ module ProductAnalyticsTracking i_analytics_dev_ops_score: :route_hll_to_snowplow_phase2, p_analytics_merge_request: :route_hll_to_snowplow_phase2, i_analytics_instance_statistics: :route_hll_to_snowplow_phase2, - g_analytics_contribution: :route_hll_to_snowplow_phase2 + g_analytics_contribution: :route_hll_to_snowplow_phase2, + p_analytics_pipelines: :route_hll_to_snowplow_phase2, + p_analytics_code_reviews: :route_hll_to_snowplow_phase2, + p_analytics_valuestream: :route_hll_to_snowplow_phase2, + p_analytics_insights: :route_hll_to_snowplow_phase2, + p_analytics_issues: :route_hll_to_snowplow_phase2, + p_analytics_repo: :route_hll_to_snowplow_phase2, + g_analytics_insights: :route_hll_to_snowplow_phase2, + g_analytics_issues: :route_hll_to_snowplow_phase2, + g_analytics_productivity: :route_hll_to_snowplow_phase2, + i_analytics_cohorts: :route_hll_to_snowplow_phase2 } Feature.enabled?(events_to_ff[event.to_sym], tracking_namespace_source) diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb index 1a3e7136481..782cae53c3f 100644 --- a/app/controllers/concerns/verifies_with_email.rb +++ b/app/controllers/concerns/verifies_with_email.rb @@ -7,11 +7,9 @@ module VerifiesWithEmail extend ActiveSupport::Concern include ActionView::Helpers::DateHelper - TOKEN_LENGTH = 6 - TOKEN_VALID_FOR_MINUTES = 60 - included do prepend_before_action :verify_with_email, only: :create, unless: -> { two_factor_enabled? } + skip_before_action :required_signup_info, only: :successful_verification end def verify_with_email @@ -76,7 +74,8 @@ module VerifiesWithEmail def send_verification_instructions(user) return if send_rate_limited?(user) - raw_token, encrypted_token = generate_token + service = Users::EmailVerification::GenerateTokenService.new(attr: :unlock_token) + raw_token, encrypted_token = service.execute user.unlock_token = encrypted_token user.lock_access!({ send_instructions: false }) send_verification_instructions_email(user, raw_token) @@ -88,27 +87,20 @@ module VerifiesWithEmail Notify.verification_instructions_email( user.id, token: token, - expires_in: TOKEN_VALID_FOR_MINUTES).deliver_later + expires_in: Users::EmailVerification::ValidateTokenService::TOKEN_VALID_FOR_MINUTES).deliver_later log_verification(user, :instructions_sent) end def verify_token(user, token) - return handle_verification_failure(user, :rate_limited) if verification_rate_limited?(user) - return handle_verification_failure(user, :invalid) unless valid_token?(user, token) - return handle_verification_failure(user, :expired) if expired_token?(user) - - handle_verification_success(user) - end - - def generate_token - raw_token = SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0') - encrypted_token = digest_token(raw_token) - [raw_token, encrypted_token] - end + service = Users::EmailVerification::ValidateTokenService.new(attr: :unlock_token, user: user, token: token) + result = service.execute - def digest_token(token) - Devise.token_generator.digest(User, :unlock_token, token) + if result[:status] == :success + handle_verification_success(user) + else + handle_verification_failure(user, result[:reason], result[:message]) + end end def render_sign_in_rate_limited @@ -122,44 +114,17 @@ module VerifiesWithEmail distance_of_time_in_words(interval_in_seconds) end - def verification_rate_limited?(user) - Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user.unlock_token) - end - def send_rate_limited?(user) Gitlab::ApplicationRateLimiter.throttled?(:email_verification_code_send, scope: user) end - def expired_token?(user) - user.locked_at < (Time.current - TOKEN_VALID_FOR_MINUTES.minutes) - end - - def valid_token?(user, token) - user.unlock_token == digest_token(token) - end - - def handle_verification_failure(user, reason) - message = case reason - when :rate_limited - s_("IdentityVerification|You've reached the maximum amount of tries. "\ - 'Wait %{interval} or resend a new code and try again.') % { interval: email_verification_interval } - when :expired - s_('IdentityVerification|The code has expired. Resend a new code and try again.') - when :invalid - s_('IdentityVerification|The code is incorrect. Enter it again, or resend a new code.') - end - + def handle_verification_failure(user, reason, message) user.errors.add(:base, message) log_verification(user, :failed_attempt, reason) prompt_for_email_verification(user) end - def email_verification_interval - interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval] - distance_of_time_in_words(interval_in_seconds) - end - def handle_verification_success(user) user.unlock_access! log_verification(user, :successful) diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb new file mode 100644 index 00000000000..ea11f13c7ef --- /dev/null +++ b/app/controllers/concerns/web_hooks/hook_actions.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module WebHooks + module HookActions + extend ActiveSupport::Concern + include HookExecutionNotice + + included do + attr_writer :hooks, :hook + end + + def index + self.hooks = relation.select(&:persisted?) + self.hook = relation.new + end + + def create + self.hook = relation.new(hook_params) + hook.save + + unless hook.valid? + self.hooks = relation.select(&:persisted?) + flash[:alert] = hook.errors.full_messages.join.html_safe + end + + redirect_to action: :index + end + + def update + if hook.update(hook_params) + flash[:notice] = _('Hook was successfully updated.') + redirect_to action: :index + else + render 'edit' + end + end + + def destroy + destroy_hook(hook) + + redirect_to action: :index, status: :found + end + + def edit + redirect_to(action: :index) unless hook + end + + private + + def hook_params + permitted = hook_param_names + trigger_values + permitted << { url_variables: [:key, :value] } + + ps = params.require(:hook).permit(*permitted).to_h + + ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables) + + if action_name == 'update' && ps.key?(:url_variables) + supplied = ps[:url_variables] + ps[:url_variables] = hook.url_variables.merge(supplied).compact + end + + ps + end + + def hook_param_names + %i[enable_ssl_verification token url push_events_branch_filter] + end + + def destroy_hook(hook) + result = WebHooks::DestroyService.new(current_user).execute(hook) + + if result[:status] == :success + flash[:notice] = + if result[:async] + format(_("%{hook_type} was scheduled for deletion"), hook_type: hook.model_name.human) + else + format(_("%{hook_type} was deleted"), hook_type: hook.model_name.human) + end + else + flash[:alert] = result[:message] + end + end + end +end diff --git a/app/controllers/concerns/web_hooks/hook_execution_notice.rb b/app/controllers/concerns/web_hooks/hook_execution_notice.rb new file mode 100644 index 00000000000..d651313b30d --- /dev/null +++ b/app/controllers/concerns/web_hooks/hook_execution_notice.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module WebHooks + module HookExecutionNotice + private + + def set_hook_execution_notice(result) + http_status = result[:http_status] + message = result[:message] + + if http_status && http_status >= 200 && http_status < 400 + flash[:notice] = "Hook executed successfully: HTTP #{http_status}" + elsif http_status + flash[:alert] = "Hook executed successfully but returned HTTP #{http_status} #{message}" + else + flash[:alert] = "Hook execution failed: #{message}" + end + end + end +end diff --git a/app/controllers/concerns/web_hooks/hook_log_actions.rb b/app/controllers/concerns/web_hooks/hook_log_actions.rb new file mode 100644 index 00000000000..f3378d7c857 --- /dev/null +++ b/app/controllers/concerns/web_hooks/hook_log_actions.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module WebHooks + module HookLogActions + extend ActiveSupport::Concern + include HookExecutionNotice + + included do + before_action :hook, only: [:show, :retry] + before_action :hook_log, only: [:show, :retry] + + respond_to :html + + feature_category :integrations + urgency :low, [:retry] + end + + def show + hide_search_settings + end + + def retry + execute_hook + redirect_to after_retry_redirect_path + end + + private + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def hook_log + @hook_log ||= hook.web_hook_logs.find(params[:id]) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + + def execute_hook + result = hook.execute(hook_log.request_data, hook_log.trigger) + set_hook_execution_notice(result) + end + + def hide_search_settings + @hide_search_settings ||= true + end + end +end diff --git a/app/controllers/groups/observability_controller.rb b/app/controllers/groups/observability_controller.rb new file mode 100644 index 00000000000..5b6503494c4 --- /dev/null +++ b/app/controllers/groups/observability_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +module Groups + class ObservabilityController < Groups::ApplicationController + feature_category :tracing + + content_security_policy do |p| + next if p.directives.blank? + + default_frame_src = p.directives['frame-src'] || p.directives['default-src'] + + # When ObservabilityUI is not authenticated, it needs to be able to redirect to the GL sign-in page, hence 'self' + frame_src_values = Array.wrap(default_frame_src) | [ObservabilityController.observability_url, "'self'"] + + p.frame_src(*frame_src_values) + end + + before_action :check_observability_allowed, only: :index + + def index + # Format: https://observe.gitlab.com/-/GROUP_ID + @observability_iframe_src = "#{ObservabilityController.observability_url}/-/#{@group.id}" + + # Uncomment below for testing with local GDK + # @observability_iframe_src = "#{ObservabilityController.observability_url}/9970?groupId=14485840" + + render layout: 'group', locals: { base_layout: 'layouts/fullscreen' } + end + + private + + def self.observability_url + return ENV['OVERRIDE_OBSERVABILITY_URL'] if ENV['OVERRIDE_OBSERVABILITY_URL'] + # TODO Make observability URL configurable https://gitlab.com/gitlab-org/opstrace/opstrace-ui/-/issues/80 + return "https://staging.observe.gitlab.com" if Gitlab.staging? + + "https://observe.gitlab.com" + end + + def check_observability_allowed + return render_404 unless self.class.observability_url.present? + + render_404 unless can?(current_user, :read_observability, @group) + end + end +end diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index aeb54527c69..652f12e34ba 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -5,12 +5,17 @@ class Groups::RunnersController < Groups::ApplicationController before_action :authorize_admin_group_runners!, only: [:edit, :update, :destroy, :pause, :resume] before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show] + before_action only: [:show] do + push_frontend_feature_flag(:enforce_runner_token_expires_at) + end + feature_category :runner urgency :low def index finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }) @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000) + @group_runner_registration_token = @group.runners_token if can?(current_user, :register_group_runners, group) Gitlab::Tracking.event(self.class.name, 'index', user: current_user, namespace: @group) end @@ -22,7 +27,7 @@ class Groups::RunnersController < Groups::ApplicationController end def update - if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success? redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.') else render 'edit' diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb index bfe61696e0f..3557d485422 100644 --- a/app/controllers/groups/settings/applications_controller.rb +++ b/app/controllers/groups/settings/applications_controller.rb @@ -16,7 +16,7 @@ module Groups end def show - @created = get_created_session + @created = get_created_session if Feature.disabled?('hash_oauth_secrets') end def edit @@ -28,9 +28,15 @@ module Groups if @application.persisted? flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) - set_created_session + if Feature.enabled?('hash_oauth_secrets') - redirect_to group_settings_application_url(@group, @application) + @created = true + render :show + else + set_created_session + + redirect_to group_settings_application_url(@group, @application) + end else set_index_vars render :index diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb index b0431c31179..cb62ea2a543 100644 --- a/app/controllers/groups/settings/repository_controller.rb +++ b/app/controllers/groups/settings/repository_controller.rb @@ -5,8 +5,9 @@ module Groups class RepositoryController < Groups::ApplicationController layout 'group_settings' skip_cross_project_access_check :show - before_action :authorize_create_deploy_token! - before_action :define_deploy_token_variables + before_action :authorize_create_deploy_token!, only: :create_deploy_token + before_action :authorize_access!, only: :show + before_action :define_deploy_token_variables, if: -> { can?(current_user, :create_deploy_token, @group) } before_action do push_frontend_feature_flag(:ajax_new_deploy_token, @group) end @@ -16,13 +17,13 @@ module Groups def create_deploy_token result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute - @new_deploy_token = result[:deploy_token] if result[:status] == :success + @created_deploy_token = result[:deploy_token] respond_to do |format| format.json do # IMPORTANT: It's a security risk to expose the token value more than just once here! - json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json + json = API::Entities::DeployTokenWithToken.represent(@created_deploy_token).as_json render json: json, status: result[:http_status] end format.html do @@ -31,6 +32,7 @@ module Groups end end else + @new_deploy_token = result[:deploy_token] respond_to do |format| format.json { render json: { message: result[:message] }, status: result[:http_status] } format.html do @@ -43,6 +45,10 @@ module Groups private + def authorize_access! + authorize_admin_group! + end + def define_deploy_token_variables @deploy_tokens = @group.deploy_tokens.active @@ -55,3 +61,5 @@ module Groups end end end + +Groups::Settings::RepositoryController.prepend_mod diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 32b187c3260..9316204d89c 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -49,9 +49,9 @@ class GroupsController < Groups::ApplicationController layout :determine_layout feature_category :subgroups, [ - :index, :new, :create, :show, :edit, :update, - :destroy, :details, :transfer, :activity - ] + :index, :new, :create, :show, :edit, :update, + :destroy, :details, :transfer, :activity + ] feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown] feature_category :code_review, [:merge_requests, :unfoldered_environment_names] @@ -276,6 +276,7 @@ class GroupsController < Groups::ApplicationController :avatar, :description, :emails_disabled, + :show_diff_preview_in_email, :mentions_disabled, :lfs_enabled, :name, diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 071378f266e..5fac7c0d663 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Rails/ApplicationController class HealthController < ActionController::Base protect_from_forgery with: :exception, prepend: true include RequiresWhitelistedMonitoringClient @@ -11,13 +12,7 @@ class HealthController < ActionController::Base ALL_CHECKS = [ *CHECKS, Gitlab::HealthChecks::DbCheck, - Gitlab::HealthChecks::Redis::RedisCheck, - Gitlab::HealthChecks::Redis::CacheCheck, - Gitlab::HealthChecks::Redis::QueuesCheck, - Gitlab::HealthChecks::Redis::SharedStateCheck, - Gitlab::HealthChecks::Redis::TraceChunksCheck, - Gitlab::HealthChecks::Redis::RateLimitingCheck, - Gitlab::HealthChecks::Redis::SessionsCheck, + *Gitlab::HealthChecks::Redis::ALL_INSTANCE_CHECKS, Gitlab::HealthChecks::GitalyCheck ].freeze @@ -45,3 +40,4 @@ class HealthController < ActionController::Base render json: result.json, status: result.http_status end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 1508531828d..9635e476510 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -12,8 +12,7 @@ class HelpController < ApplicationController YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze def index - # Remove YAML frontmatter so that it doesn't look weird - @help_index = File.read(path_to_doc('index.md')).sub(YAML_FRONT_MATTER_REGEXP, '') + @help_index = get_markdown_without_frontmatter(path_to_doc('index.md')) # Prefix Markdown links with `help/` unless they are external links. # '//' not necessarily part of URL, e.g., mailto:mail@example.com @@ -59,8 +58,25 @@ class HelpController < ApplicationController @instance_configuration = InstanceConfiguration.new end + def drawers + @clean_path = Rack::Utils.clean_path_info(params[:markdown_file]) + @path = path_to_doc("#{@clean_path}.md") + + if File.exist?(@path) + render :drawers, formats: :html, layout: false + else + head :not_found + end + end + private + # Remove YAML frontmatter so that it doesn't look weird + helper_method :get_markdown_without_frontmatter + def get_markdown_without_frontmatter(path) + File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '') + end + def redirect_to_documentation_website? Gitlab::UrlSanitizer.valid_web?(documentation_url) end @@ -100,8 +116,7 @@ class HelpController < ApplicationController path = path_to_doc("#{@path}.md") if File.exist?(path) - # Remove YAML frontmatter so that it doesn't look weird - @markdown = File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '') + @markdown = get_markdown_without_frontmatter(path) render :show, formats: :html else diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index 9fcb8385312..58a985cbc46 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -13,6 +13,7 @@ class IdeController < ApplicationController push_frontend_feature_flag(:build_service_proxy) push_frontend_feature_flag(:schema_linting) push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab) + push_frontend_feature_flag(:vscode_web_ide, current_user) define_index_vars end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 9cc58ce542c..8a3e6809736 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -130,7 +130,7 @@ class Import::GithubController < Import::BaseController if sanitized_filter_param client.search_repos_by_name(sanitized_filter_param, pagination_options)[:items] else - client.octokit.repos(nil, pagination_options) + client.repos(pagination_options) end else filtered(client.repos) diff --git a/app/controllers/jira_connect/oauth_callbacks_controller.rb b/app/controllers/jira_connect/oauth_callbacks_controller.rb index f603a563402..e1a47a12b6d 100644 --- a/app/controllers/jira_connect/oauth_callbacks_controller.rb +++ b/app/controllers/jira_connect/oauth_callbacks_controller.rb @@ -7,5 +7,7 @@ class JiraConnect::OauthCallbacksController < ApplicationController feature_category :integrations + skip_before_action :authenticate_user! + def index; end end diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index 623113f8413..9305f46c39e 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -64,10 +64,12 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController private def allow_self_managed_content_security_policy + return unless Feature.enabled?(:jira_connect_oauth_self_managed) + return unless current_jira_installation.instance_url? request.content_security_policy.directives['connect-src'] ||= [] - request.content_security_policy.directives['connect-src'] << Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/oauth_application_ids') + request.content_security_policy.directives['connect-src'].concat(allowed_instance_connect_src) end def create_service @@ -77,4 +79,11 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController def allow_rendering_in_iframe response.headers.delete('X-Frame-Options') end + + def allowed_instance_connect_src + [ + Gitlab::Utils.append_path(current_jira_installation.instance_url, '/-/jira_connect/'), + Gitlab::Utils.append_path(current_jira_installation.instance_url, '/api/') + ] + end end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 84f5632854b..7211eebdb4b 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -78,7 +78,11 @@ class JwtController < ApplicationController end def additional_params - { scopes: scopes_param, deploy_token: @authentication_result.deploy_token }.compact + { + scopes: scopes_param, + deploy_token: @authentication_result.deploy_token, + auth_type: @authentication_result.type + }.compact end # We have to parse scope here, because Docker Client does not send an array of scopes, diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index a0c307a0a03..bfd6181a940 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Rails/ApplicationController class MetricsController < ActionController::Base include RequiresWhitelistedMonitoringClient @@ -34,3 +35,4 @@ class MetricsController < ActionController::Base ) end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index a996bad3fac..ff466fd5fbb 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -25,7 +25,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController end def show - @created = get_created_session + @created = get_created_session if Feature.disabled?('hash_oauth_secrets') end def create @@ -34,9 +34,14 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController if @application.persisted? flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) - set_created_session + if Feature.enabled?('hash_oauth_secrets') + @created = true + render :show + else + set_created_session - redirect_to oauth_application_url(@application) + redirect_to oauth_application_url(@application) + end else set_index_vars render :index diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 07d786ab060..8ed67c26f19 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -65,7 +65,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController add_pagination_headers(tokens) end - ::API::Entities::PersonalAccessTokenWithDetails.represent(tokens) + ::PersonalAccessTokenSerializer.new.represent(tokens) end def add_pagination_headers(relation) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index dd1ac526b89..e3704b77adc 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -137,7 +137,7 @@ class ProfilesController < Profiles::ApplicationController :pronouns, :pronunciation, :validation_password, - status: [:emoji, :message, :availability] + status: [:emoji, :message, :availability, :clear_status_after] ] end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 5bfda526fb0..2a20c67a23d 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -23,11 +23,10 @@ class Projects::BlameController < Projects::ApplicationController environment_params[:find_latest] = true @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last - blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page)) + blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :no_pagination)) @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! - - render locals: { blame_pagination: blame_service.pagination } + @blame_pagination = blame_service.pagination end end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 6160dafb177..63c1378ad11 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -5,13 +5,17 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include ActionView::Helpers::TextHelper include CycleAnalyticsParams include GracefulTimeoutHandling - include RedisTracking + include ProductAnalyticsTracking extend ::Gitlab::Utils::Override before_action :authorize_read_cycle_analytics! before_action :load_value_stream, only: :show - track_redis_hll_event :show, name: 'p_analytics_valuestream' + track_custom_event :show, + name: 'p_analytics_valuestream', + action: 'perform_analytics_usage_action', + label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', + destinations: %i[redis_hll snowplow] feature_category :planning_analytics urgency :low @@ -54,4 +58,12 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController permissions: @cycle_analytics.permissions(user: current_user) } end + + def tracking_namespace_source + project.namespace + end + + def tracking_project_source + project + end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 7ef9fd9daed..4f037cc843e 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -5,7 +5,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController # into app/controllers/projects/metrics_dashboard_controller.rb # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details. + MIN_SEARCH_LENGTH = 3 + include MetricsDashboard + include ProductAnalyticsTracking layout 'project' @@ -26,6 +29,18 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? } after_action :expire_etag_cache, only: [:cancel_auto_stop] + track_event :index, + :folder, + :show, + :new, + :edit, + :create, + :update, + :stop, + :cancel_auto_stop, + :terminal, + name: 'users_visiting_environments_pages' + feature_category :continuous_delivery urgency :low @@ -35,12 +50,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController respond_to do |format| format.html format.json do - @environments = project.environments - .with_state(params[:scope] || :available) + @environments = search_environments.with_state(params[:scope] || :available) + environments_count_by_state = search_environments.count_by_state Gitlab::PollingInterval.set_header(response, interval: 3_000) - environments_count_by_state = project.environments.count_by_state - render json: { environments: serialize_environments(request, response, params[:nested]), review_app: serialize_review_app, @@ -59,7 +72,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController respond_to do |format| format.html format.json do - folder_environments = project.environments.where(environment_type: params[:id]) + folder_environments = search_environments(type: params[:id]) + @environments = folder_environments.with_state(params[:scope] || :available) .order(:name) @@ -236,6 +250,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment ||= project.environments.find(params[:id]) end + def search_environments(type: nil) + search = params[:search] if params[:search] && params[:search].length >= MIN_SEARCH_LENGTH + + @search_environments ||= + Environments::EnvironmentsFinder.new(project, + current_user, + type: type, + search: search).execute + end + def metrics_params params.require([:start, :end]) end diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index d1eb86c5e49..dfb73821b0f 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -12,7 +12,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController def admin_project_google_cloud! unless can?(current_user, :admin_project_google_cloud, project) - track_event('admin_project_google_cloud!', 'error_access_denied', 'invalid_user') + track_event(:error_invalid_user) access_denied! end end @@ -20,11 +20,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController def google_oauth2_enabled! config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') if config.app_id.blank? || config.app_secret.blank? - track_event( - 'google_oauth2_enabled!', - 'error_access_denied', - { reason: 'google_oauth2_not_configured', config: config } - ) + track_event(:error_google_oauth2_not_enabled) access_denied! 'This GitLab instance not configured for Google Oauth2.' end end @@ -35,7 +31,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project) feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project unless feature_is_enabled - track_event('feature_flag_enabled!', 'error_access_denied', 'feature_flag_not_enabled') + track_event(:error_feature_flag_not_enabled) access_denied! end end @@ -69,16 +65,14 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] end - def track_event(action, label, property) - options = { label: label, project: project, user: current_user } - - if property.is_a?(String) - options[:property] = property - else - options[:extra] = property - end - - Gitlab::Tracking.event('Projects::GoogleCloud', action, **options) + def track_event(action, label = nil) + Gitlab::Tracking.event( + self.class.name, + action.to_s, + label: label, + project: project, + user: current_user + ) end def gcp_projects diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb index 8d252c35031..06a6674d578 100644 --- a/app/controllers/projects/google_cloud/configuration_controller.rb +++ b/app/controllers/projects/google_cloud/configuration_controller.rb @@ -16,7 +16,7 @@ module Projects revokeOauthUrl: revoke_oauth_url } @js_data = js_data.to_json - track_event('configuration#index', 'success', js_data) + track_event(:render_page) end private diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb index 7b1cf6e5ce1..8f7554f248b 100644 --- a/app/controllers/projects/google_cloud/databases_controller.rb +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -3,14 +3,139 @@ module Projects module GoogleCloud class DatabasesController < Projects::GoogleCloud::BaseController + before_action :validate_gcp_token! + before_action :validate_product, only: :new + def index js_data = { configurationUrl: project_google_cloud_configuration_path(project), deploymentsUrl: project_google_cloud_deployments_path(project), - databasesUrl: project_google_cloud_databases_path(project) + databasesUrl: project_google_cloud_databases_path(project), + cloudsqlPostgresUrl: new_project_google_cloud_database_path(project, :postgres), + cloudsqlMysqlUrl: new_project_google_cloud_database_path(project, :mysql), + cloudsqlSqlserverUrl: new_project_google_cloud_database_path(project, :sqlserver), + cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute, + emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg') } @js_data = js_data.to_json - track_event('databases#index', 'success', js_data) + + track_event(:render_page) + end + + def new + product = permitted_params[:product].to_sym + + @title = title(product) + + @js_data = { + gcpProjects: gcp_projects, + refs: refs, + cancelPath: project_google_cloud_databases_path(project), + formTitle: form_title(product), + formDescription: description(product), + databaseVersions: Projects::GoogleCloud::CloudsqlHelper::VERSIONS[product], + tiers: Projects::GoogleCloud::CloudsqlHelper::TIERS + }.to_json + + track_event(:render_form) + render template: 'projects/google_cloud/databases/cloudsql_form', formats: :html + end + + def create + enable_response = ::GoogleCloud::EnableCloudsqlService + .new(project, current_user, enable_service_params) + .execute + + if enable_response[:status] == :error + track_event(:error_enable_cloudsql_services) + flash[:error] = error_message(enable_response[:message]) + else + permitted_params = params.permit(:gcp_project, :ref, :database_version, :tier) + create_response = ::GoogleCloud::CreateCloudsqlInstanceService + .new(project, current_user, create_service_params(permitted_params)) + .execute + + if create_response[:status] == :error + track_event(:error_create_cloudsql_instance) + flash[:warning] = error_message(create_response[:message]) + else + track_event(:create_cloudsql_instance, permitted_params.to_s) + flash[:notice] = success_message + end + end + + redirect_to project_google_cloud_databases_path(project) + end + + private + + def enable_service_params + { google_oauth2_token: token_in_session } + end + + def create_service_params(permitted_params) + { + google_oauth2_token: token_in_session, + gcp_project_id: permitted_params[:gcp_project], + environment_name: permitted_params[:ref], + database_version: permitted_params[:database_version], + tier: permitted_params[:tier] + } + end + + def error_message(message) + format(s_("CloudSeed|Google Cloud Error - %{message}"), message: message) + end + + def success_message + s_('CloudSeed|Cloud SQL instance creation request successful. Expected resolution time is ~5 minutes.') + end + + def validate_product + not_found unless permitted_params[:product].in?(%w[postgres mysql sqlserver]) + end + + def permitted_params + params.permit(:product) + end + + def title(product) + case product + when :postgres + s_('CloudSeed|Create Postgres Instance') + when :mysql + s_('CloudSeed|Create MySQL Instance') + else + s_('CloudSeed|Create MySQL Instance') + end + end + + def form_title(product) + case product + when :postgres + s_('CloudSeed|Cloud SQL for Postgres') + when :mysql + s_('CloudSeed|Cloud SQL for MySQL') + else + s_('CloudSeed|Cloud SQL for SQL Server') + end + end + + def description(product) + case product + when :postgres + s_('CloudSeed|Cloud SQL instances are fully managed, relational PostgreSQL databases. '\ + 'Google handles replication, patch management, and database management '\ + 'to ensure availability and performance.') + when :mysql + s_('Cloud SQL instances are fully managed, relational MySQL databases. '\ + 'Google handles replication, patch management, and database management '\ + 'to ensure availability and performance.') + else + s_('Cloud SQL instances are fully managed, relational SQL Server databases. ' \ + 'Google handles replication, patch management, and database management ' \ + 'to ensure availability and performance.') + end end end end diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index 1ac4697a63f..f6cc8d5eafb 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -12,7 +12,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project) } @js_data = js_data.to_json - track_event('deployments#index', 'success', js_data) + track_event(:render_page) end def cloud_run @@ -21,7 +21,7 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base .new(project, current_user, params).execute if enable_cloud_run_response[:status] == :error - track_event('deployments#cloud_run', 'error_enable_cloud_run', enable_cloud_run_response) + track_event(:error_enable_services) flash[:error] = enable_cloud_run_response[:message] redirect_to project_google_cloud_deployments_path(project) else @@ -30,17 +30,17 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::Base .new(project, current_user, params).execute if generate_pipeline_response[:status] == :error - track_event('deployments#cloud_run', 'error_generate_pipeline', generate_pipeline_response) + track_event(:error_generate_cloudrun_pipeline) flash[:error] = 'Failed to generate pipeline' redirect_to project_google_cloud_deployments_path(project) else cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name]) - track_event('deployments#cloud_run', 'success', cloud_run_mr_params) + track_event(:generate_cloudrun_pipeline) redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params) end end - rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e - track_event('deployments#cloud_run', 'error_gcp', e) + rescue Google::Apis::Error => e + track_event(:error_google_api) flash[:warning] = _('Google Cloud Error - %{error}') % { error: e } redirect_to project_google_cloud_deployments_path(project) end diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb index 39f33624804..2f0bc05030f 100644 --- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -15,13 +15,13 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC cancelPath: project_google_cloud_configuration_path(project) } @js_data = js_data.to_json - track_event('gcp_regions#index', 'success', js_data) + track_event(:render_form) end def create permitted_params = params.permit(:ref, :gcp_region) - response = GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) - track_event('gcp_regions#create', 'success', response) + GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) + track_event(:configure_region) redirect_to project_google_cloud_configuration_path(project), notice: _('GCP region configured') end end diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb index 1a9a2daf4f2..dbf91806722 100644 --- a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb +++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb @@ -9,10 +9,10 @@ class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::Base if response.success? redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') } - track_event('revoke_oauth#create', 'success', response.to_json) + track_event(:revoke_oauth) else redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') } - track_event('revoke_oauth#create', 'error', response.to_json) + track_event(:error) end session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token) diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index 7f25054177e..89d624764df 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -5,7 +5,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: def index if gcp_projects.empty? - track_event('service_accounts#index', 'error_form', 'no_gcp_projects') + track_event(:error_no_gcp_projects) flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project') redirect_to project_google_cloud_configuration_path(project) else @@ -16,10 +16,10 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: } @js_data = js_data.to_json - track_event('service_accounts#index', 'success', js_data) + track_event(:render_form) end - rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e - track_event('service_accounts#index', 'error_gcp', e) + rescue Google::Apis::Error => e + track_event(:error_google_api) flash[:warning] = _('Google Cloud Error - %{error}') % { error: e } redirect_to project_google_cloud_configuration_path(project) end @@ -35,10 +35,10 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: environment_name: permitted_params[:ref] ).execute - track_event('service_accounts#create', 'success', response) + track_event(:create_service_account) redirect_to project_google_cloud_configuration_path(project), notice: response.message - rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => e - track_event('service_accounts#create', 'error_gcp', e) + rescue Google::Apis::Error => e + track_event(:error_google_api) flash[:warning] = _('Google Cloud Error - %{error}') % { error: e } redirect_to project_google_cloud_configuration_path(project) end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 63309cce1e5..47557133ac8 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -2,14 +2,18 @@ class Projects::GraphsController < Projects::ApplicationController include ExtractsPath - include RedisTracking + include ProductAnalyticsTracking # Authorize before_action :require_non_empty_project before_action :assign_ref_vars before_action :authorize_read_repository_graphs! - track_redis_hll_event :charts, name: 'p_analytics_repo' + track_custom_event :charts, + name: 'p_analytics_repo', + action: 'perform_analytics_usage_action', + label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', + destinations: %i[redis_hll snowplow] feature_category :source_code_management, [:show, :commits, :languages, :charts] urgency :low, [:show] @@ -102,6 +106,14 @@ class Projects::GraphsController < Projects::ApplicationController render json: @log.to_json end + + def tracking_namespace_source + project.namespace + end + + def tracking_project_source + project + end end Projects::GraphsController.prepend_mod diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb index 0ca3d71f728..3ab4c34737d 100644 --- a/app/controllers/projects/hook_logs_controller.rb +++ b/app/controllers/projects/hook_logs_controller.rb @@ -1,40 +1,19 @@ # frozen_string_literal: true class Projects::HookLogsController < Projects::ApplicationController - include ::Integrations::HooksExecution - before_action :authorize_admin_project! - before_action :hook, only: [:show, :retry] - before_action :hook_log, only: [:show, :retry] - - respond_to :html + include WebHooks::HookLogActions layout 'project_settings' - feature_category :integrations - urgency :low, [:retry] - - def show - end - - def retry - execute_hook - redirect_to edit_project_hook_path(@project, @hook) - end - private - def execute_hook - result = hook.execute(hook_log.request_data, hook_log.trigger) - set_hook_execution_notice(result) - end - def hook @hook ||= @project.hooks.find(params[:hook_id]) end - def hook_log - @hook_log ||= hook.web_hook_logs.find(params[:id]) + def after_retry_redirect_path + edit_project_hook_path(@project, hook) end end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 50f388324f1..22b6bf6faf0 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Projects::HooksController < Projects::ApplicationController - include ::Integrations::HooksExecution + include ::WebHooks::HookActions # Authorize before_action :authorize_admin_project! @@ -35,7 +35,7 @@ class Projects::HooksController < Projects::ApplicationController end def hook_logs - @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]) + @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count end def trigger_values diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 36b52533e78..cbf0c756e1e 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -11,6 +11,7 @@ class Projects::IncidentsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, @project) + push_frontend_feature_flag(:remove_user_attributes_projects, @project) end feature_category :incident_management @@ -28,7 +29,7 @@ class Projects::IncidentsController < Projects::ApplicationController .inc_relations_for_view .iid_in(params[:id]) .without_order - .first + .take # rubocop:disable CodeReuse/ActiveRecord end end diff --git a/app/controllers/projects/integrations/shimos_controller.rb b/app/controllers/projects/integrations/shimos_controller.rb index 827dbb8f3f9..6c8313d0805 100644 --- a/app/controllers/projects/integrations/shimos_controller.rb +++ b/app/controllers/projects/integrations/shimos_controller.rb @@ -12,7 +12,7 @@ module Projects private def ensure_renderable - render_404 unless Feature.enabled?(:shimo_integration, project) && project.has_shimo? && project.shimo_integration&.render? + render_404 unless project.has_shimo? && project.shimo_integration&.render? end end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d19db2b11ab..800a7df2566 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -42,6 +42,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:incident_timeline, project) + push_frontend_feature_flag(:remove_user_attributes_projects, project) end before_action only: [:index, :show] do @@ -53,6 +54,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:realtime_labels, project) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, project) + push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?) end @@ -63,19 +65,19 @@ class Projects::IssuesController < Projects::ApplicationController alias_method :designs, :show feature_category :team_planning, [ - :index, :calendar, :show, :new, :create, :edit, :update, - :destroy, :move, :reorder, :designs, :toggle_subscription, - :discussions, :bulk_update, :realtime_changes, - :toggle_award_emoji, :mark_as_spam, :related_branches, - :can_create_branch, :create_merge_request - ] + :index, :calendar, :show, :new, :create, :edit, :update, + :destroy, :move, :reorder, :designs, :toggle_subscription, + :discussions, :bulk_update, :realtime_changes, + :toggle_award_emoji, :mark_as_spam, :related_branches, + :can_create_branch, :create_merge_request + ] urgency :low, [ - :index, :calendar, :show, :new, :create, :edit, :update, - :destroy, :move, :reorder, :designs, :toggle_subscription, - :discussions, :bulk_update, :realtime_changes, - :toggle_award_emoji, :mark_as_spam, :related_branches, - :can_create_branch, :create_merge_request - ] + :index, :calendar, :show, :new, :create, :edit, :update, + :destroy, :move, :reorder, :designs, :toggle_subscription, + :discussions, :bulk_update, :realtime_changes, + :toggle_award_emoji, :mark_as_spam, :related_branches, + :can_create_branch, :create_merge_request + ] feature_category :service_desk, [:service_desk] urgency :low, [:service_desk] diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 7878ace5015..557ac566733 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -20,6 +20,9 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_proxy_request!, only: :proxy_websocket_authorize before_action :push_job_log_jump_to_failures, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] + before_action do + push_frontend_feature_flag(:graphql_job_app, project, type: :development) + end layout 'project' @@ -120,11 +123,13 @@ class Projects::JobsController < Projects::ApplicationController end def erase - if @build.erase(erased_by: current_user) + service_response = Ci::BuildEraseService.new(@build, current_user).execute + + if service_response.success? redirect_to project_job_path(project, @build), notice: _("Job has been successfully erased!") else - respond_422 + head service_response.http_status end end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 279fd4c457e..a68c2ffa06d 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -29,6 +29,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render_diffs end + # rubocop: disable Metrics/AbcSize def diffs_batch diff_options_hash = diff_options diff_options_hash[:paths] = params[:paths] if params[:paths] @@ -61,21 +62,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options[:allow_tree_conflicts] ] - if Feature.enabled?(:etag_merge_request_diff_batches, @merge_request.project) - return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) - end + return unless stale?(etag: [cache_context + diff_options_hash.fetch(:paths, []), diffs]) - if diff_options_hash[:paths].blank? - render_cached( - diffs, - with: PaginatedDiffSerializer.new(current_user: current_user), - cache_context: -> (_) { [Digest::SHA256.hexdigest(cache_context.to_s)] }, - **options - ) - else - render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options) - end + render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options) end + # rubocop: enable Metrics/AbcSize def diffs_metadata diffs = @compare.diffs(diff_options) diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index ff6b6bfaf27..74bb3ad1a63 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -49,8 +49,24 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli def publish result = DraftNotes::PublishService.new(merge_request, current_user).execute(draft_note(allow_nil: true)) - if Feature.enabled?(:mr_review_submit_comment, @project) && create_note_params[:note] - Notes::CreateService.new(@project, current_user, create_note_params).execute + if Feature.enabled?(:mr_review_submit_comment, @project) + if create_note_params[:note] + ::Notes::CreateService.new(@project, current_user, create_note_params).execute + + merge_request_activity_counter.track_submit_review_comment(user: current_user) + end + + if Gitlab::Utils.to_boolean(approve_params[:approve]) + unless merge_request.approved_by?(current_user) + success = ::MergeRequests::ApprovalService.new(project: @project, current_user: current_user, params: approve_params).execute(merge_request) + + unless success + return render json: { message: _('An error occurred while approving, please try again.') }, status: :internal_server_error + end + end + + merge_request_activity_counter.track_submit_review_approve(user: current_user) + end end if result[:status] == :success @@ -115,6 +131,10 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli end end + def approve_params + params.permit(:approve) + end + def prepare_notes_for_rendering(notes) return [] unless notes @@ -147,4 +167,10 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli def authorize_create_note! access_denied! unless can?(current_user, :create_note, merge_request) end + + def merge_request_activity_counter + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + end end + +Projects::MergeRequests::DraftsController.prepend_mod diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 870c57fd6f3..5a212e9a152 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -45,6 +45,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:paginated_mr_discussions, project) push_frontend_feature_flag(:mr_review_submit_comment, project) push_frontend_feature_flag(:mr_experience_survey, project) + push_frontend_feature_flag(:remove_user_attributes_projects, @project) end before_action do @@ -56,11 +57,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo after_action :log_merge_request_show, only: [:show] feature_category :code_review, [ - :assign_related_issues, :bulk_update, :cancel_auto_merge, - :commit_change_content, :commits, :context_commits, :destroy, - :discussions, :edit, :index, :merge, :rebase, :remove_wip, - :show, :toggle_award_emoji, :toggle_subscription, :update - ] + :assign_related_issues, :bulk_update, :cancel_auto_merge, + :commit_change_content, :commits, :context_commits, :destroy, + :discussions, :edit, :index, :merge, :rebase, :remove_wip, + :show, :toggle_award_emoji, :toggle_subscription, :update + ] feature_category :code_testing, [:test_reports, :coverage_reports] feature_category :code_quality, [:codequality_reports, :codequality_mr_diff_reports] @@ -219,7 +220,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def context_commits # Get commits from repository # or from cache if already merged - commits = ContextCommitsFinder.new(project, @merge_request, { search: params[:search], limit: params[:limit], offset: params[:offset] }).execute + commits = ContextCommitsFinder.new(project, @merge_request, { + search: params[:search], + author: params[:author], + committed_before: convert_date_to_epoch(params[:committed_before]), + committed_after: convert_date_to_epoch(params[:committed_after]), + limit: params[:limit] + }).execute render json: CommitEntity.represent(commits, { type: :full, request: merge_request }) end @@ -552,6 +559,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo diffs_metadata_project_json_merge_request_path(project, merge_request, 'json', params) end + + def convert_date_to_epoch(date) + Date.strptime(date, "%Y-%m-%d")&.to_time&.to_i if date + rescue Date::Error, TypeError + end end Projects::MergeRequestsController.prepend_mod_with('Projects::MergeRequestsController') diff --git a/app/controllers/projects/packages/package_files_controller.rb b/app/controllers/projects/packages/package_files_controller.rb index 32aadb4fcf4..1aa91ee1189 100644 --- a/app/controllers/projects/packages/package_files_controller.rb +++ b/app/controllers/projects/packages/package_files_controller.rb @@ -11,6 +11,7 @@ module Projects def download package_file = project.package_files.find(params[:id]) + package_file.package.touch_last_downloaded_at send_upload(package_file.file, attachment: package_file.file_name) end end diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index 8ac370b1bd4..d77cf095a4f 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -51,7 +51,8 @@ module Projects def test_suite suite = builds.sum do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report.get_suite(build.test_suite_name) end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load! diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index b2aa1d9f4ca..2a8f7171f9c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -3,6 +3,7 @@ class Projects::PipelinesController < Projects::ApplicationController include ::Gitlab::Utils::StrongMemoize include RedisTracking + include ProductAnalyticsTracking include ProjectStatsRefreshConflictsGuard include ZuoraCSP @@ -25,6 +26,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:pipeline_tabs_vue, @project) + push_frontend_feature_flag(:run_pipeline_graphql, @project) end # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 @@ -32,8 +34,11 @@ class Projects::PipelinesController < Projects::ApplicationController around_action :allow_gitaly_ref_name_caching, only: [:index, :show] - # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/345074 - track_redis_hll_event :charts, name: 'p_analytics_pipelines' + track_custom_event :charts, + name: 'p_analytics_pipelines', + action: 'perform_analytics_usage_action', + label: 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly', + destinations: %i[redis_hll snowplow] track_redis_hll_event :charts, name: 'p_analytics_ci_cd_pipelines', if: -> { should_track_ci_cd_pipelines? } track_redis_hll_event :charts, name: 'p_analytics_ci_cd_deployment_frequency', if: -> { should_track_ci_cd_deployment_frequency? } @@ -46,10 +51,10 @@ class Projects::PipelinesController < Projects::ApplicationController POLLING_INTERVAL = 10_000 feature_category :continuous_integration, [ - :charts, :show, :config_variables, :stage, :cancel, :retry, - :builds, :dag, :failures, :status, - :index, :create, :new, :destroy - ] + :charts, :show, :config_variables, :stage, :cancel, :retry, + :builds, :dag, :failures, :status, + :index, :create, :new, :destroy + ] feature_category :code_testing, [:test_report] feature_category :build_artifacts, [:downloadable_artifacts] @@ -371,6 +376,14 @@ class Projects::PipelinesController < Projects::ApplicationController def should_track_ci_cd_change_failure_rate? params[:chart] == 'change-failure-rate' end + + def tracking_namespace_source + project.namespace + end + + def tracking_project_source + project + end end Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index ba9576795ec..ee12b85b3a4 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -15,7 +15,7 @@ class Projects::RunnersController < Projects::ApplicationController end def update - if Ci::Runners::UpdateRunnerService.new(@runner).update(runner_params) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(runner_params).success? redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.') else render 'edit' @@ -31,7 +31,7 @@ class Projects::RunnersController < Projects::ApplicationController end def resume - if Ci::Runners::UpdateRunnerService.new(@runner).update(active: true) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: true).success? redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.') else redirect_to project_runners_path(@project), alert: _('Runner was not updated.') @@ -39,7 +39,7 @@ class Projects::RunnersController < Projects::ApplicationController end def pause - if Ci::Runners::UpdateRunnerService.new(@runner).update(active: false) + if Ci::Runners::UpdateRunnerService.new(@runner).execute(active: false).success? redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.') else redirect_to project_runners_path(@project), alert: _('Runner was not updated.') diff --git a/app/controllers/projects/settings/integration_hook_logs_controller.rb b/app/controllers/projects/settings/integration_hook_logs_controller.rb index 1e42fbce4c4..3a921ecad0d 100644 --- a/app/controllers/projects/settings/integration_hook_logs_controller.rb +++ b/app/controllers/projects/settings/integration_hook_logs_controller.rb @@ -7,13 +7,13 @@ module Projects before_action :integration, only: [:show, :retry] - def retry - execute_hook - redirect_to edit_project_settings_integration_path(@project, @integration) - end - private + override :after_retry_redirect_path + def after_retry_redirect_path + edit_project_settings_integration_path(@project, @integration) + end + def integration @integration ||= @project.find_or_initialize_integration(params[:integration_id]) end diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb index 03ef434456f..2bbcd9fe20c 100644 --- a/app/controllers/projects/settings/integrations_controller.rb +++ b/app/controllers/projects/settings/integrations_controller.rb @@ -11,7 +11,7 @@ module Projects before_action :integration, only: [:edit, :update, :test] before_action :default_integration, only: [:edit, :update] before_action :web_hook_logs, only: [:edit, :update] - before_action -> { check_rate_limit!(:project_testing_integration, scope: [@project, current_user]) }, only: :test + before_action -> { check_test_rate_limit! }, only: :test respond_to :html @@ -124,7 +124,7 @@ module Projects def web_hook_logs return unless integration.try(:service_hook).present? - @web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page]) + @web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page]).without_count end def ensure_integration_enabled @@ -140,6 +140,15 @@ module Projects def use_inherited_settings?(attributes) default_integration && attributes[:inherit_from_id] == default_integration.id.to_s end + + def check_test_rate_limit! + check_rate_limit!(:project_testing_integration, scope: [@project, current_user]) do + render json: { + error: true, + message: _('This endpoint has been requested too many times. Try again later.') + }, status: :ok + end + end end end end diff --git a/app/controllers/projects/settings/merge_requests_controller.rb b/app/controllers/projects/settings/merge_requests_controller.rb new file mode 100644 index 00000000000..93e10695767 --- /dev/null +++ b/app/controllers/projects/settings/merge_requests_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Projects + module Settings + class MergeRequestsController < Projects::ApplicationController + layout 'project_settings' + + before_action :merge_requests_enabled? + before_action :present_project, only: [:edit] + before_action :authorize_admin_project! + + feature_category :code_review + + def update + result = ::Projects::UpdateService.new(@project, current_user, project_params).execute + + if result[:status] == :success + flash[:notice] = format(_("Project '%{project_name}' was successfully updated."), project_name: @project.name) + redirect_to project_settings_merge_requests_path(@project) + else + # Refresh the repo in case anything changed + @repository = @project.repository.reset + + flash[:alert] = result[:message] + @project.reset + render 'show' + end + end + + private + + def merge_requests_enabled? + render_404 unless @project.merge_requests_enabled? + end + + def project_params + params.require(:project) + .permit(project_params_attributes) + end + + def project_setting_attributes + %i[ + squash_option + allow_editing_commit_messages + mr_default_target_self + ] + end + + def project_params_attributes + [ + :allow_merge_on_skipped_pipeline, + :resolve_outdated_diff_discussions, + :only_allow_merge_if_all_discussions_are_resolved, + :only_allow_merge_if_pipeline_succeeds, + :printing_merge_request_link_enabled, + :remove_source_branch_after_merge, + :merge_method, + :merge_commit_template_or_default, + :squash_commit_template_or_default, + :suggestion_commit_message + ] + [project_setting_attributes: project_setting_attributes] + end + end + end +end + +Projects::Settings::MergeRequestsController.prepend_mod_with('Projects::Settings::MergeRequestsController') diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index a178b8f7aa3..43c6451577a 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -34,13 +34,13 @@ module Projects def create_deploy_token result = Projects::DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute - @new_deploy_token = result[:deploy_token] if result[:status] == :success + @created_deploy_token = result[:deploy_token] respond_to do |format| format.json do # IMPORTANT: It's a security risk to expose the token value more than just once here! - json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json + json = API::Entities::DeployTokenWithToken.represent(@created_deploy_token).as_json render json: json, status: result[:http_status] end format.html do @@ -49,6 +49,7 @@ module Projects end end else + @new_deploy_token = result[:deploy_token] respond_to do |format| format.json { render json: { message: result[:message] }, status: result[:http_status] } format.html do diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index a364668ea5f..8f4987a07f6 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -35,14 +35,4 @@ class Projects::UploadsController < Projects::ApplicationController Project.find_by_full_path("#{namespace}/#{id}") end - - # Overrides ApplicationController#build_canonical_path since there are - # multiple routes that match project uploads: - # https://gitlab.com/gitlab-org/gitlab/issues/196396 - def build_canonical_path(project) - return super unless action_name == 'show' - return super unless params[:secret] && params[:filename] - - show_namespace_project_uploads_url(project.namespace.to_param, project.to_param, params[:secret], params[:filename]) - end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 8a6bcb4b3fc..5ceedbc1e01 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -12,6 +12,8 @@ class ProjectsController < Projects::ApplicationController include SourcegraphDecorator include PlanningHierarchy + REFS_LIMIT = 100 + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } around_action :allow_gitaly_ref_name_caching, only: [:index, :show] @@ -54,9 +56,9 @@ class ProjectsController < Projects::ApplicationController layout :determine_layout feature_category :projects, [ - :index, :show, :new, :create, :edit, :update, :transfer, - :destroy, :archive, :unarchive, :toggle_star, :activity - ] + :index, :show, :new, :create, :edit, :update, :transfer, + :destroy, :archive, :unarchive, :toggle_star, :activity + ] feature_category :source_code_management, [:remove_fork, :housekeeping, :refs] feature_category :team_planning, [:preview_markdown, :new_issuable_address] @@ -309,6 +311,8 @@ class ProjectsController < Projects::ApplicationController find_tags = true find_commits = true + use_gitaly_pagination = Feature.enabled?(:use_gitaly_pagination_for_refs, @project) + unless find_refs.nil? find_branches = find_refs.include?('branches') find_tags = find_refs.include?('tags') @@ -318,13 +322,21 @@ class ProjectsController < Projects::ApplicationController options = {} if find_branches - branches = BranchesFinder.new(@repository, refs_params).execute.take(100).map(&:name) + branches = BranchesFinder.new(@repository, refs_params.merge(per_page: REFS_LIMIT)) + .execute(gitaly_pagination: use_gitaly_pagination) + .take(REFS_LIMIT) + .map(&:name) + options['Branches'] = branches end if find_tags && @repository.tag_count.nonzero? - tags = TagsFinder.new(@repository, refs_params).execute - options['Tags'] = tags.take(100).map(&:name) + tags = TagsFinder.new(@repository, refs_params.merge(per_page: REFS_LIMIT)) + .execute(gitaly_pagination: use_gitaly_pagination) + .take(REFS_LIMIT) + .map(&:name) + + options['Tags'] = tags end # If reference is commit id - we should add it to branch/tag selectbox @@ -430,6 +442,7 @@ class ProjectsController < Projects::ApplicationController if Feature.enabled?(:split_operations_visibility_permissions, project) %i[ environments_access_level feature_flags_access_level releases_access_level + monitor_access_level ] else %i[operations_access_level] diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 33d2c482795..0bd266bb490 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -32,6 +32,7 @@ class RegistrationsController < Devise::RegistrationsController def create set_user_state + token = set_custom_confirmation_token super do |new_user| accept_pending_invitations if new_user.persisted? @@ -39,6 +40,7 @@ class RegistrationsController < Devise::RegistrationsController persist_accepted_terms_if_required(new_user) set_role_required(new_user) track_experiment_event(new_user) + send_custom_confirmation_instructions(new_user, token) if pending_approval? NotificationService.new.new_instance_access_request(new_user) @@ -118,8 +120,10 @@ class RegistrationsController < Devise::RegistrationsController def after_inactive_sign_up_path_for(resource) Gitlab::AppLogger.info(user_created_message) return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval? + return dashboard_projects_path if Feature.enabled?(:soft_email_confirmation) + return identity_verification_redirect_path if custom_confirmation_enabled?(resource) - Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path(email: resource.email) + users_almost_there_path(email: resource.email) end private @@ -236,6 +240,22 @@ class RegistrationsController < Devise::RegistrationsController # signing up and becoming users experiment(:logged_out_marketing_header, actor: new_user).track(:signed_up) if new_user.persisted? end + + def identity_verification_redirect_path + # overridden by EE module + end + + def custom_confirmation_enabled?(resource) + # overridden by EE module + end + + def set_custom_confirmation_token + # overridden by EE module + end + + def send_custom_confirmation_instructions(user, token) + # overridden by EE module + end end RegistrationsController.prepend_mod_with('RegistrationsController') diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb index fbf5d82a45b..a5ca17db113 100644 --- a/app/controllers/repositories/git_http_client_controller.rb +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -3,7 +3,7 @@ module Repositories class GitHttpClientController < Repositories::ApplicationController include ActionController::HttpAuthentication::Basic - include KerberosSpnegoHelper + include KerberosHelper include Gitlab::Utils::StrongMemoize attr_reader :authentication_result, :redirected_path @@ -49,7 +49,7 @@ module Repositories if handle_basic_authentication(login, password) return # Allow access end - elsif allow_kerberos_spnego_auth? && spnego_provided? + elsif allow_kerberos_auth? && spnego_provided? kerberos_user = find_kerberos_user if kerberos_user @@ -91,7 +91,7 @@ module Repositories def send_challenges challenges = [] challenges << 'Basic realm="GitLab"' if allow_basic_auth? - challenges << spnego_challenge if allow_kerberos_spnego_auth? + challenges << spnego_challenge if allow_kerberos_auth? headers['Www-Authenticate'] = challenges.join("\n") if challenges.any? end diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index c3c6a51239d..144ec4c0de9 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -83,7 +83,7 @@ module Repositories return if Gitlab::Database.read_only? return unless repo_type.project? - OnboardingProgressService.async(project.namespace_id).execute(action: :git_pull) + Onboarding::ProgressService.async(project.namespace_id).execute(action: :git_pull) return if Feature.enabled?(:disable_git_http_fetch_writes) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 5843e13c7cd..9f87ad6aaf6 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -57,7 +57,25 @@ class SearchController < ApplicationController @search_highlight = @search_service.search_highlight end + Gitlab::Metrics::GlobalSearchSlis.record_apdex( + elapsed: @global_search_duration_s, + search_type: @search_type, + search_level: @search_level, + search_scope: @scope + ) + increment_search_counters + ensure + if @search_type + # If we raise an error somewhere in the @global_search_duration_s benchmark block, we will end up here + # with a 200 status code, but an empty @global_search_duration_s. + Gitlab::Metrics::GlobalSearchSlis.record_error_rate( + error: @global_search_duration_s.nil? || (status < 200 || status >= 400), + search_type: @search_type, + search_level: @search_level, + search_scope: @scope + ) + end end def count diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb deleted file mode 100644 index 38295cec0d3..00000000000 --- a/app/experiments/combined_registration_experiment.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class CombinedRegistrationExperiment < ApplicationExperiment - include Rails.application.routes.url_helpers - - control { new_users_sign_up_group_path } - candidate { new_users_sign_up_groups_project_path } - - def key_for(source, _ = nil) - super(source, 'force_company_trial') - end - - def redirect_path - run - end -end diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb index d623854ada4..4a45817cc61 100644 --- a/app/finders/context_commits_finder.rb +++ b/app/finders/context_commits_finder.rb @@ -5,8 +5,10 @@ class ContextCommitsFinder @project = project @merge_request = merge_request @search = params[:search] + @author = params[:author] + @committed_before = params[:committed_before] + @committed_after = params[:committed_after] @limit = (params[:limit] || 40).to_i - @offset = (params[:offset] || 0).to_i end def execute @@ -16,13 +18,13 @@ class ContextCommitsFinder private - attr_reader :project, :merge_request, :search, :limit, :offset + attr_reader :project, :merge_request, :search, :author, :committed_before, :committed_after, :limit def init_collection if search.present? search_commits else - project.repository.commits(merge_request.target_branch, { limit: limit, offset: offset }) + project.repository.commits(merge_request.target_branch, { limit: limit }) end end @@ -41,7 +43,8 @@ class ContextCommitsFinder commits = [commit_by_sha] if commit_by_sha end else - commits = project.repository.find_commits_by_message(search, merge_request.target_branch, nil, 20) + commits = project.repository.list_commits_by(search, merge_request.target_branch, + author: author, before: committed_before, after: committed_after, limit: limit) end commits diff --git a/app/finders/crm/organizations_finder.rb b/app/finders/crm/organizations_finder.rb index 5a8ab148ef3..69f72235c71 100644 --- a/app/finders/crm/organizations_finder.rb +++ b/app/finders/crm/organizations_finder.rb @@ -16,6 +16,11 @@ module Crm attr_reader :params, :current_user + def self.counts_by_state(current_user, params = {}) + params = params.merge(sort: nil) + new(current_user, params).execute.counts_by_state + end + def initialize(current_user, params = {}) @current_user = current_user @params = params @@ -28,11 +33,20 @@ module Crm organizations = by_ids(organizations) organizations = by_search(organizations) organizations = by_state(organizations) - organizations.sort_by_name + sort_organizations(organizations) end private + def sort_organizations(organizations) + return organizations.sort_by_name unless @params.key?(:sort) + return organizations if @params[:sort].nil? + + field = @params[:sort][:field] + direction = @params[:sort][:direction] + organizations.sort_by_field(field, direction) + end + def root_group strong_memoize(:root_group) do group = params[:group]&.root_ancestor diff --git a/app/finders/database/batched_background_migrations_finder.rb b/app/finders/database/batched_background_migrations_finder.rb new file mode 100644 index 00000000000..866acd47238 --- /dev/null +++ b/app/finders/database/batched_background_migrations_finder.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Database + class BatchedBackgroundMigrationsFinder + RETURNED_MIGRATIONS = 20 + + def initialize(connection:) + @connection = connection + end + + def execute + batched_migration_class.ordered_by_created_at_desc.for_gitlab_schema(schema).limit(RETURNED_MIGRATIONS) + end + + private + + attr_accessor :connection + + def batched_migration_class + Gitlab::Database::BackgroundMigration::BatchedMigration + end + + def schema + Gitlab::Database.gitlab_schemas_for_connection(connection) + end + end +end diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index 04b82ee04ec..5b2139cb941 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -9,8 +9,8 @@ # updated_before: DateTime # finished_after: DateTime # finished_before: DateTime -# environment: String -# status: String (see Deployment.statuses) +# environment: String (name) or Integer (ID) +# status: String or Array<String> (see Deployment.statuses) # order_by: String (see ALLOWED_SORT_VALUES constant) # sort: String (asc | desc) class DeploymentsFinder @@ -33,6 +33,7 @@ class DeploymentsFinder def initialize(params = {}) @params = params + @params[:status] = Array(@params[:status]).map(&:to_s) if @params[:status] validate! end @@ -68,16 +69,25 @@ class DeploymentsFinder raise error if raise_for_inefficient_updated_at_query? end - if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?) - raise InefficientQueryError, '`finished_at` filter and `finished_at` sorting must be paired' + if filter_by_finished_at? && !order_by_finished_at? + raise InefficientQueryError, '`finished_at` filter requires `finished_at` sort.' + end + + if order_by_finished_at? && !(filter_by_finished_at? || filter_by_finished_statuses?) + raise InefficientQueryError, + '`finished_at` sort requires `finished_at` filter or a filter with at least one of the finished statuses.' end if filter_by_finished_at? && !filter_by_successful_deployment? raise InefficientQueryError, '`finished_at` filter must be combined with `success` status filter.' end - if params[:environment].present? && !params[:project].present? - raise InefficientQueryError, '`environment` filter must be combined with `project` scope.' + if filter_by_environment_name? && !params[:project].present? + raise InefficientQueryError, '`environment` name filter must be combined with `project` scope.' + end + + if filter_by_finished_statuses? && filter_by_upcoming_statuses? + raise InefficientQueryError, 'finished statuses and upcoming statuses must be separately queried.' end end @@ -86,6 +96,8 @@ class DeploymentsFinder params[:project].deployments elsif params[:group].present? ::Deployment.for_projects(params[:group].all_projects) + elsif filter_by_environment_id? + ::Deployment.for_environment(params[:environment]) else ::Deployment.none end @@ -112,7 +124,7 @@ class DeploymentsFinder end def by_environment(items) - if params[:project].present? && params[:environment].present? + if params[:project].present? && filter_by_environment_name? items.for_environment_name(params[:project], params[:environment]) else items @@ -122,7 +134,7 @@ class DeploymentsFinder def by_status(items) return items unless params[:status].present? - unless Deployment.statuses.key?(params[:status]) + unless Deployment.statuses.keys.intersection(params[:status]) == params[:status] raise ArgumentError, "The deployment status #{params[:status]} is invalid" end @@ -165,7 +177,23 @@ class DeploymentsFinder end def filter_by_successful_deployment? - params[:status].to_s == 'success' + params[:status].present? && params[:status].count == 1 && params[:status].first.to_s == 'success' + end + + def filter_by_finished_statuses? + params[:status].present? && Deployment::FINISHED_STATUSES.map(&:to_s).intersection(params[:status]).any? + end + + def filter_by_upcoming_statuses? + params[:status].present? && Deployment::UPCOMING_STATUSES.map(&:to_s).intersection(params[:status]).any? + end + + def filter_by_environment_name? + params[:environment].present? && params[:environment].is_a?(String) + end + + def filter_by_environment_id? + params[:environment].present? && params[:environment].is_a?(Integer) end def order_by_updated_at? @@ -183,6 +211,7 @@ class DeploymentsFinder environment: [], deployable: { job_artifacts: [], + user: [], pipeline: { project: { route: [], diff --git a/app/finders/environments/environments_finder.rb b/app/finders/environments/environments_finder.rb index 46c49f096c6..f2dcba04349 100644 --- a/app/finders/environments/environments_finder.rb +++ b/app/finders/environments/environments_finder.rb @@ -14,6 +14,7 @@ module Environments def execute environments = project.environments + environments = by_type(environments) environments = by_name(environments) environments = by_search(environments) environments = by_ids(environments) @@ -24,6 +25,12 @@ module Environments private + def by_type(environments) + return environments unless params[:type].present? + + environments.for_type(params[:type]) + end + def by_name(environments) if params[:name].present? environments.for_name(params[:name]) diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 048e25046da..4688d561897 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -47,7 +47,7 @@ class GroupMembersFinder < UnionFinder related_groups << Group.by_id(group.id) if include_relations&.include?(:direct) related_groups << group.ancestors if include_relations&.include?(:inherited) related_groups << group.descendants if include_relations&.include?(:descendants) - related_groups << group.shared_with_groups.public_or_visible_to_user(user) if include_relations&.include?(:shared_from_groups) + related_groups << Group.shared_into_ancestors(group).public_or_visible_to_user(user) if include_relations&.include?(:shared_from_groups) find_union(related_groups, Group) end diff --git a/app/finders/groups/accepting_group_transfers_finder.rb b/app/finders/groups/accepting_group_transfers_finder.rb new file mode 100644 index 00000000000..df67f940d20 --- /dev/null +++ b/app/finders/groups/accepting_group_transfers_finder.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Groups + class AcceptingGroupTransfersFinder < Base + include Gitlab::Utils::StrongMemoize + + def initialize(current_user, group_to_be_transferred, params = {}) + @current_user = current_user + @group_to_be_transferred = group_to_be_transferred + @params = params + end + + def execute + return Group.none unless can_transfer_group? + + items = if Feature.enabled?(:include_groups_from_group_shares_in_group_transfer_locations) + find_all_groups + else + find_groups + end + + items = by_search(items) + + sort(items) + end + + private + + attr_reader :current_user, :group_to_be_transferred, :params + + def find_groups + GroupsFinder.new( # rubocop: disable CodeReuse/Finder + current_user, + min_access_level: Gitlab::Access::OWNER, + exclude_group_ids: exclude_groups + ).execute.without_order + end + + def find_all_groups + ::Namespace.from_union( + [ + find_groups, + groups_originating_from_group_shares_with_owner_access + ] + ) + end + + def groups_originating_from_group_shares_with_owner_access + GroupGroupLink + .with_owner_access + .groups_accessible_via( + current_user.owned_groups.select(:id) + ).id_not_in(exclude_groups) + end + + def exclude_groups + strong_memoize(:exclude_groups) do + exclude_groups = group_to_be_transferred.self_and_descendants.pluck_primary_key + exclude_groups << group_to_be_transferred.parent_id if group_to_be_transferred.parent_id + + exclude_groups + end + end + + def can_transfer_group? + Ability.allowed?(current_user, :admin_group, group_to_be_transferred) + end + end +end diff --git a/app/finders/groups/accepting_project_transfers_finder.rb b/app/finders/groups/accepting_project_transfers_finder.rb index 09d3c430641..a3f58a78eca 100644 --- a/app/finders/groups/accepting_project_transfers_finder.rb +++ b/app/finders/groups/accepting_project_transfers_finder.rb @@ -7,10 +7,6 @@ module Groups end def execute - if Feature.disabled?(:include_groups_from_group_shares_in_project_transfer_locations) - return current_user.manageable_groups - end - groups_accepting_project_transfers = [ current_user.manageable_groups, diff --git a/app/finders/groups/base.rb b/app/finders/groups/base.rb new file mode 100644 index 00000000000..d7f56b1a7a6 --- /dev/null +++ b/app/finders/groups/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Groups + class Base + private + + def sort(items) + items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord + end + + def by_search(items) + return items if params[:search].blank? + + items.search(params[:search], include_parents: true) + end + end +end diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb index bda8b7cc1e0..b58c1323b1f 100644 --- a/app/finders/groups/user_groups_finder.rb +++ b/app/finders/groups/user_groups_finder.rb @@ -13,7 +13,7 @@ # # Initially created to filter user groups and descendants where the user can create projects module Groups - class UserGroupsFinder + class UserGroupsFinder < Base def initialize(current_user, target_user, params = {}) @current_user = current_user @target_user = target_user @@ -34,16 +34,6 @@ module Groups attr_reader :current_user, :target_user, :params - def sort(items) - items.order(Group.arel_table[:path].asc, Group.arel_table[:id].asc) # rubocop: disable CodeReuse/ActiveRecord - end - - def by_search(items) - return items if params[:search].blank? - - items.search(params[:search], include_parents: true) - end - def by_permission_scope if permission_scope_create_projects? target_user.manageable_groups(include_groups_with_developer_maintainer_access: true) diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 9a8bc74f435..61d79885001 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -15,6 +15,7 @@ # exclude_group_ids: array of integers # include_parent_descendants: boolean (defaults to false) - includes descendant groups when # filtering by parent. The parent param must be present. +# include_ancestors: boolean (defaults to true) # # Users with full private access can see all groups. The `owned` and `parent` # params can be used to restrict the groups that are returned. @@ -52,15 +53,7 @@ class GroupsFinder < UnionFinder return [Group.all] if current_user&.can_read_all_resources? && all_available? groups = [] - - if current_user - if Feature.enabled?(:use_traversal_ids_groups_finder, current_user) - groups << current_user.authorized_groups.self_and_ancestors - groups << current_user.groups.self_and_descendants - else - groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects - end - end + groups = get_groups_for_user if current_user groups << Group.unscoped.public_to_user(current_user) if include_public_groups? groups << Group.none if groups.empty? @@ -136,4 +129,29 @@ class GroupsFinder < UnionFinder def min_access_level? current_user && params[:min_access_level].present? end + + def include_ancestors? + params.fetch(:include_ancestors, true) + end + + def get_groups_for_user + groups = [] + + if Feature.enabled?(:use_traversal_ids_groups_finder, current_user) + groups << if include_ancestors? + current_user.authorized_groups.self_and_ancestors + else + current_user.authorized_groups + end + + groups << current_user.groups.self_and_descendants + elsif include_ancestors? + groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects + else + groups << current_user.authorized_groups + groups << Gitlab::ObjectHierarchy.new(groups_for_descendants).base_and_descendants + end + + groups + end end diff --git a/app/finders/incident_management/timeline_events_finder.rb b/app/finders/incident_management/timeline_events_finder.rb index 09de46bb79f..aaf3133236a 100644 --- a/app/finders/incident_management/timeline_events_finder.rb +++ b/app/finders/incident_management/timeline_events_finder.rb @@ -31,7 +31,7 @@ module IncidentManagement end def sort(collection) - collection.order_occurred_at_asc + collection.order_occurred_at_asc_id_asc end end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 1088d53c9a0..9f331d381aa 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -46,8 +46,7 @@ class IssuableFinder requires_cross_project_access unless: -> { params.project? } - FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u218F]*' - FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze + FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze attr_accessor :current_user, :params @@ -59,19 +58,19 @@ class IssuableFinder class << self def scalar_params @scalar_params ||= %i[ - assignee_id - assignee_username - author_id - author_username - crm_contact_id - crm_organization_id - label_name - milestone_title - release_tag - my_reaction_emoji - search - in - ] + assignee_id + assignee_username + author_id + author_username + crm_contact_id + crm_organization_id + label_name + milestone_title + release_tag + my_reaction_emoji + search + in + ] end def array_params diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 663dda73a6a..9f96abcd4e5 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -60,10 +60,10 @@ class IssuesFinder < IssuableFinder # count of issues assigned to the user for the header bar. return issues.all if current_user && assignee_filter.includes_user?(current_user) - return issues.where('issues.confidential IS NOT TRUE') if params.user_cannot_see_confidential_issues? + return issues.public_only if params.user_cannot_see_confidential_issues? issues.where(' - issues.confidential IS NOT TRUE + issues.confidential = FALSE OR (issues.confidential = TRUE AND (issues.author_id = :user_id OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) diff --git a/app/finders/merge_requests/by_approvals_finder.rb b/app/finders/merge_requests/by_approvals_finder.rb index 94f13468327..8b2e9aa8df1 100644 --- a/app/finders/merge_requests/by_approvals_finder.rb +++ b/app/finders/merge_requests/by_approvals_finder.rb @@ -71,9 +71,7 @@ module MergeRequests # # @param [ActiveRecord::Relation] items the activerecord relation def with_any_approvals(items) - items.select_from_union([ - items.with_approvals - ]) + items.select_from_union([items.with_approvals]) end # Merge requests approved by given usernames diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 06feefb9059..ffa912afd1e 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -30,6 +30,8 @@ # updated_before: datetime # class MergeRequestsFinder < IssuableFinder + extend ::Gitlab::Utils::Override + include MergedAtFilter def self.scalar_params @@ -44,8 +46,7 @@ class MergeRequestsFinder < IssuableFinder :reviewer_id, :reviewer_username, :target_branch, - :wip, - :attention + :wip ] end @@ -70,7 +71,6 @@ class MergeRequestsFinder < IssuableFinder items = by_approvals(items) items = by_deployments(items) items = by_reviewer(items) - items = by_attention(items) by_source_project_id(items) end @@ -84,6 +84,16 @@ class MergeRequestsFinder < IssuableFinder private + override :sort + def sort(items) + items = super(items) + + return items unless use_grouping_columns? + + grouping_columns = klass.grouping_columns(params[:sort]) + items.group(grouping_columns) # rubocop:disable CodeReuse/ActiveRecord + end + def by_commit(items) return items unless params[:commit_sha].presence @@ -220,18 +230,18 @@ class MergeRequestsFinder < IssuableFinder end end - def by_attention(items) - return items unless params.attention? - - items.attention(params.attention) - end - def parse_datetime(input) # To work around http://www.ruby-lang.org/en/news/2021/11/15/date-parsing-method-regexp-dos-cve-2021-41817/ DateTime.parse(input.byteslice(0, 128)) if input rescue Date::Error nil end + + def use_grouping_columns? + return false unless params[:sort].present? + + params[:approved_by_usernames].present? || params[:approved_by_ids].present? + end end MergeRequestsFinder.prepend_mod_with('MergeRequestsFinder') diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb index 1c6a425c8af..e44e96054d3 100644 --- a/app/finders/merge_requests_finder/params.rb +++ b/app/finders/merge_requests_finder/params.rb @@ -21,11 +21,5 @@ class MergeRequestsFinder end end end - - def attention - strong_memoize(:attention) do - User.find_by_username(params[:attention]) - end - end end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 6b8dcd61d29..6bfe730ebc9 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -119,9 +119,9 @@ class ProjectsFinder < UnionFinder # This is an optimization - surprisingly PostgreSQL does not optimize # for this. # - # If the default visiblity level and desired visiblity level filter cancels + # If the default visibility level and desired visibility level filter cancels # each other out, don't use the SQL clause for visibility level in - # `Project.public_or_visible_to_user`. In fact, this then becames equivalent + # `Project.public_or_visible_to_user`. In fact, this then becomes equivalent # to just authorized projects for the user. # # E.g. diff --git a/app/finders/user_groups_counter.rb b/app/finders/user_groups_counter.rb index 7dbc8502be2..e8e552510cd 100644 --- a/app/finders/user_groups_counter.rb +++ b/app/finders/user_groups_counter.rb @@ -8,9 +8,9 @@ class UserGroupsCounter def execute Namespace.unscoped do Namespace.from_union([ - groups, - project_groups - ]).group(:user_id).count # rubocop: disable CodeReuse/ActiveRecord + groups, + project_groups + ]).group(:user_id).count # rubocop: disable CodeReuse/ActiveRecord end end diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb index b39875b83a9..8086d8c02a4 100644 --- a/app/graphql/graphql_triggers.rb +++ b/app/graphql/graphql_triggers.rb @@ -21,3 +21,5 @@ module GraphqlTriggers GitlabSchema.subscriptions.trigger('issuableDatesUpdated', { issuable_id: issuable.to_gid }, issuable) end end + +GraphqlTriggers.prepend_mod diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb index 14fe9714f99..e9cae80e5f9 100644 --- a/app/graphql/mutations/boards/issues/issue_move_list.rb +++ b/app/graphql/mutations/boards/issues/issue_move_list.rb @@ -38,10 +38,16 @@ module Mutations required: false, description: 'ID of issue that should be placed after the current issue.' + argument :position_in_list, GraphQL::Types::Int, + required: false, + description: "Position of issue within the board list. Positions start at 0. "\ + "Use #{::Boards::Issues::MoveService::LIST_END_POSITION} to move to the end of the list." + def ready?(**args) if move_arguments(args).blank? raise Gitlab::Graphql::Errors::ArgumentError, - 'At least one of the arguments fromListId, toListId, afterId or beforeId is required' + 'At least one of the arguments ' \ + 'fromListId, toListId, positionInList, moveAfterId, or moveBeforeId is required' end if move_list_arguments(args).one? @@ -49,6 +55,24 @@ module Mutations 'Both fromListId and toListId must be present' end + if args[:position_in_list].present? + if move_list_arguments(args).empty? + raise Gitlab::Graphql::Errors::ArgumentError, + 'Both fromListId and toListId are required when positionInList is given' + end + + if args[:move_before_id].present? || args[:move_after_id].present? + raise Gitlab::Graphql::Errors::ArgumentError, + 'positionInList is mutually exclusive with any of moveBeforeId or moveAfterId' + end + + if args[:position_in_list] != ::Boards::Issues::MoveService::LIST_END_POSITION && + args[:position_in_list] < 0 + raise Gitlab::Graphql::Errors::ArgumentError, + "positionInList must be >= 0 or #{::Boards::Issues::MoveService::LIST_END_POSITION}" + end + end + super end @@ -77,7 +101,7 @@ module Mutations end def move_arguments(args) - args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id) + args.slice(:from_list_id, :to_list_id, :position_in_list, :move_after_id, :move_before_id) end def error_for(result) diff --git a/app/graphql/mutations/ci/job/artifacts_destroy.rb b/app/graphql/mutations/ci/job/artifacts_destroy.rb new file mode 100644 index 00000000000..c27ab9c4d89 --- /dev/null +++ b/app/graphql/mutations/ci/job/artifacts_destroy.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Job + class ArtifactsDestroy < Base + graphql_name 'JobArtifactsDestroy' + + authorize :destroy_artifacts + + field :job, + Types::Ci::JobType, + null: true, + description: 'Job with artifacts to be deleted.' + + field :destroyed_artifacts_count, + GraphQL::Types::Int, + null: false, + description: 'Number of artifacts deleted.' + + def find_object(id: ) + GlobalID::Locator.locate(id) + end + + def resolve(id:) + job = authorized_find!(id: id) + + result = ::Ci::JobArtifacts::DestroyBatchService.new(job.job_artifacts, pick_up_at: Time.current).execute + { + job: job, + destroyed_artifacts_count: result[:destroyed_artifacts_count], + errors: Array(result[:errors]) + } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/job_artifact/destroy.rb b/app/graphql/mutations/ci/job_artifact/destroy.rb new file mode 100644 index 00000000000..47b3535d631 --- /dev/null +++ b/app/graphql/mutations/ci/job_artifact/destroy.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module JobArtifact + class Destroy < BaseMutation + graphql_name 'ArtifactDestroy' + + authorize :destroy_artifacts + + ArtifactID = ::Types::GlobalIDType[::Ci::JobArtifact] + + argument :id, + ArtifactID, + required: true, + description: 'ID of the artifact to delete.' + + field :artifact, + Types::Ci::JobArtifactType, + null: true, + description: 'Deleted artifact.' + + def find_object(id: ) + GlobalID::Locator.locate(id) + end + + def resolve(id:) + artifact = authorized_find!(id: id) + + if artifact.destroy + { errors: [] } + else + { errors: artifact.errors.full_messages } + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/runner/bulk_delete.rb b/app/graphql/mutations/ci/runner/bulk_delete.rb index 4c1c2967799..4265099d28e 100644 --- a/app/graphql/mutations/ci/runner/bulk_delete.rb +++ b/app/graphql/mutations/ci/runner/bulk_delete.rb @@ -40,9 +40,7 @@ module Mutations private def model_ids_of(ids) - ids.map do |gid| - gid.model_id.to_i - end.compact + ids.filter_map { |gid| gid.model_id.to_i } end def find_all_runners_by_ids(ids) diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb index 1c6cf6989bf..f98138646be 100644 --- a/app/graphql/mutations/ci/runner/update.rb +++ b/app/graphql/mutations/ci/runner/update.rb @@ -48,8 +48,13 @@ module Mutations description: 'Indicates the runner is able to run untagged jobs.' argument :tag_list, [GraphQL::Types::String], - required: false, - description: 'Tags associated with the runner.' + required: false, + description: 'Tags associated with the runner.' + + argument :associated_projects, [::Types::GlobalIDType[::Project]], + required: false, + description: 'Projects associated with the runner. Available only for project runners.', + prepare: -> (global_ids, ctx) { global_ids&.filter_map { |gid| gid.model_id.to_i } } field :runner, Types::Ci::RunnerType, @@ -59,16 +64,47 @@ module Mutations def resolve(id:, **runner_attrs) runner = authorized_find!(id) - unless ::Ci::Runners::UpdateRunnerService.new(runner).update(runner_attrs) - return { runner: nil, errors: runner.errors.full_messages } + associated_projects_ids = runner_attrs.delete(:associated_projects) + + response = { runner: runner, errors: [] } + ::Ci::Runner.transaction do + associate_runner_projects(response, runner, associated_projects_ids) if associated_projects_ids.present? + update_runner(response, runner, runner_attrs) end - { runner: runner, errors: [] } + response end def find_object(id) GitlabSchema.find_by_gid(id) end + + private + + def associate_runner_projects(response, runner, associated_project_ids) + unless runner.project_type? + raise Gitlab::Graphql::Errors::ArgumentError, + "associatedProjects must not be specified for '#{runner.runner_type}' scope" + end + + result = ::Ci::Runners::SetRunnerAssociatedProjectsService.new( + runner: runner, + current_user: current_user, + project_ids: associated_project_ids + ).execute + return if result.success? + + response[:errors] = result.errors + raise ActiveRecord::Rollback + end + + def update_runner(response, runner, attrs) + result = ::Ci::Runners::UpdateRunnerService.new(runner).execute(attrs) + return if result.success? + + response[:errors] = result.errors + raise ActiveRecord::Rollback + end end end end diff --git a/app/graphql/mutations/custom_emoji/create.rb b/app/graphql/mutations/custom_emoji/create.rb index 269ea6c9999..535ff44a7fd 100644 --- a/app/graphql/mutations/custom_emoji/create.rb +++ b/app/graphql/mutations/custom_emoji/create.rb @@ -28,6 +28,10 @@ module Mutations description: 'Location of the emoji file.' def resolve(group_path:, **args) + if Feature.disabled?(:custom_emoji) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Custom emoji feature is disabled' + end + group = authorized_find!(group_path: group_path) # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37911#note_444682238 args[:external] = true diff --git a/app/graphql/mutations/custom_emoji/destroy.rb b/app/graphql/mutations/custom_emoji/destroy.rb index 863b8152cc7..64e3f2ed7d3 100644 --- a/app/graphql/mutations/custom_emoji/destroy.rb +++ b/app/graphql/mutations/custom_emoji/destroy.rb @@ -17,6 +17,10 @@ module Mutations description: 'Global ID of the custom emoji to destroy.' def resolve(id:) + if Feature.disabled?(:custom_emoji) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Custom emoji feature is disabled' + end + custom_emoji = authorized_find!(id: id) custom_emoji.destroy! diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb index 65c919db3c3..6be07edd883 100644 --- a/app/graphql/mutations/dependency_proxy/group_settings/update.rb +++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb @@ -8,6 +8,11 @@ module Mutations include Mutations::ResolvesGroup + description 'These settings can be adjusted by the group Owner or Maintainer. However, in GitLab 16.0, we ' \ + 'will be limiting this to the Owner role. ' \ + '[GitLab-#364441](https://gitlab.com/gitlab-org/gitlab/-/issues/364441) proposes making ' \ + 'this change to match the permissions level in the user interface.' + authorize :admin_dependency_proxy argument :group_path, diff --git a/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb b/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb index 31ae29d896b..bb1da9278ff 100644 --- a/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb +++ b/app/graphql/mutations/incident_management/timeline_event/promote_from_note.rb @@ -6,6 +6,8 @@ module Mutations class PromoteFromNote < Base graphql_name 'TimelineEventPromoteFromNote' + include NotesHelper + argument :note_id, Types::GlobalIDType[::Note], required: true, description: 'Note ID from which the timeline event promoted.' @@ -20,7 +22,7 @@ module Mutations incident, current_user, promoted_from_note: note, - note: note.note, + note: build_note_string(note), occurred_at: note.created_at, editable: true ).execute @@ -38,6 +40,11 @@ module Mutations super end + def build_note_string(note) + commented = _('commented') + "@#{note.author.username} [#{commented}](#{noteable_note_url(note)}): '#{note.note}'" + end + def raise_noteable_not_incident! raise_resource_not_available_error! 'Note does not belong to an incident' end diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb index 70a0e71c869..ba1fa8d446c 100644 --- a/app/graphql/mutations/releases/create.rb +++ b/app/graphql/mutations/releases/create.rb @@ -32,7 +32,7 @@ module Mutations argument :released_at, Types::TimeType, required: false, - description: 'Date and time for the release. Defaults to the current date and time.' + description: 'Date and time for the release. Defaults to the current time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). Only provide this field if creating an upcoming or historical release.' argument :milestones, [GraphQL::Types::String], required: false, diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index fe0ad6df65b..20913a9e7da 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -32,9 +32,7 @@ module Mutations private def model_ids_of(ids) - ids.map do |gid| - gid.model_id.to_i - end.compact + ids.filter_map { |gid| gid.model_id.to_i } end def raise_too_many_todos_requested_error diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/graphql/queries/repository/blob_info.query.graphql index 45a7793e559..fd463436ed4 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/graphql/queries/repository/blob_info.query.graphql @@ -1,5 +1,3 @@ -#import "ee_else_ce/repository/queries/path_locks.fragment.graphql" - query getBlobInfo( $projectPath: ID! $filePath: String! @@ -7,17 +5,15 @@ query getBlobInfo( $shouldFetchRawText: Boolean! ) { project(fullPath: $projectPath) { - userPermissions { - pushCode - downloadCode - createMergeRequestIn - forkProject - } - ...ProjectPathLocksFragment + __typename + id repository { + __typename empty blobs(paths: [$filePath], ref: $ref) { + __typename nodes { + __typename id webPath name diff --git a/app/graphql/resolvers/ci/job_token_scope_resolver.rb b/app/graphql/resolvers/ci/job_token_scope_resolver.rb index ca76a7b94fc..7c6aedad1d6 100644 --- a/app/graphql/resolvers/ci/job_token_scope_resolver.rb +++ b/app/graphql/resolvers/ci/job_token_scope_resolver.rb @@ -6,14 +6,12 @@ module Resolvers include Gitlab::Graphql::Authorize::AuthorizeResource authorize :admin_project - description 'Container for resources that can be accessed by a CI job token from the current project. Null if job token scope setting is disabled.' + description 'Container for resources that can be accessed by a CI job token from the current project.' type ::Types::Ci::JobTokenScopeType, null: true def resolve authorize!(object) - return unless object.ci_job_token_scope_enabled? - ::Ci::JobToken::Scope.new(object) end end diff --git a/app/graphql/resolvers/ci/runner_jobs_resolver.rb b/app/graphql/resolvers/ci/runner_jobs_resolver.rb index 2f6ca09d031..de00aadaea8 100644 --- a/app/graphql/resolvers/ci/runner_jobs_resolver.rb +++ b/app/graphql/resolvers/ci/runner_jobs_resolver.rb @@ -9,6 +9,7 @@ module Resolvers type ::Types::Ci::JobType.connection_type, null: true authorize :read_builds authorizes_object! + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 argument :statuses, [::Types::Ci::JobStatusEnum], required: false, @@ -16,15 +17,6 @@ module Resolvers alias_method :runner, :object - def ready?(**args) - context[self.class] ||= { executions: 0 } - context[self.class][:executions] += 1 - - raise GraphQL::ExecutionError, "Jobs can be requested for only one runner at a time" if context[self.class][:executions] > 1 - - super - end - def resolve_with_lookahead(statuses: nil) jobs = ::Ci::JobsFinder.new(current_user: current_user, runner: runner, params: { scope: statuses }).execute diff --git a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb index 14b5f8f90eb..da8fab93619 100644 --- a/app/graphql/resolvers/ci/runner_owner_project_resolver.rb +++ b/app/graphql/resolvers/ci/runner_owner_project_resolver.rb @@ -9,7 +9,7 @@ module Resolvers alias_method :runner, :object - def resolve_with_lookahead(**args) + def resolve_with_lookahead(**_args) resolve_owner end @@ -19,6 +19,8 @@ module Resolvers } end + private + def filtered_preloads selection = lookahead @@ -27,8 +29,6 @@ module Resolvers end end - private - def resolve_owner return unless runner.project_type? @@ -48,14 +48,13 @@ module Resolvers .transform_values { |runner_projects| runner_projects.first.project_id } project_ids = owner_project_id_by_runner_id.values.uniq - all_preloads = unconditional_includes + filtered_preloads - owner_relation = Project.all - owner_relation = owner_relation.preload(*all_preloads) if all_preloads.any? - projects = owner_relation.where(id: project_ids).index_by(&:id) + projects = Project.where(id: project_ids) + Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute + projects_by_id = projects.index_by(&:id) runner_ids.each do |runner_id| owner_project_id = owner_project_id_by_runner_id[runner_id] - loader.call(runner_id, projects[owner_project_id]) + loader.call(runner_id, projects_by_id[owner_project_id]) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/graphql/resolvers/ci/runner_projects_resolver.rb b/app/graphql/resolvers/ci/runner_projects_resolver.rb new file mode 100644 index 00000000000..ca3b4ebb797 --- /dev/null +++ b/app/graphql/resolvers/ci/runner_projects_resolver.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerProjectsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include LooksAhead + include ProjectSearchArguments + + type Types::ProjectType.connection_type, null: true + authorize :read_runner + authorizes_object! + + alias_method :runner, :object + + argument :sort, GraphQL::Types::String, + required: false, + default_value: 'id_asc', # TODO: Remove in %16.0 and move :sort to ProjectSearchArguments, see https://gitlab.com/gitlab-org/gitlab/-/issues/372117 + deprecated: { + reason: 'Default sort order will change in 16.0. ' \ + 'Specify `"id_asc"` if query results\' order is important', + milestone: '15.4' + }, + description: "Sort order of results. Format: '<field_name>_<sort_direction>', " \ + "for example: 'id_desc' or 'name_asc'" + + def resolve_with_lookahead(**args) + return unless runner.project_type? + + # rubocop:disable CodeReuse/ActiveRecord + BatchLoader::GraphQL.for(runner.id).batch(key: :runner_projects) do |runner_ids, loader| + plucked_runner_and_project_ids = ::Ci::RunnerProject + .select(:runner_id, :project_id) + .where(runner_id: runner_ids) + .pluck(:runner_id, :project_id) + + project_ids = plucked_runner_and_project_ids.collect { |_runner_id, project_id| project_id }.uniq + projects = ProjectsFinder + .new(current_user: current_user, + params: project_finder_params(args), + project_ids_relation: project_ids) + .execute + Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute + projects_by_id = projects.index_by(&:id) + + # In plucked_runner_and_project_ids, first() represents the runner ID, and second() the project ID, + # so let's group the project IDs by runner ID + runner_project_ids_by_runner_id = + plucked_runner_and_project_ids + .group_by(&:first) + .transform_values { |values| values.map(&:second).filter_map { |project_id| projects_by_id[project_id] } } + + runner_ids.each do |runner_id| + runner_projects = runner_project_ids_by_runner_id[runner_id] || [] + + loader.call(runner_id, runner_projects) + end + end + # rubocop:enable CodeReuse/ActiveRecord + end + end + end +end diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb index f758e217b47..a2d3af9c664 100644 --- a/app/graphql/resolvers/ci/test_suite_resolver.rb +++ b/app/graphql/resolvers/ci/test_suite_resolver.rb @@ -28,7 +28,8 @@ module Resolvers def load_test_suite_data(builds) suite = builds.sum do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report.get_suite(build.test_suite_name) end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, pipeline.project).load! diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index fe213936f55..8295bd58388 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -76,24 +76,11 @@ module IssueResolverArguments end def resolve_with_lookahead(**args) - # The project could have been loaded in batch by `BatchLoader`. - # At this point we need the `id` of the project to query for issues, so - # make sure it's loaded and not `nil` before continuing. - parent = object.respond_to?(:sync) ? object.sync : object - return Issue.none if parent.nil? - - # Will need to be made group & namespace aware with - # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 - args[:not] = args[:not].to_h if args[:not].present? - args[:iids] ||= [args.delete(:iid)].compact if args[:iid] - args[:attempt_project_search_optimizations] = true if args[:search].present? + return Issue.none if resource_parent.nil? - prepare_assignee_username_params(args) - prepare_release_tag_params(args) + finder = IssuesFinder.new(current_user, prepare_finder_params(args)) - finder = IssuesFinder.new(current_user, args) - - continue_issue_resolve(parent, finder, **args) + continue_issue_resolve(resource_parent, finder, **args) end def ready?(**args) @@ -103,7 +90,6 @@ module IssueResolverArguments params_not_mutually_exclusive(args, mutually_exclusive_milestone_args) params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args) params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args) - validate_anonymous_search_access! if args[:search].present? super end @@ -128,6 +114,17 @@ module IssueResolverArguments private + def prepare_finder_params(args) + params = super(args) + params[:iids] ||= [params.delete(:iid)].compact if params[:iid] + params[:attempt_project_search_optimizations] = true if params[:search].present? + + prepare_assignee_username_params(params) + prepare_release_tag_params(params) + + params + end + def prepare_release_tag_params(args) release_tag_wildcard = args.delete(:release_tag_wildcard_id) return if release_tag_wildcard.blank? @@ -135,20 +132,13 @@ module IssueResolverArguments args[:release_tag] ||= release_tag_wildcard end - def mutually_exclusive_release_tag_args - [:release_tag, :release_tag_wildcard_id] - end - def prepare_assignee_username_params(args) args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present? args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present? end - def params_not_mutually_exclusive(args, mutually_exclusive_args) - if args.slice(*mutually_exclusive_args).compact.size > 1 - arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ') - raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time." - end + def mutually_exclusive_release_tag_args + [:release_tag, :release_tag_wildcard_id] end def mutually_exclusive_milestone_args @@ -158,4 +148,20 @@ module IssueResolverArguments def mutually_exclusive_assignee_username_args [:assignee_usernames, :assignee_username] end + + def params_not_mutually_exclusive(args, mutually_exclusive_args) + if args.slice(*mutually_exclusive_args).compact.size > 1 + arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(', ') + raise ::Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time." + end + end + + def resource_parent + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for issues, so + # make sure it's loaded and not `nil` before continuing. + strong_memoize(:resource_parent) do + object.respond_to?(:sync) ? object.sync : object + end + end end diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb index 644b2a11460..b548dc1e175 100644 --- a/app/graphql/resolvers/concerns/looks_ahead.rb +++ b/app/graphql/resolvers/concerns/looks_ahead.rb @@ -33,10 +33,14 @@ module LooksAhead end def filtered_preloads - selection = node_selection + nodes = node_selection + + return [] unless nodes + + selected_fields = nodes.selections.map(&:name) preloads.each.flat_map do |name, requirements| - selection&.selects?(name) ? requirements : [] + selected_fields.include?(name) ? requirements : [] end end diff --git a/app/graphql/resolvers/concerns/project_search_arguments.rb b/app/graphql/resolvers/concerns/project_search_arguments.rb new file mode 100644 index 00000000000..7e03963f412 --- /dev/null +++ b/app/graphql/resolvers/concerns/project_search_arguments.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ProjectSearchArguments + extend ActiveSupport::Concern + + included do + argument :membership, GraphQL::Types::Boolean, + required: false, + description: 'Return only projects that the current user is a member of.' + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search query, which can be for the project name, a path, or a description.' + + argument :search_namespaces, GraphQL::Types::Boolean, + required: false, + description: 'Include namespace in project search.' + + argument :topics, type: [GraphQL::Types::String], + required: false, + description: 'Filter projects by topics.' + end + + private + + def project_finder_params(params) + { + without_deleted: true, + non_public: params[:membership], + search: params[:search], + search_namespaces: params[:search_namespaces], + sort: params[:sort], + topic: params[:topics] + }.compact + end +end diff --git a/app/graphql/resolvers/concerns/search_arguments.rb b/app/graphql/resolvers/concerns/search_arguments.rb index 7f480f9d0b6..95c6dbf7497 100644 --- a/app/graphql/resolvers/concerns/search_arguments.rb +++ b/app/graphql/resolvers/concerns/search_arguments.rb @@ -7,12 +7,49 @@ module SearchArguments argument :search, GraphQL::Types::String, required: false, description: 'Search query for title or description.' + argument :in, [Types::IssuableSearchableFieldEnum], + required: false, + description: <<~DESC + Specify the fields to perform the search in. + Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.' + DESC + end + + def ready?(**args) + validate_search_in_params!(args) + validate_anonymous_search_access!(args) + + super end - def validate_anonymous_search_access! + private + + def validate_anonymous_search_access!(args) + return unless args[:search].present? return if current_user.present? || Feature.disabled?(:disable_anonymous_search, type: :ops) raise ::Gitlab::Graphql::Errors::ArgumentError, "User must be authenticated to include the `search` argument." end + + def validate_search_in_params!(args) + return unless args[:in].present? && args[:search].blank? + + raise Gitlab::Graphql::Errors::ArgumentError, + '`search` should be present when including the `in` argument' + end + + def prepare_finder_params(args) + prepare_search_params(args) + end + + def prepare_search_params(args) + return args unless args[:search].present? + + parent_type = resource_parent.is_a?(Project) ? :project : :group + args[:"attempt_#{parent_type}_search_optimizations"] = true + args[:in] = args[:in].join(',') if args[:in].present? + + args + end end diff --git a/app/graphql/resolvers/crm/organization_state_counts_resolver.rb b/app/graphql/resolvers/crm/organization_state_counts_resolver.rb new file mode 100644 index 00000000000..c16a4bd24ea --- /dev/null +++ b/app/graphql/resolvers/crm/organization_state_counts_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + module Crm + class OrganizationStateCountsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_crm_organization + authorizes_object! + + type Types::CustomerRelations::OrganizationStateCountsType, null: true + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search term to find organizations with.' + + argument :state, Types::CustomerRelations::OrganizationStateEnum, + required: false, + description: 'State of the organizations to search for.' + + def resolve(**args) + ::Crm::OrganizationsFinder.counts_by_state(context[:current_user], args.merge({ group: object })) + end + end + end +end diff --git a/app/graphql/resolvers/crm/organizations_resolver.rb b/app/graphql/resolvers/crm/organizations_resolver.rb index ca0a908ee22..719834f406d 100644 --- a/app/graphql/resolvers/crm/organizations_resolver.rb +++ b/app/graphql/resolvers/crm/organizations_resolver.rb @@ -10,6 +10,11 @@ module Resolvers type Types::CustomerRelations::OrganizationType, null: true + argument :sort, Types::CustomerRelations::OrganizationSortEnum, + description: 'Criteria to sort organizations by.', + required: false, + default_value: { field: 'name', direction: :asc } + argument :search, GraphQL::Types::String, required: false, description: 'Search term used to find organizations with.' @@ -24,6 +29,7 @@ module Resolvers def resolve(**args) args[:ids] = resolve_ids(args.delete(:ids)) + args.delete(:state) if args[:state] == :all ::Crm::OrganizationsFinder.new(current_user, { group: group }.merge(args)).execute end diff --git a/app/graphql/resolvers/deployment_resolver.rb b/app/graphql/resolvers/deployment_resolver.rb new file mode 100644 index 00000000000..7d9ce0f023c --- /dev/null +++ b/app/graphql/resolvers/deployment_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class DeploymentResolver < BaseResolver + argument :iid, + GraphQL::Types::ID, + required: true, + description: 'Project-level internal ID of the Deployment.' + + type Types::DeploymentType, null: true + + alias_method :project, :object + + def resolve(iid:) + return unless project.present? && project.is_a?(::Project) + + Deployment.for_iid(project, iid) + end + end +end diff --git a/app/graphql/resolvers/deployments_resolver.rb b/app/graphql/resolvers/deployments_resolver.rb new file mode 100644 index 00000000000..341d23c2ccb --- /dev/null +++ b/app/graphql/resolvers/deployments_resolver.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Resolvers + class DeploymentsResolver < BaseResolver + argument :statuses, [Types::DeploymentStatusEnum], + description: 'Statuses of the deployments.', + required: false, + as: :status + + argument :order_by, Types::DeploymentsOrderByInputType, + description: 'Order by a specified field.', + required: false + + type Types::DeploymentType, null: true + + alias_method :environment, :object + + def resolve(**args) + return unless environment.present? && environment.is_a?(::Environment) + + args = transform_args_for_finder(**args) + + # GraphQL BatchLoader shouldn't be used here because pagination query will be inefficient + # that fetches thousands of rows before limiting and offsetting. + DeploymentsFinder.new(environment: environment.id, **args).execute + end + + private + + def transform_args_for_finder(**args) + if (order_by = args.delete(:order_by)) + order_by = order_by.to_h.map { |k, v| { order_by: k.to_s, sort: v } }.first + args.merge!(order_by) + end + + args + end + end +end diff --git a/app/graphql/resolvers/environments/last_deployment_resolver.rb b/app/graphql/resolvers/environments/last_deployment_resolver.rb new file mode 100644 index 00000000000..76f80112673 --- /dev/null +++ b/app/graphql/resolvers/environments/last_deployment_resolver.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Resolvers + module Environments + class LastDeploymentResolver < BaseResolver + argument :status, + Types::DeploymentStatusEnum, + required: true, + description: 'Status of the Deployment.' + + type Types::DeploymentType, null: true + + def resolve(status:) + return unless object.present? && object.is_a?(::Environment) + + validate!(status) + + find_last_deployment(status) + end + + private + + def find_last_deployment(status) + BatchLoader::GraphQL.for(object).batch(key: status) do |environments, loader, args| + association_name = "last_#{args[:key]}_deployment".to_sym + + Preloaders::Environments::DeploymentPreloader.new(environments) + .execute_with_union(association_name, {}) + + environments.each do |environment| + loader.call(environment, environment.public_send(association_name)) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + + def validate!(status) + unless Deployment::FINISHED_STATUSES.include?(status.to_sym) || + Deployment::UPCOMING_STATUSES.include?(status.to_sym) + raise Gitlab::Graphql::Errors::ArgumentError, "\"#{status}\" status is not supported." + end + end + end + end +end diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb index 934c1ba2738..f265e2183d0 100644 --- a/app/graphql/resolvers/environments_resolver.rb +++ b/app/graphql/resolvers/environments_resolver.rb @@ -21,8 +21,8 @@ module Resolvers def resolve(**args) return unless project.present? - Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute - rescue Environments::EnvironmentsFinder::InvalidStatesError => e + ::Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute + rescue ::Environments::EnvironmentsFinder::InvalidStatesError => e raise Gitlab::Graphql::Errors::ArgumentError, e.message end end diff --git a/app/graphql/resolvers/group_packages_resolver.rb b/app/graphql/resolvers/group_packages_resolver.rb index b48e0b75190..e6a6abb39dd 100644 --- a/app/graphql/resolvers/group_packages_resolver.rb +++ b/app/graphql/resolvers/group_packages_resolver.rb @@ -5,6 +5,8 @@ module Resolvers class GroupPackagesResolver < PackagesBaseResolver # The GraphQL type is defined in the extended class + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + argument :sort, Types::Packages::PackageGroupSortEnum, description: 'Sort packages by this criteria.', required: false, @@ -15,14 +17,6 @@ module Resolvers project_path_asc: { order_by: 'project_path', sort: 'asc' } }).freeze - def ready?(**args) - context[self.class] ||= { executions: 0 } - context[self.class][:executions] += 1 - raise GraphQL::ExecutionError, "Packages can be requested only for one group at a time" if context[self.class][:executions] > 1 - - super - end - def resolve(sort:, **filters) return unless packages_available? diff --git a/app/graphql/resolvers/members_resolver.rb b/app/graphql/resolvers/members_resolver.rb index 827db54134a..3d7894fdd6a 100644 --- a/app/graphql/resolvers/members_resolver.rb +++ b/app/graphql/resolvers/members_resolver.rb @@ -11,6 +11,10 @@ module Resolvers required: false, description: 'Search query.' + argument :sort, ::Types::MemberSortEnum, + required: false, + description: 'sort query.' + def resolve_with_lookahead(**args) authorize!(object) diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb index 705d3900cd2..b77c6b1112b 100644 --- a/app/graphql/resolvers/package_details_resolver.rb +++ b/app/graphql/resolvers/package_details_resolver.rb @@ -2,20 +2,14 @@ module Resolvers class PackageDetailsResolver < BaseResolver + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + type ::Types::Packages::PackageDetailsType, null: true argument :id, ::Types::GlobalIDType[::Packages::Package], required: true, description: 'Global ID of the package.' - def ready?(**args) - context[self.class] ||= { executions: 0 } - context[self.class][:executions] += 1 - raise GraphQL::ExecutionError, "Package details can be requested only for one package at a time" if context[self.class][:executions] > 1 - - super - end - def resolve(id:) GitlabSchema.find_by_gid(id) end diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb index b09158d475d..4d13a4a3fae 100644 --- a/app/graphql/resolvers/project_jobs_resolver.rb +++ b/app/graphql/resolvers/project_jobs_resolver.rb @@ -8,6 +8,7 @@ module Resolvers type ::Types::Ci::JobType.connection_type, null: true authorize :read_build authorizes_object! + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 argument :statuses, [::Types::Ci::JobStatusEnum], required: false, @@ -15,15 +16,6 @@ module Resolvers alias_method :project, :object - def ready?(**args) - context[self.class] ||= { executions: 0 } - context[self.class][:executions] += 1 - - raise GraphQL::ExecutionError, "Jobs can be requested for only one project at a time" if context[self.class][:executions] > 1 - - super - end - def resolve_with_lookahead(statuses: nil) jobs = ::Ci::JobsFinder.new(current_user: current_user, project: project, params: { scope: statuses }).execute diff --git a/app/graphql/resolvers/projects/branch_rules_resolver.rb b/app/graphql/resolvers/projects/branch_rules_resolver.rb new file mode 100644 index 00000000000..6c8b416bcea --- /dev/null +++ b/app/graphql/resolvers/projects/branch_rules_resolver.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class BranchRulesResolver < BaseResolver + type Types::Projects::BranchRuleType.connection_type, null: false + + alias_method :project, :object + + def resolve(**args) + project.protected_branches + end + end + end +end diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index facf8ffe36f..4d1e1b867da 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -2,31 +2,18 @@ module Resolvers class ProjectsResolver < BaseResolver - type Types::ProjectType, null: true - - argument :membership, GraphQL::Types::Boolean, - required: false, - description: 'Limit projects that the current user is a member of.' + include ProjectSearchArguments - argument :search, GraphQL::Types::String, - required: false, - description: 'Search query for project name, path, or description.' + type Types::ProjectType, null: true argument :ids, [GraphQL::Types::ID], required: false, description: 'Filter projects by IDs.' - argument :search_namespaces, GraphQL::Types::Boolean, - required: false, - description: 'Include namespace in project search.' - argument :sort, GraphQL::Types::String, required: false, - description: 'Sort order of results.' - - argument :topics, type: [GraphQL::Types::String], - required: false, - description: 'Filters projects by topics.' + description: "Sort order of results. Format: '<field_name>_<sort_direction>', " \ + "for example: 'id_desc' or 'name_asc'" def resolve(**args) ProjectsFinder @@ -36,17 +23,6 @@ module Resolvers private - def project_finder_params(params) - { - without_deleted: true, - non_public: params[:membership], - search: params[:search], - search_namespaces: params[:search_namespaces], - sort: params[:sort], - topic: params[:topics] - }.compact - end - def parse_gids(gids) gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::Project).model_id } end diff --git a/app/graphql/resolvers/work_items_resolver.rb b/app/graphql/resolvers/work_items_resolver.rb index 055984db3cb..9c7931a4edb 100644 --- a/app/graphql/resolvers/work_items_resolver.rb +++ b/app/graphql/resolvers/work_items_resolver.rb @@ -26,27 +26,31 @@ module Resolvers required: false def resolve_with_lookahead(**args) - # The project could have been loaded in batch by `BatchLoader`. - # At this point we need the `id` of the project to query for issues, so - # make sure it's loaded and not `nil` before continuing. - parent = object.respond_to?(:sync) ? object.sync : object - return WorkItem.none if parent.nil? || !parent.work_items_feature_flag_enabled? + return WorkItem.none if resource_parent.nil? || !resource_parent.work_items_feature_flag_enabled? - args[:iids] ||= [args.delete(:iid)].compact if args[:iid] - args[:attempt_project_search_optimizations] = true if args[:search].present? + finder = ::WorkItems::WorkItemsFinder.new(current_user, prepare_finder_params(args)) - finder = ::WorkItems::WorkItemsFinder.new(current_user, args) - - Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) } + Gitlab::Graphql::Loaders::IssuableLoader.new(resource_parent, finder).batching_find_all { |q| apply_lookahead(q) } end - def ready?(**args) - validate_anonymous_search_access! if args[:search].present? + private - super + def preloads + { + last_edited_by: :last_edited_by + } end - private + # Allows to apply lookahead for fields + # selected from WidgetInterface + override :node_selection + def node_selection + selected_fields = super + + return unless selected_fields + + selected_fields.selection(:widgets) + end def unconditional_includes [ @@ -56,6 +60,22 @@ module Resolvers :author ] end + + def prepare_finder_params(args) + params = super(args) + params[:iids] ||= [params.delete(:iid)].compact if params[:iid] + + params + end + + def resource_parent + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for work items, so + # make sure it's loaded and not `nil` before continuing. + strong_memoize(:resource_parent) do + object.respond_to?(:sync) ? object.sync : object + end + end end end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 1c43432594a..6f64e5b5053 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -17,8 +17,6 @@ module Types @requires_argument = !!kwargs.delete(:requires_argument) @authorize = Array.wrap(kwargs.delete(:authorize)) kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) - @feature_flag = kwargs[:_deprecated_feature_flag] - kwargs = check_feature_flag(kwargs) @deprecation = gitlab_deprecation(kwargs) after_connection_extensions = kwargs.delete(:late_extensions) || [] @@ -91,16 +89,8 @@ module Types @constant_complexity end - def visible?(context) - return false if feature_flag.present? && !Feature.enabled?(feature_flag) - - super - end - private - attr_reader :feature_flag - def field_authorized?(object, ctx) object = object.node if object.is_a?(GraphQL::Pagination::Connection::Edge) @@ -123,27 +113,6 @@ module Types @authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(@authorize) end - def feature_documentation_message(key, description) - message_parts = ["#{description} Available only when feature flag `#{key}` is enabled."] - - message_parts << if Feature::Definition.has_definition?(key) && Feature::Definition.default_enabled?(key) - "This flag is enabled by default." - else - "This flag is disabled by default, because the feature is experimental and is subject to change without notice." - end - - message_parts.join(' ') - end - - def check_feature_flag(args) - ff = args.delete(:_deprecated_feature_flag) - return args unless ff.present? - - args[:description] = feature_documentation_message(ff, args[:description]) - - args - end - def field_complexity(resolver_class, current) return current if current.present? && current > 0 diff --git a/app/graphql/types/branch_protections/base_access_level_type.rb b/app/graphql/types/branch_protections/base_access_level_type.rb new file mode 100644 index 00000000000..472733a6bc5 --- /dev/null +++ b/app/graphql/types/branch_protections/base_access_level_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module BranchProtections + class BaseAccessLevelType < Types::BaseObject + authorize :read_protected_branch + + field :access_level, + type: GraphQL::Types::Int, + null: false, + description: 'GitLab::Access level.' + + field :access_level_description, + type: GraphQL::Types::String, + null: false, + description: 'Human readable representation for this access level.', + hash_key: 'humanize' + end + end +end + +Types::BranchProtections::BaseAccessLevelType.prepend_mod diff --git a/app/graphql/types/branch_protections/merge_access_level_type.rb b/app/graphql/types/branch_protections/merge_access_level_type.rb new file mode 100644 index 00000000000..85295e1ba25 --- /dev/null +++ b/app/graphql/types/branch_protections/merge_access_level_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BranchProtections + class MergeAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'MergeAccessLevel' + description 'Represents the merge access level of a branch protection.' + accepts ::ProtectedBranch::MergeAccessLevel + end + end +end diff --git a/app/graphql/types/branch_protections/push_access_level_type.rb b/app/graphql/types/branch_protections/push_access_level_type.rb new file mode 100644 index 00000000000..bfbdc4edbea --- /dev/null +++ b/app/graphql/types/branch_protections/push_access_level_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BranchProtections + class PushAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'PushAccessLevel' + description 'Represents the push access level of a branch protection.' + accepts ::ProtectedBranch::PushAccessLevel + end + end +end diff --git a/app/graphql/types/branch_rules/branch_protection_type.rb b/app/graphql/types/branch_rules/branch_protection_type.rb new file mode 100644 index 00000000000..4177a6f92a1 --- /dev/null +++ b/app/graphql/types/branch_rules/branch_protection_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module BranchRules + class BranchProtectionType < BaseObject + graphql_name 'BranchProtection' + description 'Branch protection details for a branch rule.' + accepts ::ProtectedBranch + authorize :read_protected_branch + + field :merge_access_levels, + type: Types::BranchProtections::MergeAccessLevelType.connection_type, + null: true, + description: 'Details about who can merge when this branch is the source branch.' + + field :push_access_levels, + type: Types::BranchProtections::PushAccessLevelType.connection_type, + null: true, + description: 'Details about who can push when this branch is the source branch.' + + field :allow_force_push, + type: GraphQL::Types::Boolean, + null: false, + description: 'Toggle force push to the branch for users with write access.' + end + end +end + +Types::BranchRules::BranchProtectionType.prepend_mod_with('Types::BranchRules::BranchProtectionType') diff --git a/app/graphql/types/ci/config_variable_type.rb b/app/graphql/types/ci/config_variable_type.rb new file mode 100644 index 00000000000..87ae026c2c1 --- /dev/null +++ b/app/graphql/types/ci/config_variable_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Ci + class ConfigVariableType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'CiConfigVariable' + description 'CI/CD config variables.' + + field :key, GraphQL::Types::String, + null: true, + description: 'Name of the variable.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Description for the CI/CD config variable.' + + field :value, GraphQL::Types::String, + null: true, + description: 'Value of the variable.' + end + end +end diff --git a/app/graphql/types/ci/group_variable_connection_type.rb b/app/graphql/types/ci/group_variable_connection_type.rb new file mode 100644 index 00000000000..1f55dde6697 --- /dev/null +++ b/app/graphql/types/ci/group_variable_connection_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupVariableConnectionType < GraphQL::Types::Relay::BaseConnection + field :limit, GraphQL::Types::Int, + null: false, + description: 'Maximum amount of group CI/CD variables.' + + def limit + ::Plan.default.actual_limits.group_ci_variables + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/group_variable_type.rb b/app/graphql/types/ci/group_variable_type.rb index 3322f741342..f9ed54f0d10 100644 --- a/app/graphql/types/ci/group_variable_type.rb +++ b/app/graphql/types/ci/group_variable_type.rb @@ -7,19 +7,20 @@ module Types graphql_name 'CiGroupVariable' description 'CI/CD variables for a group.' + connection_type_class(Types::Ci::GroupVariableConnectionType) implements(VariableInterface) field :environment_scope, GraphQL::Types::String, - null: true, - description: 'Scope defining the environments that can use the variable.' - - field :protected, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is protected.' + null: true, + description: 'Scope defining the environments that can use the variable.' field :masked, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is masked.' + null: true, + description: 'Indicates whether the variable is masked.' + + field :protected, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is protected.' end end end diff --git a/app/graphql/types/ci/instance_variable_type.rb b/app/graphql/types/ci/instance_variable_type.rb index f564a2f59a0..7ffc52deb73 100644 --- a/app/graphql/types/ci/instance_variable_type.rb +++ b/app/graphql/types/ci/instance_variable_type.rb @@ -9,21 +9,29 @@ module Types implements(VariableInterface) + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the variable.' + field :environment_scope, GraphQL::Types::String, - null: true, - deprecated: { - reason: 'No longer used, only available for GroupVariableType and ProjectVariableType', - milestone: '15.3' - }, - description: 'Scope defining the environments that can use the variable.' + null: true, + deprecated: { + reason: 'No longer used, only available for GroupVariableType and ProjectVariableType', + milestone: '15.3' + }, + description: 'Scope defining the environments that can use the variable.' field :protected, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is protected.' + null: true, + description: 'Indicates whether the variable is protected.' field :masked, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is masked.' + null: true, + description: 'Indicates whether the variable is masked.' + + field :raw, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is raw.' def environment_scope nil diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb index a6ab445702c..6346d50de3a 100644 --- a/app/graphql/types/ci/job_artifact_type.rb +++ b/app/graphql/types/ci/job_artifact_type.rb @@ -6,6 +6,9 @@ module Types class JobArtifactType < BaseObject graphql_name 'CiJobArtifact' + field :id, Types::GlobalIDType[::Ci::JobArtifact], null: false, + description: 'ID of the artifact.' + field :download_path, GraphQL::Types::String, null: true, description: "URL for downloading the artifact's file." @@ -16,6 +19,12 @@ module Types description: 'File name of the artifact.', method: :filename + field :size, GraphQL::Types::Int, null: false, + description: 'Size of the artifact in bytes.' + + field :expire_at, Types::TimeType, null: true, + description: 'Expiry date of the artifact.' + def download_path ::Gitlab::Routing.url_helpers.download_project_job_artifacts_path( object.project, diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 4ea9a016e74..ab6103d9469 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -92,6 +92,8 @@ module Types description: 'Indicates the job is stuck.' field :triggered, GraphQL::Types::Boolean, null: true, description: 'Whether the job was triggered.' + field :web_path, GraphQL::Types::String, null: true, + description: 'Web path of the job.' def kind return ::Ci::Build unless [::Ci::Build, ::Ci::Bridge].include?(object.class) @@ -181,6 +183,10 @@ module Types ::Gitlab::Routing.url_helpers.project_commits_path(object.project, ref_name) end + def web_path + ::Gitlab::Routing.url_helpers.project_job_path(object.project, object) + end + def coverage object&.coverage end diff --git a/app/graphql/types/ci/manual_variable_type.rb b/app/graphql/types/ci/manual_variable_type.rb index d6f59c1d249..ed92a6645b4 100644 --- a/app/graphql/types/ci/manual_variable_type.rb +++ b/app/graphql/types/ci/manual_variable_type.rb @@ -10,12 +10,12 @@ module Types implements(VariableInterface) field :environment_scope, GraphQL::Types::String, - null: true, - deprecated: { - reason: 'No longer used, only available for GroupVariableType and ProjectVariableType', - milestone: '15.3' - }, - description: 'Scope defining the environments that can use the variable.' + null: true, + deprecated: { + reason: 'No longer used, only available for GroupVariableType and ProjectVariableType', + milestone: '15.3' + }, + description: 'Scope defining the environments that can use the variable.' def environment_scope nil diff --git a/app/graphql/types/ci/project_variable_connection_type.rb b/app/graphql/types/ci/project_variable_connection_type.rb new file mode 100644 index 00000000000..c3cdc425f10 --- /dev/null +++ b/app/graphql/types/ci/project_variable_connection_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class ProjectVariableConnectionType < GraphQL::Types::Relay::BaseConnection + field :limit, GraphQL::Types::Int, + null: false, + description: 'Maximum amount of project CI/CD variables.' + + def limit + ::Plan.default.actual_limits.project_ci_variables + end + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/ci/project_variable_type.rb b/app/graphql/types/ci/project_variable_type.rb index 625bb7fd4b1..2a5375045e5 100644 --- a/app/graphql/types/ci/project_variable_type.rb +++ b/app/graphql/types/ci/project_variable_type.rb @@ -7,19 +7,20 @@ module Types graphql_name 'CiProjectVariable' description 'CI/CD variables for a project.' + connection_type_class(Types::Ci::ProjectVariableConnectionType) implements(VariableInterface) field :environment_scope, GraphQL::Types::String, - null: true, - description: 'Scope defining the environments that can use the variable.' + null: true, + description: 'Scope defining the environments that can use the variable.' field :protected, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is protected.' + null: true, + description: 'Indicates whether the variable is protected.' field :masked, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is masked.' + null: true, + description: 'Indicates whether the variable is masked.' end end end diff --git a/app/graphql/types/ci/runner_membership_filter_enum.rb b/app/graphql/types/ci/runner_membership_filter_enum.rb index 2e1051b2151..4fd7e0749b0 100644 --- a/app/graphql/types/ci/runner_membership_filter_enum.rb +++ b/app/graphql/types/ci/runner_membership_filter_enum.rb @@ -3,15 +3,17 @@ module Types module Ci class RunnerMembershipFilterEnum < BaseEnum - graphql_name 'RunnerMembershipFilter' - description 'Values for filtering runners in namespaces.' + graphql_name 'CiRunnerMembershipFilter' + description 'Values for filtering runners in namespaces. ' \ + 'The previous type name `RunnerMembershipFilter` was deprecated in 15.4.' value 'DIRECT', description: "Include runners that have a direct relationship.", value: :direct value 'DESCENDANTS', - description: "Include runners that have either a direct relationship or a relationship with descendants. These can be project runners or group runners (in the case where group is queried).", + description: "Include runners that have either a direct or inherited relationship. " \ + "These runners can be specific to a project or a group.", value: :descendants end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 0afb61d2b64..a9c76974850 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -52,7 +52,7 @@ module Types field :job_count, GraphQL::Types::Int, null: true, description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)." field :jobs, ::Types::Ci::JobType.connection_type, null: true, - description: 'Jobs assigned to the runner.', + description: 'Jobs assigned to the runner. This field can only be resolved for one runner in any single request.', authorize: :read_builds, resolver: ::Resolvers::Ci::RunnerJobsResolver field :locked, GraphQL::Types::Boolean, null: true, @@ -63,8 +63,11 @@ module Types description: 'Indicates the runner is paused and not available to run jobs.' field :project_count, GraphQL::Types::Int, null: true, description: 'Number of projects that the runner is associated with.' - field :projects, ::Types::ProjectType.connection_type, null: true, - description: 'Projects the runner is associated with. For project runners only.' + field :projects, + ::Types::ProjectType.connection_type, + null: true, + resolver: ::Resolvers::Ci::RunnerProjectsResolver, + description: 'Find projects the runner is associated with. For project runners only.' field :revision, GraphQL::Types::String, null: true, description: 'Revision of the runner.' field :run_untagged, GraphQL::Types::Boolean, null: false, @@ -131,12 +134,6 @@ module Types batched_owners(::Ci::RunnerNamespace, Group, :runner_groups, :namespace_id) end - def projects - return unless runner.project_type? - - batched_owners(::Ci::RunnerProject, Project, :runner_projects, :project_id) - end - private def can_admin_runners? @@ -159,19 +156,12 @@ module Types owner_ids = runner_owner_ids_by_runner_id.values.flatten.uniq owners = assoc_type.where(id: owner_ids).index_by(&:id) - # Preload projects namespaces to avoid N+1 queries when checking the `read_project` policy for each - preload_projects_namespaces(owners.values) if assoc_type == Project - runner_ids.each do |runner_id| loader.call(runner_id, runner_owner_ids_by_runner_id[runner_id]&.map { |owner_id| owners[owner_id] } || []) end end end # rubocop: enable CodeReuse/ActiveRecord - - def preload_projects_namespaces(_projects) - # overridden in EE - end end end end diff --git a/app/graphql/types/ci/variable_interface.rb b/app/graphql/types/ci/variable_interface.rb index 82c9ba7121c..ec68d3c987c 100644 --- a/app/graphql/types/ci/variable_interface.rb +++ b/app/graphql/types/ci/variable_interface.rb @@ -8,24 +8,24 @@ module Types graphql_name 'CiVariable' field :id, GraphQL::Types::ID, - null: false, - description: 'ID of the variable.' + null: false, + description: 'ID of the variable.' field :key, GraphQL::Types::String, - null: true, - description: 'Name of the variable.' + null: true, + description: 'Name of the variable.' + + field :raw, GraphQL::Types::Boolean, + null: true, + description: 'Indicates whether the variable is raw.' field :value, GraphQL::Types::String, - null: true, - description: 'Value of the variable.' + null: true, + description: 'Value of the variable.' field :variable_type, ::Types::Ci::VariableTypeEnum, - null: true, - description: 'Type of the variable.' - - field :raw, GraphQL::Types::Boolean, - null: true, - description: 'Indicates whether the variable is raw.' + null: true, + description: 'Type of the variable.' end end end diff --git a/app/graphql/types/clusters/agent_type.rb b/app/graphql/types/clusters/agent_type.rb index 546252b2285..5d7b8815cde 100644 --- a/app/graphql/types/clusters/agent_type.rb +++ b/app/graphql/types/clusters/agent_type.rb @@ -71,3 +71,5 @@ module Types end end end + +Types::Clusters::AgentType.prepend_mod diff --git a/app/graphql/types/customer_relations/contact_sort_enum.rb b/app/graphql/types/customer_relations/contact_sort_enum.rb index 221dedacb6a..bb11d741368 100644 --- a/app/graphql/types/customer_relations/contact_sort_enum.rb +++ b/app/graphql/types/customer_relations/contact_sort_enum.rb @@ -11,10 +11,10 @@ module Types sortable_fields.each do |field| value "#{field.upcase.tr(' ', '_')}_ASC", value: { field: field.downcase.tr(' ', '_'), direction: :asc }, - description: "#{field} by ascending order." + description: "#{field} in ascending order." value "#{field.upcase.tr(' ', '_')}_DESC", value: { field: field.downcase.tr(' ', '_'), direction: :desc }, - description: "#{field} by descending order." + description: "#{field} in descending order." end end end diff --git a/app/graphql/types/customer_relations/organization_sort_enum.rb b/app/graphql/types/customer_relations/organization_sort_enum.rb new file mode 100644 index 00000000000..742a5a4fa99 --- /dev/null +++ b/app/graphql/types/customer_relations/organization_sort_enum.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module CustomerRelations + class OrganizationSortEnum < SortEnum + graphql_name 'OrganizationSort' + description 'Values for sorting organizations' + + sortable_fields = ['Name', 'Description', 'Default Rate'] + + sortable_fields.each do |field| + value "#{field.upcase.tr(' ', '_')}_ASC", + value: { field: field.downcase.tr(' ', '_'), direction: :asc }, + description: "#{field} in ascending order." + value "#{field.upcase.tr(' ', '_')}_DESC", + value: { field: field.downcase.tr(' ', '_'), direction: :desc }, + description: "#{field} in descending order." + end + end + end +end diff --git a/app/graphql/types/customer_relations/organization_state_counts_type.rb b/app/graphql/types/customer_relations/organization_state_counts_type.rb new file mode 100644 index 00000000000..7d813209a8e --- /dev/null +++ b/app/graphql/types/customer_relations/organization_state_counts_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module CustomerRelations + # `object` is a hash. Authorization is performed by OrganizationStateCountsResolver + class OrganizationStateCountsType < Types::BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'OrganizationStateCounts' + description 'Represents the total number of organizations for the represented states.' + + AVAILABLE_STATES = ::CustomerRelations::Organization.states.keys.push('all').freeze + + AVAILABLE_STATES.each do |state| + field state, + GraphQL::Types::Int, + null: true, + description: "Number of organizations with state `#{state.upcase}`" + end + + def all + object.values.sum + end + end + end +end diff --git a/app/graphql/types/customer_relations/organization_state_enum.rb b/app/graphql/types/customer_relations/organization_state_enum.rb index ecdd7d092ad..84bbbbc90fc 100644 --- a/app/graphql/types/customer_relations/organization_state_enum.rb +++ b/app/graphql/types/customer_relations/organization_state_enum.rb @@ -5,12 +5,16 @@ module Types class OrganizationStateEnum < BaseEnum graphql_name 'CustomerRelationsOrganizationState' + value 'all', + description: "All available organizations.", + value: :all + value 'active', - description: "Active organization.", + description: "Active organizations.", value: :active value 'inactive', - description: "Inactive organization.", + description: "Inactive organizations.", value: :inactive end end diff --git a/app/graphql/types/deployment_details_type.rb b/app/graphql/types/deployment_details_type.rb new file mode 100644 index 00000000000..f8ba0cb1b24 --- /dev/null +++ b/app/graphql/types/deployment_details_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + class DeploymentDetailsType < DeploymentType + graphql_name 'DeploymentDetails' + description 'The details of the deployment' + authorize :read_deployment + present_using Deployments::DeploymentPresenter + + field :tags, + [Types::DeploymentTagType], + description: 'Git tags that contain this deployment.', + calls_gitaly: true + end +end diff --git a/app/graphql/types/deployment_status_enum.rb b/app/graphql/types/deployment_status_enum.rb new file mode 100644 index 00000000000..7ef69d3f1c1 --- /dev/null +++ b/app/graphql/types/deployment_status_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class DeploymentStatusEnum < BaseEnum + graphql_name 'DeploymentStatus' + description 'All deployment statuses.' + + ::Deployment.statuses.each_key do |status| + value status.upcase, + description: "A deployment that is #{status.tr('_', ' ')}.", + value: status + end + end +end diff --git a/app/graphql/types/deployment_tag_type.rb b/app/graphql/types/deployment_tag_type.rb new file mode 100644 index 00000000000..bc3597404a2 --- /dev/null +++ b/app/graphql/types/deployment_tag_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + # DeploymentTagType is a hash, authorized by the deployment + # rubocop:disable Graphql/AuthorizeTypes + class DeploymentTagType < BaseObject + graphql_name 'DeploymentTag' + description 'Tags for a given deployment' + + field :name, + GraphQL::Types::String, + description: 'Name of this git tag.' + + field :path, + GraphQL::Types::String, + description: 'Path for this tag.', + hash_key: :path + end + # rubocop:enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/deployment_type.rb b/app/graphql/types/deployment_type.rb new file mode 100644 index 00000000000..70a3a4cb574 --- /dev/null +++ b/app/graphql/types/deployment_type.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Types + # If you're considering to add a new field in DeploymentType, please follow this guideline: + # - If the field is preloadable in batch, define it in DeploymentType. + # In this case, you should extend DeploymentsResolver logic to preload the field. Also, add a new test that + # fetching the specific field for multiple deployments doesn't cause N+1 query problem. + # - If the field is NOT preloadable in batch, define it in DeploymentDetailsType. + # This type can be only fetched for a single deployment, so you don't need to take care of the preloading. + class DeploymentType < BaseObject + graphql_name 'Deployment' + description 'The deployment of an environment' + + present_using Deployments::DeploymentPresenter + + authorize :read_deployment + + field :id, + GraphQL::Types::ID, + description: 'Global ID of the deployment.' + + field :iid, + GraphQL::Types::ID, + description: 'Project-level internal ID of the deployment.' + + field :ref, + GraphQL::Types::String, + description: 'Git-Ref that the deployment ran on.' + + field :tag, + GraphQL::Types::Boolean, + description: 'True or false if the deployment ran on a Git-tag.' + + field :sha, + GraphQL::Types::String, + description: 'Git-SHA that the deployment ran on.' + + field :created_at, + Types::TimeType, + description: 'When the deployment record was created.' + + field :updated_at, + Types::TimeType, + description: 'When the deployment record was updated.' + + field :finished_at, + Types::TimeType, + description: 'When the deployment finished.' + + field :status, + Types::DeploymentStatusEnum, + description: 'Status of the deployment.' + + field :commit, + Types::CommitType, + description: 'Commit details of the deployment.', + calls_gitaly: true + + field :job, + Types::Ci::JobType, + description: 'Pipeline job of the deployment.', + method: :build + + field :triggerer, + Types::UserType, + description: 'User who executed the deployment.', + method: :deployed_by + end +end diff --git a/app/graphql/types/deployments_order_by_input_type.rb b/app/graphql/types/deployments_order_by_input_type.rb new file mode 100644 index 00000000000..a87fef9fe8a --- /dev/null +++ b/app/graphql/types/deployments_order_by_input_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class DeploymentsOrderByInputType < BaseInputObject + graphql_name 'DeploymentsOrderByInput' + description 'Values for ordering deployments by a specific field' + + argument :created_at, + Types::SortDirectionEnum, + required: false, + description: 'Order by Created time.' + + argument :finished_at, + Types::SortDirectionEnum, + required: false, + description: 'Order by Finished time.' + + def prepare + raise GraphQL::ExecutionError, 'orderBy parameter must contain one key-value pair.' unless to_h.size == 1 + + super + end + end +end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index 2a7076cc3c9..eb4e7b1dabf 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -21,6 +21,30 @@ module Types field :path, GraphQL::Types::String, null: false, description: 'Path to the environment.' + field :slug, GraphQL::Types::String, + description: 'Slug of the environment.' + + field :external_url, GraphQL::Types::String, null: true, + description: 'External URL of the environment.' + + field :created_at, Types::TimeType, + description: 'When the environment was created.' + + field :updated_at, Types::TimeType, + description: 'When the environment was updated.' + + field :auto_stop_at, Types::TimeType, + description: 'When the environment is going to be stopped automatically.' + + field :auto_delete_at, Types::TimeType, + description: 'When the environment is going to be deleted automatically.' + + field :tier, Types::DeploymentTierEnum, + description: 'Deployment tier of the environment.' + + field :environment_type, GraphQL::Types::String, + description: 'Folder name of the environment.' + field :metrics_dashboard, Types::Metrics::DashboardType, null: true, description: 'Metrics dashboard schema for the environment.', resolver: Resolvers::Metrics::DashboardResolver @@ -29,5 +53,22 @@ module Types Types::AlertManagement::AlertType, null: true, description: 'Most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.' + + field :deployments, + Types::DeploymentType.connection_type, + null: true, + description: 'Deployments of the environment. This field can only be resolved for one project in any single request.', + resolver: Resolvers::DeploymentsResolver do + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + end + + field :last_deployment, + Types::DeploymentType, + description: 'Last deployment of the environment.', + resolver: Resolvers::Environments::LastDeploymentResolver + + def tier + object.tier.to_sym + end end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 235a2bc2a34..45357de5502 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -22,7 +22,7 @@ module Types type: Types::CustomEmojiType.connection_type, null: true, description: 'Custom emoji within this namespace.', - _deprecated_feature_flag: :custom_emoji + alpha: { milestone: '13.6' } field :share_with_group_lock, type: GraphQL::Types::Boolean, @@ -134,7 +134,7 @@ module Types description: 'Number of container repositories in the group.' field :packages, - description: 'Packages of the group.', + description: 'Packages of the group. This field can only be resolved for one group in any single request.', resolver: Resolvers::GroupPackagesResolver field :dependency_proxy_setting, @@ -212,6 +212,12 @@ module Types description: "Find organizations of this group.", resolver: Resolvers::Crm::OrganizationsResolver + field :organization_state_counts, + Types::CustomerRelations::OrganizationStateCountsType, + null: true, + description: 'Counts of organizations by status for the group.', + resolver: Resolvers::Crm::OrganizationStateCountsResolver + field :contacts, Types::CustomerRelations::ContactType.connection_type, null: true, description: "Find contacts of this group.", @@ -272,6 +278,10 @@ module Types group.dependency_proxy_setting || group.create_dependency_proxy_setting end + def custom_emoji + object.custom_emoji if Feature.enabled?(:custom_emoji) + end + private def group diff --git a/app/graphql/types/member_sort_enum.rb b/app/graphql/types/member_sort_enum.rb new file mode 100644 index 00000000000..f3291dda13b --- /dev/null +++ b/app/graphql/types/member_sort_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + class MemberSortEnum < SortEnum + graphql_name 'MemberSort' + description 'Values for sorting members' + + value 'ACCESS_LEVEL_ASC', 'Access level ascending order.', value: :access_level_asc + value 'ACCESS_LEVEL_DESC', 'Access level descending order.', value: :access_level_desc + value 'USER_FULL_NAME_ASC', "User's full name ascending order.", value: :name_asc + value 'USER_FULL_NAME_DESC', "User's full name descending order.", value: :name_desc + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index d88653f2f8c..399dcc8e03d 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -94,9 +94,10 @@ module Types method: :public_merge_status, null: true, description: 'Merge status of the merge request.' - field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, method: :detailed_merge_status, null: true, + field :detailed_merge_status, ::Types::MergeRequests::DetailedMergeStatusEnum, null: true, calls_gitaly: true, - description: 'Detailed merge status of the merge request.', alpha: { milestone: '15.3' } + description: 'Detailed merge status of the merge request.', + alpha: { milestone: '15.3' } field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, calls_gitaly: true, @@ -280,6 +281,10 @@ module Types def merge_user object.metrics&.merged_by || object.merge_user end + + def detailed_merge_status + ::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute + end end end diff --git a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb index 58104159303..3de6296154d 100644 --- a/app/graphql/types/merge_requests/detailed_merge_status_enum.rb +++ b/app/graphql/types/merge_requests/detailed_merge_status_enum.rb @@ -21,6 +21,9 @@ module Types value 'CI_MUST_PASS', value: :ci_must_pass, description: 'Pipeline must succeed before merging.' + value 'CI_STILL_RUNNING', + value: :ci_still_running, + description: 'Pipeline is still running.' value 'DISCUSSIONS_NOT_RESOLVED', value: :discussions_not_resolved, description: 'Discussions must be resolved before merging.' diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 499c2e786bf..ea833b35085 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -37,8 +37,8 @@ module Types mount_mutation Mutations::Clusters::AgentTokens::Create mount_mutation Mutations::Clusters::AgentTokens::Revoke mount_mutation Mutations::Commits::Create, calls_gitaly: true - mount_mutation Mutations::CustomEmoji::Create, _deprecated_feature_flag: :custom_emoji - mount_mutation Mutations::CustomEmoji::Destroy, _deprecated_feature_flag: :custom_emoji + mount_mutation Mutations::CustomEmoji::Create, alpha: { milestone: '13.6' } + mount_mutation Mutations::CustomEmoji::Destroy, alpha: { milestone: '13.6' } mount_mutation Mutations::CustomerRelations::Contacts::Create mount_mutation Mutations::CustomerRelations::Contacts::Update mount_mutation Mutations::CustomerRelations::Organizations::Create @@ -120,10 +120,12 @@ module Types milestone: '15.0' } mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate + mount_mutation Mutations::Ci::Job::ArtifactsDestroy mount_mutation Mutations::Ci::Job::Play mount_mutation Mutations::Ci::Job::Retry mount_mutation Mutations::Ci::Job::Cancel mount_mutation Mutations::Ci::Job::Unschedule + mount_mutation Mutations::Ci::JobArtifact::Destroy mount_mutation Mutations::Ci::JobTokenScope::AddProject mount_mutation Mutations::Ci::JobTokenScope::RemoveProject mount_mutation Mutations::Ci::Runner::Update diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb index 0413177ef14..6c0d955ed77 100644 --- a/app/graphql/types/packages/package_details_type.rb +++ b/app/graphql/types/packages/package_details_type.rb @@ -26,6 +26,8 @@ module Types field :pypi_setup_url, GraphQL::Types::String, null: true, description: 'Url of the PyPi project setup endpoint.' field :pypi_url, GraphQL::Types::String, null: true, description: 'Url of the PyPi project endpoint.' + field :last_downloaded_at, Types::TimeType, null: true, description: 'Last time that a file of this package was downloaded.' + def versions object.versions end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index ecc6c9d7811..f43f5c27dac 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -10,119 +10,204 @@ module Types expose_permissions Types::PermissionTypes::Project - field :id, GraphQL::Types::ID, null: false, - description: 'ID of the project.' - - field :ci_config_path_or_default, GraphQL::Types::String, null: false, - description: 'Path of the CI configuration file.' - field :full_path, GraphQL::Types::ID, null: false, - description: 'Full path of the project.' - field :path, GraphQL::Types::String, null: false, - description: 'Path of the project.' - - field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, null: true, - calls_gitaly: true, - description: 'SAST CI configuration for the project.' - - field :name, GraphQL::Types::String, null: false, - description: 'Name of the project (without namespace).' - field :name_with_namespace, GraphQL::Types::String, null: false, - description: 'Full name of the project with its namespace.' - - field :description, GraphQL::Types::String, null: true, - description: 'Short description of the project.' - - field :tag_list, GraphQL::Types::String, null: true, - deprecated: { reason: 'Use `topics`', milestone: '13.12' }, - description: 'List of project topics (not Git tags).', method: :topic_list - - field :topics, [GraphQL::Types::String], null: true, - description: 'List of project topics.', method: :topic_list - - field :http_url_to_repo, GraphQL::Types::String, null: true, - description: 'URL to connect to the project via HTTPS.' - field :ssh_url_to_repo, GraphQL::Types::String, null: true, - description: 'URL to connect to the project via SSH.' - field :web_url, GraphQL::Types::String, null: true, - description: 'Web URL of the project.' - - field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, # 4 times - description: 'Number of times the project has been forked.' - field :star_count, GraphQL::Types::Int, null: false, - description: 'Number of times the project has been starred.' - - field :created_at, Types::TimeType, null: true, - description: 'Timestamp of the project creation.' - field :last_activity_at, Types::TimeType, null: true, - description: 'Timestamp of the project last activity.' - - field :archived, GraphQL::Types::Boolean, null: true, - description: 'Indicates the archived status of the project.' - - field :visibility, GraphQL::Types::String, null: true, - description: 'Visibility of the project.' - - field :lfs_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the project has Large File Storage (LFS) enabled.' - field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' - field :shared_runners_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if shared runners are enabled for the project.' - - field :service_desk_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if the project has Service Desk enabled.' - - field :service_desk_address, GraphQL::Types::String, null: true, - description: 'E-mail address of the Service Desk.' - - field :avatar_url, GraphQL::Types::String, null: true, calls_gitaly: true, - description: 'URL to avatar image file of the project.' - - field :jobs_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' - - field :public_jobs, GraphQL::Types::Boolean, method: :public_builds, null: true, - description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts.' - - field :open_issues_count, GraphQL::Types::Int, null: true, - description: 'Number of open issues for the project.' - - field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, null: true, - description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of the project can also be merged with skipped jobs.' - field :autoclose_referenced_issues, GraphQL::Types::Boolean, null: true, - description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.' - field :import_status, GraphQL::Types::String, null: true, - description: 'Status of import background job of the project.' - field :jira_import_status, GraphQL::Types::String, null: true, - description: 'Status of Jira import background job of the project.' - field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, null: true, - description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' - field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, null: true, - description: 'Indicates if merge requests of the project can only be merged with successful jobs.' - field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line.' - field :remove_source_branch_after_merge, GraphQL::Types::Boolean, null: true, - description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project.' - field :request_access_enabled, GraphQL::Types::Boolean, null: true, - description: 'Indicates if users can request member access to the project.' - field :squash_read_only, GraphQL::Types::Boolean, null: false, method: :squash_readonly?, - description: 'Indicates if `squashReadOnly` is enabled.' - field :suggestion_commit_message, GraphQL::Types::String, null: true, - description: 'Commit message used to apply merge request suggestions.' + field :id, GraphQL::Types::ID, + null: false, + description: 'ID of the project.' + + field :ci_config_path_or_default, GraphQL::Types::String, + null: false, + description: 'Path of the CI configuration file.' + + field :ci_config_variables, [Types::Ci::ConfigVariableType], + null: true, + calls_gitaly: true, + authorize: :create_pipeline, + alpha: { milestone: '15.3' }, + description: 'CI/CD config variable.' do + argument :sha, GraphQL::Types::String, + required: true, + description: 'Sha.' + end + + field :full_path, GraphQL::Types::ID, + null: false, + description: 'Full path of the project.' + + field :path, GraphQL::Types::String, + null: false, + description: 'Path of the project.' + + field :sast_ci_configuration, Types::CiConfiguration::Sast::Type, + null: true, + calls_gitaly: true, + description: 'SAST CI configuration for the project.' + + field :name, GraphQL::Types::String, + null: false, + description: 'Name of the project (without namespace).' + + field :name_with_namespace, GraphQL::Types::String, + null: false, + description: 'Full name of the project with its namespace.' + + field :description, GraphQL::Types::String, + null: true, + description: 'Short description of the project.' + + field :tag_list, GraphQL::Types::String, + null: true, + deprecated: { reason: 'Use `topics`', milestone: '13.12' }, + description: 'List of project topics (not Git tags).', + method: :topic_list + + field :topics, [GraphQL::Types::String], + null: true, + description: 'List of project topics.', + method: :topic_list + + field :http_url_to_repo, GraphQL::Types::String, + null: true, + description: 'URL to connect to the project via HTTPS.' + + field :ssh_url_to_repo, GraphQL::Types::String, + null: true, + description: 'URL to connect to the project via SSH.' + + field :web_url, GraphQL::Types::String, + null: true, + description: 'Web URL of the project.' + + field :forks_count, GraphQL::Types::Int, + null: false, + calls_gitaly: true, # 4 times + description: 'Number of times the project has been forked.' + + field :star_count, GraphQL::Types::Int, + null: false, + description: 'Number of times the project has been starred.' + + field :created_at, Types::TimeType, + null: true, + description: 'Timestamp of the project creation.' + + field :last_activity_at, Types::TimeType, + null: true, + description: 'Timestamp of the project last activity.' + + field :archived, GraphQL::Types::Boolean, + null: true, + description: 'Indicates the archived status of the project.' + + field :visibility, GraphQL::Types::String, + null: true, + description: 'Visibility of the project.' + + field :lfs_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project has Large File Storage (LFS) enabled.' + + field :merge_requests_ff_only_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if no merge commits should be created and all merges should instead be ' \ + 'fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + + field :shared_runners_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if shared runners are enabled for the project.' + + field :service_desk_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if the project has Service Desk enabled.' + + field :service_desk_address, GraphQL::Types::String, + null: true, + description: 'E-mail address of the Service Desk.' + + field :avatar_url, GraphQL::Types::String, + null: true, + calls_gitaly: true, + description: 'URL to avatar image file of the project.' + + field :jobs_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if CI/CD pipeline jobs are enabled for the current user.' + + field :public_jobs, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if there is public access to pipelines and job details of the project, ' \ + 'including output logs and artifacts.', + method: :public_builds + + field :open_issues_count, GraphQL::Types::Int, + null: true, + description: 'Number of open issues for the project.' + + field :allow_merge_on_skipped_pipeline, GraphQL::Types::Boolean, + null: true, + description: 'If `only_allow_merge_if_pipeline_succeeds` is true, indicates if merge requests of ' \ + 'the project can also be merged with skipped jobs.' + + field :autoclose_referenced_issues, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if issues referenced by merge requests and commits within the default branch ' \ + 'are closed automatically.' + + field :import_status, GraphQL::Types::String, + null: true, + description: 'Status of import background job of the project.' + + field :jira_import_status, GraphQL::Types::String, + null: true, + description: 'Status of Jira import background job of the project.' + + field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.' + + field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if merge requests of the project can only be merged with successful jobs.' + + field :printing_merge_request_link_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if a link to create or view a merge request should display after a push to Git ' \ + 'repositories of the project from the command line.' + + field :remove_source_branch_after_merge, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if `Delete source branch` option should be enabled by default for all ' \ + 'new merge requests of the project.' + + field :request_access_enabled, GraphQL::Types::Boolean, + null: true, + description: 'Indicates if users can request member access to the project.' + + field :squash_read_only, GraphQL::Types::Boolean, + null: false, + description: 'Indicates if `squashReadOnly` is enabled.', + method: :squash_readonly? + + field :suggestion_commit_message, GraphQL::Types::String, + null: true, + description: 'Commit message used to apply merge request suggestions.' # No, the quotes are not a typo. Used to get around circular dependencies. # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536#note_871009675 - field :group, 'Types::GroupType', null: true, - description: 'Group of the project.' - field :namespace, Types::NamespaceType, null: true, - description: 'Namespace of the project.' + field :group, 'Types::GroupType', + null: true, + description: 'Group of the project.' + + field :namespace, Types::NamespaceType, + null: true, + description: 'Namespace of the project.' field :statistics, Types::ProjectStatisticsType, null: true, description: 'Statistics of the project.' - field :repository, Types::RepositoryType, null: true, - description: 'Git repository of the project.' + field :repository, Types::RepositoryType, + null: true, + description: 'Git repository of the project.' field :merge_requests, Types::MergeRequestType.connection_type, @@ -159,9 +244,10 @@ module Types extras: [:lookahead], resolver: Resolvers::IssueStatusCountsResolver - field :milestones, Types::MilestoneType.connection_type, null: true, - description: 'Milestones of the project.', - resolver: Resolvers::ProjectMilestonesResolver + field :milestones, Types::MilestoneType.connection_type, + null: true, + description: 'Milestones of the project.', + resolver: Resolvers::ProjectMilestonesResolver field :project_members, description: 'Members of the project.', @@ -179,6 +265,12 @@ module Types description: 'A single environment of the project.', resolver: Resolvers::EnvironmentsResolver.single + field :deployment, + Types::DeploymentDetailsType, + null: true, + description: 'Details of the deployment of the project.', + resolver: Resolvers::DeploymentResolver.single + field :issue, Types::IssueType, null: true, @@ -201,164 +293,150 @@ module Types description: 'Jobs of a project. This field can only be resolved for one project in any single request.', resolver: Resolvers::ProjectJobsResolver + field :job, + type: Types::Ci::JobType, + null: true, + authorize: :read_build, + description: 'One job belonging to the project, selected by ID.' do + argument :id, Types::GlobalIDType[::CommitStatus], + required: true, + description: 'ID of the job.' + end + field :pipelines, null: true, description: 'Build pipelines of the project.', extras: [:lookahead], resolver: Resolvers::ProjectPipelinesResolver - field :pipeline, - Types::Ci::PipelineType, + field :pipeline, Types::Ci::PipelineType, null: true, description: 'Build pipeline of the project.', extras: [:lookahead], resolver: Resolvers::ProjectPipelineResolver - field :pipeline_counts, - Types::Ci::PipelineCountsType, + field :pipeline_counts, Types::Ci::PipelineCountsType, null: true, description: 'Build pipeline counts of the project.', resolver: Resolvers::Ci::ProjectPipelineCountsResolver - field :ci_variables, - Types::Ci::ProjectVariableType.connection_type, + field :ci_variables, Types::Ci::ProjectVariableType.connection_type, null: true, description: "List of the project's CI/CD variables.", authorize: :admin_build, method: :variables - field :ci_cd_settings, - Types::Ci::CiCdSettingType, + field :ci_cd_settings, Types::Ci::CiCdSettingType, null: true, description: 'CI/CD settings for the project.' - field :sentry_detailed_error, - Types::ErrorTracking::SentryDetailedErrorType, + field :sentry_detailed_error, Types::ErrorTracking::SentryDetailedErrorType, null: true, description: 'Detailed version of a Sentry error on the project.', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver - field :grafana_integration, - Types::GrafanaIntegrationType, + field :grafana_integration, Types::GrafanaIntegrationType, null: true, description: 'Grafana integration details for the project.', resolver: Resolvers::Projects::GrafanaIntegrationResolver - field :snippets, - Types::SnippetType.connection_type, + field :snippets, Types::SnippetType.connection_type, null: true, description: 'Snippets of the project.', resolver: Resolvers::Projects::SnippetsResolver - field :sentry_errors, - Types::ErrorTracking::SentryErrorCollectionType, + field :sentry_errors, Types::ErrorTracking::SentryErrorCollectionType, null: true, description: 'Paginated collection of Sentry errors on the project.', resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver - field :boards, - Types::BoardType.connection_type, + field :boards, Types::BoardType.connection_type, null: true, description: 'Boards of the project.', max_page_size: 2000, resolver: Resolvers::BoardsResolver - field :recent_issue_boards, - Types::BoardType.connection_type, + field :recent_issue_boards, Types::BoardType.connection_type, null: true, description: 'List of recently visited boards of the project. Maximum size is 4.', resolver: Resolvers::RecentBoardsResolver - field :board, - Types::BoardType, + field :board, Types::BoardType, null: true, description: 'A single board of the project.', resolver: Resolvers::BoardResolver - field :jira_imports, - Types::JiraImportType.connection_type, + field :jira_imports, Types::JiraImportType.connection_type, null: true, description: 'Jira imports into the project.' - field :services, - Types::Projects::ServiceType.connection_type, + field :services, Types::Projects::ServiceType.connection_type, null: true, description: 'Project services.', resolver: Resolvers::Projects::ServicesResolver - field :alert_management_alerts, - Types::AlertManagement::AlertType.connection_type, + field :alert_management_alerts, Types::AlertManagement::AlertType.connection_type, null: true, description: 'Alert Management alerts of the project.', extras: [:lookahead], resolver: Resolvers::AlertManagement::AlertResolver - field :alert_management_alert, - Types::AlertManagement::AlertType, + field :alert_management_alert, Types::AlertManagement::AlertType, null: true, description: 'A single Alert Management alert of the project.', resolver: Resolvers::AlertManagement::AlertResolver.single - field :alert_management_alert_status_counts, - Types::AlertManagement::AlertStatusCountsType, + field :alert_management_alert_status_counts, Types::AlertManagement::AlertStatusCountsType, null: true, description: 'Counts of alerts by status for the project.', resolver: Resolvers::AlertManagement::AlertStatusCountsResolver - field :alert_management_integrations, - Types::AlertManagement::IntegrationType.connection_type, + field :alert_management_integrations, Types::AlertManagement::IntegrationType.connection_type, null: true, description: 'Integrations which can receive alerts for the project.', resolver: Resolvers::AlertManagement::IntegrationsResolver - field :alert_management_http_integrations, - Types::AlertManagement::HttpIntegrationType.connection_type, + field :alert_management_http_integrations, Types::AlertManagement::HttpIntegrationType.connection_type, null: true, description: 'HTTP Integrations which can receive alerts for the project.', resolver: Resolvers::AlertManagement::HttpIntegrationsResolver - field :incident_management_timeline_events, - Types::IncidentManagement::TimelineEventType.connection_type, + field :incident_management_timeline_events, Types::IncidentManagement::TimelineEventType.connection_type, null: true, description: 'Incident Management Timeline events associated with the incident.', extras: [:lookahead], resolver: Resolvers::IncidentManagement::TimelineEventsResolver - field :incident_management_timeline_event, - Types::IncidentManagement::TimelineEventType, + field :incident_management_timeline_event, Types::IncidentManagement::TimelineEventType, null: true, description: 'Incident Management Timeline event associated with the incident.', resolver: Resolvers::IncidentManagement::TimelineEventsResolver.single - field :releases, - Types::ReleaseType.connection_type, + field :releases, Types::ReleaseType.connection_type, null: true, description: 'Releases of the project.', resolver: Resolvers::ReleasesResolver - field :release, - Types::ReleaseType, + field :release, Types::ReleaseType, null: true, description: 'A single release of the project.', resolver: Resolvers::ReleasesResolver.single, authorize: :read_release - field :container_expiration_policy, - Types::ContainerExpirationPolicyType, + field :container_expiration_policy, Types::ContainerExpirationPolicyType, null: true, description: 'Container expiration policy of the project.' - field :container_repositories, - Types::ContainerRepositoryType.connection_type, + field :container_repositories, Types::ContainerRepositoryType.connection_type, null: true, description: 'Container repositories of the project.', resolver: Resolvers::ContainerRepositoriesResolver - field :container_repositories_count, GraphQL::Types::Int, null: false, - description: 'Number of container repositories in the project.' + field :container_repositories_count, GraphQL::Types::Int, + null: false, + description: 'Number of container repositories in the project.' - field :label, - Types::LabelType, + field :label, Types::LabelType, null: true, description: 'Label available on this project.' do argument :title, GraphQL::Types::String, @@ -366,68 +444,63 @@ module Types description: 'Title of the label.' end - field :terraform_state, - Types::Terraform::StateType, + field :terraform_state, Types::Terraform::StateType, null: true, description: 'Find a single Terraform state by name.', resolver: Resolvers::Terraform::StatesResolver.single - field :terraform_states, - Types::Terraform::StateType.connection_type, + field :terraform_states, Types::Terraform::StateType.connection_type, null: true, description: 'Terraform states associated with the project.', resolver: Resolvers::Terraform::StatesResolver - field :pipeline_analytics, Types::Ci::AnalyticsType, null: true, - description: 'Pipeline analytics.', - resolver: Resolvers::ProjectPipelineStatisticsResolver + field :pipeline_analytics, Types::Ci::AnalyticsType, + null: true, + description: 'Pipeline analytics.', + resolver: Resolvers::ProjectPipelineStatisticsResolver - field :ci_template, Types::Ci::TemplateType, null: true, - description: 'Find a single CI/CD template by name.', - resolver: Resolvers::Ci::TemplateResolver + field :ci_template, Types::Ci::TemplateType, + null: true, + description: 'Find a single CI/CD template by name.', + resolver: Resolvers::Ci::TemplateResolver - field :ci_job_token_scope, Types::Ci::JobTokenScopeType, null: true, - description: 'The CI Job Tokens scope of access.', - resolver: Resolvers::Ci::JobTokenScopeResolver + field :ci_job_token_scope, Types::Ci::JobTokenScopeType, + null: true, + description: 'The CI Job Tokens scope of access.', + resolver: Resolvers::Ci::JobTokenScopeResolver - field :timelogs, - Types::TimelogType.connection_type, null: true, - description: 'Time logged on issues and merge requests in the project.', - extras: [:lookahead], - complexity: 5, - resolver: ::Resolvers::TimelogResolver + field :timelogs, Types::TimelogType.connection_type, + null: true, + description: 'Time logged on issues and merge requests in the project.', + extras: [:lookahead], + complexity: 5, + resolver: ::Resolvers::TimelogResolver - field :agent_configurations, - ::Types::Kas::AgentConfigurationType.connection_type, + field :agent_configurations, ::Types::Kas::AgentConfigurationType.connection_type, null: true, description: 'Agent configurations defined by the project', resolver: ::Resolvers::Kas::AgentConfigurationsResolver - field :cluster_agent, - ::Types::Clusters::AgentType, + field :cluster_agent, ::Types::Clusters::AgentType, null: true, description: 'Find a single cluster agent by name.', resolver: ::Resolvers::Clusters::AgentsResolver.single - field :cluster_agents, - ::Types::Clusters::AgentType.connection_type, + field :cluster_agents, ::Types::Clusters::AgentType.connection_type, extras: [:lookahead], null: true, description: 'Cluster agents associated with the project.', resolver: ::Resolvers::Clusters::AgentsResolver - field :merge_commit_template, - GraphQL::Types::String, + field :merge_commit_template, GraphQL::Types::String, null: true, description: 'Template used to create merge commit message in merge requests.' - field :squash_commit_template, - GraphQL::Types::String, + field :squash_commit_template, GraphQL::Types::String, null: true, description: 'Template used to create squash commit message in merge requests.' - field :labels, - Types::LabelType.connection_type, + field :labels, Types::LabelType.connection_type, null: true, description: 'Labels available on this project.', resolver: Resolvers::LabelsResolver @@ -438,8 +511,7 @@ module Types ' Returns `null` if `work_items` feature flag is disabled.' \ ' This flag is disabled by default, because the feature is experimental and is subject to change without notice.' - field :timelog_categories, - Types::TimeTracking::TimelogCategoryType.connection_type, + field :timelog_categories, Types::TimeTracking::TimelogCategoryType.connection_type, null: true, description: "Timelog categories for the project.", alpha: { milestone: '15.3' } @@ -448,6 +520,12 @@ module Types resolver: Resolvers::Projects::ForkTargetsResolver, description: 'Namespaces in which the current user can fork the project into.' + field :branch_rules, + Types::Projects::BranchRuleType.connection_type, + null: true, + description: "Branch rules configured for the project.", + resolver: Resolvers::Projects::BranchRulesResolver + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end @@ -498,6 +576,21 @@ module Types project.container_repositories.size end + def ci_config_variables(sha:) + result = ::Ci::ListConfigVariablesService.new(object, context[:current_user]).execute(sha) + + return if result.nil? + + result.map do |var_key, var_config| + { key: var_key, **var_config } + end + end + + def job(id:) + object.commit_statuses.find(id.model_id) + rescue ActiveRecord::RecordNotFound + end + def sast_ci_configuration return unless Ability.allowed?(current_user, :download_code, object) diff --git a/app/graphql/types/projects/branch_rule_type.rb b/app/graphql/types/projects/branch_rule_type.rb new file mode 100644 index 00000000000..866cff0f439 --- /dev/null +++ b/app/graphql/types/projects/branch_rule_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Projects + class BranchRuleType < BaseObject + graphql_name 'BranchRule' + description 'List of branch rules for a project, grouped by branch name.' + accepts ::ProtectedBranch + authorize :read_protected_branch + + field :name, + type: GraphQL::Types::String, + null: false, + description: 'Branch name, with wildcards, for the branch rules.' + + field :branch_protection, + type: Types::BranchRules::BranchProtectionType, + null: false, + description: 'Branch protections configured for this branch rule.', + method: :itself + + field :created_at, + Types::TimeType, + null: false, + description: 'Timestamp of when the branch rule was created.' + + field :updated_at, + Types::TimeType, + null: false, + description: 'Timestamp of when the branch rule was last updated.' + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 84355390ea0..78463a1804a 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -67,7 +67,7 @@ module Types end field :package, - description: 'Find a package.', + description: 'Find a package. This field can only be resolved for one query in any single request.', resolver: Resolvers::PackageDetailsResolver field :user, Types::UserType, diff --git a/app/graphql/types/sort_direction_enum.rb b/app/graphql/types/sort_direction_enum.rb new file mode 100644 index 00000000000..28dba1abfb6 --- /dev/null +++ b/app/graphql/types/sort_direction_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class SortDirectionEnum < BaseEnum + graphql_name 'SortDirectionEnum' + description 'Values for sort direction' + + value 'ASC', 'Ascending order.', value: 'asc' + value 'DESC', 'Descending order.', value: 'desc' + end +end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb index 9b5f028a857..ef701bbfc10 100644 --- a/app/graphql/types/subscription_type.rb +++ b/app/graphql/types/subscription_type.rb @@ -18,5 +18,12 @@ module Types field :issuable_dates_updated, subscription: Subscriptions::IssuableUpdated, null: true, description: 'Triggered when the due date or start date of an issuable is updated.' + + field :merge_request_reviewers_updated, + subscription: Subscriptions::IssuableUpdated, + null: true, + description: 'Triggered when the reviewers of a merge request are updated.' end end + +Types::SubscriptionType.prepend_mod diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb index c3fb9b77927..3856e1aa3b3 100644 --- a/app/graphql/types/timelog_type.rb +++ b/app/graphql/types/timelog_type.rb @@ -4,7 +4,7 @@ module Types class TimelogType < BaseObject graphql_name 'Timelog' - authorize :read_issue + authorize :read_issuable expose_permissions Types::PermissionTypes::Timelog diff --git a/app/graphql/types/work_items/widgets/description_type.rb b/app/graphql/types/work_items/widgets/description_type.rb index 4c365a67bfd..4861f7f46d8 100644 --- a/app/graphql/types/work_items/widgets/description_type.rb +++ b/app/graphql/types/work_items/widgets/description_type.rb @@ -13,8 +13,18 @@ module Types implements Types::WorkItems::WidgetInterface field :description, GraphQL::Types::String, - null: true, - description: 'Description of the work item.' + null: true, + description: 'Description of the work item.' + field :edited, GraphQL::Types::Boolean, + null: false, + description: 'Whether the description has been edited since the work item was created.', + method: :edited? + field :last_edited_at, Types::TimeType, + null: true, + description: 'Timestamp of when the work item\'s description was last edited.' + field :last_edited_by, Types::UserType, + null: true, + description: 'User that made the last edit to the work item\'s description.' markdown_field :description_html, null: true do |resolved_object| resolved_object.work_item diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 321a6e9395e..ddc682bc08a 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -7,6 +7,7 @@ module ApplicationSettingsHelper :gravatar_enabled?, :password_authentication_enabled_for_web?, :akismet_enabled?, + :spam_check_endpoint_enabled?, to: :'Gitlab::CurrentSettings.current_application_settings' def user_oauth_applications? @@ -60,6 +61,10 @@ module ApplicationSettingsHelper all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http' end + def anti_spam_service_enabled? + akismet_enabled? || spam_check_endpoint_enabled? + end + def enabled_protocol_button(container, protocol) case protocol when 'ssh' @@ -278,6 +283,7 @@ module ApplicationSettingsHelper :max_export_size, :max_import_size, :max_pages_size, + :max_pages_custom_domains_per_project, :max_yaml_size_bytes, :max_yaml_depth, :metrics_method_call_threshold, @@ -434,12 +440,24 @@ module ApplicationSettingsHelper :runner_token_expiration_interval, :group_runner_token_expiration_interval, :project_runner_token_expiration_interval, - :pipeline_limit_per_project_user_sha + :pipeline_limit_per_project_user_sha, + :invitation_flow_enforcement ].tap do |settings| - settings << :deactivate_dormant_users unless Gitlab.com? + next if Gitlab.com? + + settings << :deactivate_dormant_users + settings << :deactivate_dormant_users_period end end + def runner_token_expiration_interval_attributes + { + instance_runner_token_expiration_interval: @application_setting.runner_token_expiration_interval, + group_runner_token_expiration_interval: @application_setting.group_runner_token_expiration_interval, + project_runner_token_expiration_interval: @application_setting.project_runner_token_expiration_interval + } + end + def external_authorization_service_attributes [ :external_auth_client_cert, diff --git a/app/helpers/badges_helper.rb b/app/helpers/badges_helper.rb index d48eae26a90..069c15433a5 100644 --- a/app/helpers/badges_helper.rb +++ b/app/helpers/badges_helper.rb @@ -1,25 +1,6 @@ # frozen_string_literal: true module BadgesHelper - VARIANT_CLASSES = { - muted: "badge-muted", - neutral: "badge-neutral", - info: "badge-info", - success: "badge-success", - warning: "badge-warning", - danger: "badge-danger" - }.tap { |hash| hash.default = hash.fetch(:muted) }.freeze - - SIZE_CLASSES = { - sm: "sm", - md: "md", - lg: "lg" - }.tap { |hash| hash.default = hash.fetch(:md) }.freeze - - GL_BADGE_CLASSES = %w[gl-badge badge badge-pill].freeze - - GL_ICON_CLASSES = %w[gl-icon gl-badge-icon].freeze - # Creates a GitLab UI badge. # # Examples: @@ -53,47 +34,16 @@ module BadgesHelper # # See also https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-badge--default. def gl_badge_tag(*args, &block) + # Merge the options and html_options hashes if both are present, + # because the badge component wants a flat list of keyword args. + args.compact! + hashes, params = args.partition { |a| a.is_a? Hash } + options_hash = hashes.reduce({}, :merge) + if block - build_gl_badge_tag(capture(&block), *args) + render Pajamas::BadgeComponent.new(**options_hash), &block else - build_gl_badge_tag(*args) + render Pajamas::BadgeComponent.new(*params, **options_hash) end end - - private - - def build_gl_badge_tag(content, options = nil, html_options = nil) - options ||= {} - html_options ||= {} - - icon_only = options[:icon_only] - variant_class = VARIANT_CLASSES[options.fetch(:variant, :muted)] - size_class = SIZE_CLASSES[options.fetch(:size, :md)] - icon_classes = GL_ICON_CLASSES.dup << options.fetch(:icon_classes, nil) - - html_options = html_options.merge( - class: [ - *GL_BADGE_CLASSES, - variant_class, - size_class, - *html_options[:class] - ] - ) - - if icon_only - html_options['aria-label'] = content - html_options['role'] = 'img' - end - - if options[:icon] - icon_classes << "gl-mr-2" unless icon_only - icon = sprite_icon(options[:icon], css_class: icon_classes.join(' ')) - - content = icon_only ? icon : icon + content - end - - tag = html_options[:href].nil? ? :span : :a - - content_tag(tag, content, html_options) - end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 2c84da4862a..6c09e15f56f 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -92,32 +92,6 @@ module BlobHelper end end - def replace_blob_link(project = @project, ref = @ref, path = @path, blob:) - modify_file_button( - project, - ref, - path, - blob: blob, - label: _("Replace"), - action: "replace", - btn_class: "default", - modal_type: "upload" - ) - end - - def delete_blob_link(project = @project, ref = @ref, path = @path, blob:) - modify_file_button( - project, - ref, - path, - blob: blob, - label: _("Delete"), - action: "delete", - btn_class: "default", - modal_type: "remove" - ) - end - def can_modify_blob?(blob, project = @project, ref = @ref) !blob.stored_externally? && can_edit_tree?(project, ref) end diff --git a/app/helpers/ci/builds_helper.rb b/app/helpers/ci/builds_helper.rb index b4a2cf7bb1e..afd0af18ba7 100644 --- a/app/helpers/ci/builds_helper.rb +++ b/app/helpers/ci/builds_helper.rb @@ -25,7 +25,7 @@ module Ci { page_path: project_job_path(@project, @build), build_status: @build.status, - build_stage: @build.stage, + build_stage: @build.stage_name, log_state: '' } end diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 6d63151769f..7b8290ac9ef 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -11,7 +11,7 @@ module Ci "runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'), "page_path" => project_job_path(@project, @build), "build_status" => @build.status, - "build_stage" => @build.stage, + "build_stage" => @build.stage_name, "log_state" => '', "build_options" => javascript_build_options, "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 852eaeca5e3..0de84c0d61f 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -84,7 +84,6 @@ module Ci def group_runners_data_attributes(group) { - registration_token: group.runners_token, group_id: group.id, group_full_path: group.full_path, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb index 560d2fcd29f..597823cdac7 100644 --- a/app/helpers/deploy_tokens_helper.rb +++ b/app/helpers/deploy_tokens_helper.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true module DeployTokensHelper - def expand_deploy_tokens_section?(deploy_token) - deploy_token.persisted? || - deploy_token.errors.present? || + def expand_deploy_tokens_section?(new_deploy_token, created_deploy_token) + created_deploy_token || + new_deploy_token.errors.present? || Rails.env.test? end @@ -14,7 +14,7 @@ module DeployTokensHelper def packages_registry_enabled?(group_or_project) Gitlab.config.packages.enabled && - can?(current_user, :read_package, group_or_project) + can?(current_user, :read_package, group_or_project&.packages_policy_subject) end def deploy_token_revoke_button_data(token:, group_or_project:) diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 457502347ee..5c3b9d4b5ab 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -140,12 +140,12 @@ module DiffHelper if compare_url link_text = [ - _('Compare'), - ' ', - content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'), - '...', - content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha') - ].join('').html_safe + _('Compare'), + ' ', + content_tag(:span, Commit.truncate_sha(diff_file.old_blob.id), class: 'commit-sha'), + '...', + content_tag(:span, Commit.truncate_sha(diff_file.blob.id), class: 'commit-sha') + ].join('').html_safe tooltip = _('Compare submodule commit revisions') link = content_tag(:span, link_to(link_text, compare_url, class: 'btn gl-button has-tooltip', title: tooltip), class: 'submodule-compare') diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index a910d3d7c9d..62e66b9a3ea 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module DropdownsHelper + # rubocop:disable Metrics/CyclomaticComplexity def dropdown_tag(toggle_text, options: {}, &block) content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do data_attr = { toggle: "dropdown" } @@ -16,7 +17,8 @@ module DropdownsHelper end content_tag_options = { class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}" } - content_tag_options[:data] = { qa_selector: "#{options[:dropdown_qa_selector]}" } if options[:dropdown_qa_selector] + content_tag_options[:data] = options[:dropdown_qa_selector] ? { qa_selector: "#{options[:dropdown_qa_selector]}" } : {} + content_tag_options[:data][:testid] = "#{options[:dropdown_testid]}" if options[:dropdown_testid] dropdown_output << content_tag(:div, content_tag_options) do output = [] @@ -46,6 +48,7 @@ module DropdownsHelper dropdown_output.html_safe end end + # rubocop:enable Metrics/CyclomaticComplexity def dropdown_toggle(toggle_text, data_attr, options = {}) default_label = data_attr[:default_label] diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index f74eeeb8c6a..f2e24f54391 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module FormHelper - def form_errors(model, type: 'form', truncate: [], pajamas_alert: true) + def form_errors(model, type: 'form', truncate: []) errors = model.errors return unless errors.any? @@ -64,7 +64,7 @@ module FormHelper field_name: "#{issuable_type}[assignee_ids][]", default_label: _('Unassigned'), 'max-select': 1, - 'dropdown-header': _('Assignee'), + 'dropdown-header': s_('SearchToken|Assignee'), multi_select: true, 'input-meta': 'name', 'always-show-selectbox': true, diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index bb92792de2d..f77bd6621f9 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -172,6 +172,15 @@ module GroupsHelper } end + def group_overview_tabs_app_data(group) + { + subgroups_and_projects_endpoint: group_children_path(group, format: :json), + shared_projects_endpoint: group_shared_projects_path(group, format: :json), + archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'), + current_group_visibility: group.visibility + }.merge(subgroups_and_projects_list_app_data(group)) + end + def enabled_git_access_protocol_options_for_group case ::Gitlab::CurrentSettings.enabled_git_access_protocol when nil, "" diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 4b463b9971d..ec1327cf7ae 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -24,7 +24,8 @@ module IdeHelper 'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'), 'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'), 'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'), - 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration') + 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration'), + 'csp-nonce' => content_security_policy_nonce } end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 8fd004233e2..96daf398243 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -156,7 +156,7 @@ module IssuablesHelper output = [] if issuable.respond_to?(:work_item_type) && WorkItems::Type::WI_TYPES_WITH_CREATED_HEADER.include?(issuable.work_item_type.base_type) - output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle'), class: 'gl-mr-2', aria: { hidden: 'true' }) + output << content_tag(:span, sprite_icon("#{issuable.work_item_type.icon_name}", css_class: 'gl-icon gl-vertical-align-middle gl-text-gray-500'), class: 'gl-mr-2', aria: { hidden: 'true' }) output << s_('IssuableStatus|%{wi_type} created %{created_at} by ').html_safe % { wi_type: issuable.issue_type.capitalize, created_at: time_ago_with_tooltip(issuable.created_at) } else output << s_('IssuableStatus|Created %{created_at} by').html_safe % { created_at: time_ago_with_tooltip(issuable.created_at) } @@ -240,6 +240,7 @@ module IssuablesHelper updateEndpoint: "#{issuable_path(issuable)}.json", canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), + canUpdateTimelineEvent: can?(current_user, :admin_incident_management_timeline_event, issuable), issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent, target_type: issuable.model_name, target_id: issuable.iid), markdownDocsPath: help_page_path('user/markdown'), diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb deleted file mode 100644 index 7cb6da26236..00000000000 --- a/app/helpers/javascript_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module JavascriptHelper - def page_specific_javascript_tag(js) - javascript_include_tag asset_path(js) - end -end diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb index 4ddfb0224d1..0971fdae8dd 100644 --- a/app/helpers/jira_connect_helper.rb +++ b/app/helpers/jira_connect_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module JiraConnectHelper - def jira_connect_app_data(subscriptions) + def jira_connect_app_data(subscriptions, installation) skip_groups = subscriptions.map(&:namespace_id) { @@ -11,14 +11,16 @@ module JiraConnectHelper subscriptions_path: jira_connect_subscriptions_path(format: :json), users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in gitlab_user_path: current_user ? user_path(current_user) : nil, - oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil + oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data(installation).to_json : nil } end private - def jira_connect_oauth_data - oauth_authorize_url = oauth_authorization_url( + def jira_connect_oauth_data(installation) + oauth_instance_url = installation.oauth_authorization_url + + oauth_authorize_path = oauth_authorization_path( client_id: Gitlab::CurrentSettings.jira_connect_application_key, response_type: 'code', scope: 'api', @@ -27,8 +29,8 @@ module JiraConnectHelper ) { - oauth_authorize_url: oauth_authorize_url, - oauth_token_url: oauth_token_url, + oauth_authorize_url: Gitlab::Utils.append_path(oauth_instance_url, oauth_authorize_path), + oauth_token_path: oauth_token_path, state: oauth_state, oauth_token_payload: { grant_type: :authorization_code, diff --git a/app/helpers/kerberos_spnego_helper.rb b/app/helpers/kerberos_helper.rb index 0f6812bc31b..31166772367 100644 --- a/app/helpers/kerberos_spnego_helper.rb +++ b/app/helpers/kerberos_helper.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -module KerberosSpnegoHelper +module KerberosHelper def allow_basic_auth? true # different behavior in GitLab Enterprise Edition end - def allow_kerberos_spnego_auth? + def allow_kerberos_auth? false # different behavior in GitLab Enterprise Edition end end -KerberosSpnegoHelper.prepend_mod_with('KerberosSpnegoHelper') +KerberosHelper.prepend_mod_with('KerberosHelper') diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index e865db128c1..0123eb68c9a 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -87,7 +87,7 @@ module LabelsHelper '#013220' => s_('SuggestedColors|Dark green'), '#6699cc' => s_('SuggestedColors|Blue-gray'), '#0000ff' => s_('SuggestedColors|Blue'), - '#e6e6fa' => s_('SuggestedColors|Lavendar'), + '#e6e6fa' => s_('SuggestedColors|Lavender'), '#9400d3' => s_('SuggestedColors|Dark violet'), '#330066' => s_('SuggestedColors|Deep violet'), '#808080' => s_('SuggestedColors|Gray'), diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb index 421cf84f98c..a07922e451a 100644 --- a/app/helpers/learn_gitlab_helper.rb +++ b/app/helpers/learn_gitlab_helper.rb @@ -21,8 +21,8 @@ module LearnGitlabHelper end def learn_gitlab_onboarding_available?(project) - OnboardingProgress.onboarding?(project.namespace) && - LearnGitlab::Project.new(current_user).available? + Onboarding::Progress.onboarding?(project.namespace) && + Onboarding::LearnGitlab.new(current_user).available? end private @@ -33,10 +33,12 @@ module LearnGitlabHelper action_urls(project).to_h do |action, url| [ action, - url: url, - completed: attributes[OnboardingProgress.column_name(action)].present?, - svg: image_path("learn_gitlab/#{action}.svg"), - enabled: true + { + url: url, + completed: attributes[Onboarding::Progress.column_name(action)].present?, + svg: image_path("learn_gitlab/#{action}.svg"), + enabled: true + } ] end end @@ -70,11 +72,14 @@ module LearnGitlabHelper end def action_issue_urls - LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } + Onboarding::Completion::ACTION_ISSUE_IDS.transform_values do |id| + project_issue_url(learn_gitlab_project, id) + end end def deploy_section_action_urls(project) - experiment(:security_actions_continuous_onboarding, + experiment( + :security_actions_continuous_onboarding, namespace: project.namespace, user: current_user, sticky_to: current_user @@ -91,11 +96,11 @@ module LearnGitlabHelper end def learn_gitlab_project - @learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project + @learn_gitlab_project ||= Onboarding::LearnGitlab.new(current_user).project end def onboarding_progress(project) - OnboardingProgress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord + Onboarding::Progress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 4581da4a063..45ded6e35d8 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -256,6 +256,26 @@ module MergeRequestsHelper def moved_mr_sidebar_enabled? Feature.enabled?(:moved_mr_sidebar, @project) && defined?(@merge_request) end + + def sticky_header_data + data = { + iid: @merge_request.iid, + projectPath: @project.full_path, + title: markdown_field(@merge_request, :title), + isFluidLayout: fluid_layout.to_s, + tabs: [ + ['show', _('Overview'), project_merge_request_path(@project, @merge_request), @merge_request.related_notes.user.count], + ['commits', _('Commits'), commits_project_merge_request_path(@project, @merge_request), @commits_count], + ['diffs', _('Changes'), diffs_project_merge_request_path(@project, @merge_request), @diffs_count] + ] + } + + if @project.builds_enabled? + data[:tabs].insert(2, ['pipelines', _('Pipelines'), pipelines_project_merge_request_path(@project, @merge_request), @number_of_pipelines]) + end + + data + end end MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper') diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index dc7d8049556..b017c9a81d1 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -135,7 +135,7 @@ module Nav id: 'general_new_group', title: _('New group'), href: new_group_path, - data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_group_link' } ) ) end diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index efec6f2d0d8..32d3f4aebb4 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -48,6 +48,13 @@ module Nav private + def top_nav_localized_headers + { + explore: s_('TopNav|Explore'), + switch_to: s_('TopNav|Switch to') + }.freeze + end + def build_base_view_model(builder:, project:, group:) if current_user build_view_model(builder: builder, project: project, group: group) @@ -60,6 +67,7 @@ module Nav # These come from `app/views/layouts/nav/_explore.html.ham` if explore_nav_link?(:projects) builder.add_primary_menu_item_with_shortcut( + header: top_nav_localized_headers[:explore], href: explore_root_path, active: nav == 'project' || active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]), **projects_menu_item_attrs @@ -68,6 +76,7 @@ module Nav if explore_nav_link?(:groups) builder.add_primary_menu_item_with_shortcut( + header: top_nav_localized_headers[:explore], href: explore_groups_path, active: nav == 'group' || active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']), **groups_menu_item_attrs @@ -76,6 +85,7 @@ module Nav if explore_nav_link?(:snippets) builder.add_primary_menu_item_with_shortcut( + header: top_nav_localized_headers[:explore], active: active_nav_link?(controller: :snippets), href: explore_snippets_path, **snippets_menu_item_attrs @@ -89,6 +99,7 @@ module Nav current_item = project ? current_project(project: project) : {} builder.add_primary_menu_item_with_shortcut( + header: top_nav_localized_headers[:switch_to], active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), css_class: 'qa-projects-dropdown', data: { track_label: "projects_dropdown", track_action: "click_dropdown" }, @@ -103,6 +114,7 @@ module Nav current_item = group ? current_group(group: group) : {} builder.add_primary_menu_item_with_shortcut( + header: top_nav_localized_headers[:switch_to], active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]), css_class: 'qa-groups-dropdown', data: { track_label: "groups_dropdown", track_action: "click_dropdown" }, @@ -116,6 +128,7 @@ module Nav if dashboard_nav_link?(:milestones) builder.add_primary_menu_item_with_shortcut( id: 'milestones', + header: top_nav_localized_headers[:explore], title: _('Milestones'), href: dashboard_milestones_path, active: active_nav_link?(controller: 'dashboard/milestones'), @@ -127,6 +140,7 @@ module Nav if dashboard_nav_link?(:snippets) builder.add_primary_menu_item_with_shortcut( + header: top_nav_localized_headers[:explore], active: active_nav_link?(controller: 'dashboard/snippets'), data: { qa_selector: 'snippets_link', **menu_data_tracking_attrs('snippets') }, href: dashboard_snippets_path, @@ -137,6 +151,7 @@ module Nav if dashboard_nav_link?(:activity) builder.add_primary_menu_item_with_shortcut( id: 'activity', + header: top_nav_localized_headers[:explore], title: _('Activity'), href: activity_dashboard_path, active: active_nav_link?(path: 'dashboard#activity'), @@ -266,52 +281,74 @@ module Nav end def projects_submenu_items(builder:) - # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml` - [ - { id: 'your', title: _('Your projects'), href: dashboard_projects_path }, - { id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path }, - { id: 'explore', title: _('Explore projects'), href: explore_root_path }, - { id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path } - ].each do |item| + if Feature.enabled?(:remove_extra_primary_submenu_options) + title = _('View all projects') + builder.add_primary_menu_item( - **item, - data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) } + id: 'your', + title: title, + href: dashboard_projects_path, + data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } ) - end + else + # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml` + [ + { id: 'your', title: _('Your projects'), href: dashboard_projects_path }, + { id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path }, + { id: 'explore', title: _('Explore projects'), href: explore_root_path }, + { id: 'topics', title: _('Explore topics'), href: topics_explore_projects_path } + ].each do |item| + builder.add_primary_menu_item( + **item, + data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) } + ) + end - title = _('Create new project') + title = _('Create new project') - builder.add_secondary_menu_item( - id: 'create', - title: title, - href: new_project_path, - data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } - ) + builder.add_secondary_menu_item( + id: 'create', + title: title, + href: new_project_path, + data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } + ) + end end def groups_submenu # These group links come from `app/views/layouts/nav/groups_dropdown/_show.html.haml` builder = ::Gitlab::Nav::TopNavMenuBuilder.new - [ - { id: 'your', title: _('Your groups'), href: dashboard_groups_path }, - { id: 'explore', title: _('Explore groups'), href: explore_groups_path } - ].each do |item| - builder.add_primary_menu_item( - **item, - data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) } - ) - end + if Feature.enabled?(:remove_extra_primary_submenu_options) + title = _('View all groups') - if current_user.can_create_group? - title = _('Create group') - - builder.add_secondary_menu_item( - id: 'create', + builder.add_primary_menu_item( + id: 'your', title: title, - href: new_group_path, + href: dashboard_groups_path, data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } ) + else + [ + { id: 'your', title: _('Your groups'), href: dashboard_groups_path }, + { id: 'explore', title: _('Explore groups'), href: explore_groups_path } + ].each do |item| + builder.add_primary_menu_item( + **item, + data: { qa_selector: 'menu_item_link', qa_title: item[:title], **menu_data_tracking_attrs(item[:title]) } + ) + end + + if current_user.can_create_group? + title = _('Create group') + + builder.add_secondary_menu_item( + id: 'create', + title: title, + href: new_group_path, + data: { qa_selector: 'menu_item_link', qa_title: title, **menu_data_tracking_attrs(title) } + ) + end end builder.build diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index c0ba93f4a30..b7ab1c2e2d1 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -20,4 +20,15 @@ module NotifyHelper (source.description || default_description).truncate(200, separator: ' ') end + + def merge_request_hash_param(merge_request, reviewer) + { + mr_highlight: '<span style="font-weight: 600;color:#333333;">'.html_safe, + highlight_end: '</span>'.html_safe, + mr_link: link_to(merge_request.to_reference, merge_request_url(merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none").html_safe, + reviewer_highlight: '<span>'.html_safe, + reviewer_avatar: content_tag(:img, nil, height: "24", src: avatar_icon_for_user(reviewer, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar", class: "avatar").html_safe, + reviewer_link: link_to(reviewer.name, user_url(reviewer), style: "color:#333333;text-decoration:none;", class: "muted").html_safe + } + end end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index b52357bc891..f9ec20bdd01 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -73,6 +73,7 @@ module PackagesHelper older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), + project_settings_path: project_settings_packages_and_registries_path(@project), enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), show_cleanup_policy_link: show_cleanup_policy_link(@project).to_s, @@ -83,7 +84,8 @@ module PackagesHelper def settings_data cleanup_settings_data.merge( show_container_registry_settings: show_container_registry_settings(@project).to_s, - show_package_registry_settings: show_package_registry_settings(@project).to_s + show_package_registry_settings: show_package_registry_settings(@project).to_s, + cleanup_settings_path: cleanup_image_tags_project_settings_packages_and_registries_path(@project) ) end end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 0c057a29bec..c0665463706 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -150,6 +150,10 @@ module PageLayoutHelper css_class.join(' ') end + def full_content_class + "#{container_class} #{@content_class}" # rubocop:disable Rails/HelperInstanceVariable + end + def page_itemtype(itemtype = nil) if itemtype @page_itemtype = { itemscope: true, itemtype: itemtype } diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 104026ff21e..bfe39bbc211 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -53,7 +53,7 @@ module ProfilesHelper # Overridden in EE::ProfilesHelper#ssh_key_expires_field_description def ssh_key_expires_field_description - s_('Profiles|Key becomes invalid on this date.') + s_('Profiles|Optional but recommended. If set, key becomes invalid on the specified date.') end # Overridden in EE::ProfilesHelper#ssh_key_expiration_policy_enabled? diff --git a/app/helpers/projects/google_cloud/cloudsql_helper.rb b/app/helpers/projects/google_cloud/cloudsql_helper.rb new file mode 100644 index 00000000000..0c24254d9b4 --- /dev/null +++ b/app/helpers/projects/google_cloud/cloudsql_helper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +module Projects + module GoogleCloud + module CloudsqlHelper + # Sources: + # - https://cloud.google.com/sql/docs/postgres/instance-settings + # - https://cloud.google.com/sql/docs/mysql/instance-settings + # - https://cloud.google.com/sql/docs/sqlserver/instance-settings + + TIERS = [ + { value: 'db-custom-1-3840', label: '1 vCPU, 3840 MB RAM - Standard' }, + { value: 'db-custom-2-7680', label: '2 vCPU, 7680 MB RAM - Standard' }, + { value: 'db-custom-2-13312', label: '2 vCPU, 13312 MB RAM - High memory' }, + { value: 'db-custom-4-15360', label: '4 vCPU, 15360 MB RAM - Standard' }, + { value: 'db-custom-4-26624', label: '4 vCPU, 26624 MB RAM - High memory' }, + { value: 'db-custom-8-30720', label: '8 vCPU, 30720 MB RAM - Standard' }, + { value: 'db-custom-8-53248', label: '8 vCPU, 53248 MB RAM - High memory' }, + { value: 'db-custom-16-61440', label: '16 vCPU, 61440 MB RAM - Standard' }, + { value: 'db-custom-16-106496', label: '16 vCPU, 106496 MB RAM - High memory' }, + { value: 'db-custom-32-122880', label: '32 vCPU, 122880 MB RAM - Standard' }, + { value: 'db-custom-32-212992', label: '32 vCPU, 212992 MB RAM - High memory' }, + { value: 'db-custom-64-245760', label: '64 vCPU, 245760 MB RAM - Standard' }, + { value: 'db-custom-64-425984', label: '64 vCPU, 425984 MB RAM - High memory' }, + { value: 'db-custom-96-368640', label: '96 vCPU, 368640 MB RAM - Standard' }, + { value: 'db-custom-96-638976', label: '96 vCPU, 638976 MB RAM - High memory' } + ].freeze + + VERSIONS = { + postgres: [ + { value: 'POSTGRES_14', label: 'PostgreSQL 14' }, + { value: 'POSTGRES_13', label: 'PostgreSQL 13' }, + { value: 'POSTGRES_12', label: 'PostgreSQL 12' }, + { value: 'POSTGRES_11', label: 'PostgreSQL 11' }, + { value: 'POSTGRES_10', label: 'PostgreSQL 10' }, + { value: 'POSTGRES_9_6', label: 'PostgreSQL 9.6' } + ], + mysql: [ + { value: 'MYSQL_8_0', label: 'MySQL 8' }, + { value: 'MYSQL_5_7', label: 'MySQL 5.7' }, + { value: 'MYSQL_5_6', label: 'MySQL 5.6' } + ], + sqlserver: [ + { value: 'SQLSERVER_2017_STANDARD', label: 'SQL Server 2017 Standard' }, + { value: 'SQLSERVER_2017_ENTERPRISE', label: 'SQL Server 2017 Enterprise' }, + { value: 'SQLSERVER_2017_EXPRESS', label: 'SQL Server 2017 Express' }, + { value: 'SQLSERVER_2017_WEB', label: 'SQL Server 2017 Web' }, + { value: 'SQLSERVER_2019_STANDARD', label: 'SQL Server 2019 Standard' }, + { value: 'SQLSERVER_2019_ENTERPRISE', label: 'SQL Server 2019 Enterprise' }, + { value: 'SQLSERVER_2019_EXPRESS', label: 'SQL Server 2019 Express' }, + { value: 'SQLSERVER_2019_WEB', label: 'SQL Server 2019 Web' } + ] + }.freeze + end + end +end diff --git a/app/helpers/projects/pages_helper.rb b/app/helpers/projects/pages_helper.rb new file mode 100644 index 00000000000..f46c11db1db --- /dev/null +++ b/app/helpers/projects/pages_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Projects + module PagesHelper + def can_create_pages_custom_domains?(current_user, project) + current_user.can?(:update_pages, project) && + (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) && + project.can_create_custom_domains? + end + end +end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 5f2a9f7bf21..c72beb4d722 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -6,7 +6,6 @@ module Projects def js_pipeline_tabs_data(project, pipeline, _user) { - can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json, failed_jobs_count: pipeline.failed_builds.count, failed_jobs_summary: prepare_failed_jobs_summary_data(pipeline.failed_builds), full_path: project.full_path, diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index dfc270adf8b..e760fad7be9 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -172,6 +172,7 @@ module ProjectsHelper def project_list_cache_key(project, pipeline_status: true) key = [ + project.star_count, project.route.cache_key, project.cache_key, project.last_activity_date, @@ -389,7 +390,10 @@ module ProjectsHelper pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?, pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control'), issuesHelpPath: help_page_path('user/project/issues/index'), - membersPagePath: project_project_members_path(project) + membersPagePath: project_project_members_path(project), + environmentsHelpPath: help_page_path('ci/environments/index'), + featureFlagsHelpPath: help_page_path('operations/feature_flags'), + releasesHelpPath: help_page_path('user/project/releases/index') } end @@ -437,7 +441,6 @@ module ProjectsHelper def show_inactive_project_deletion_banner?(project) return false unless project.present? && project.saved? return false unless delete_inactive_projects? - return false unless Feature.enabled?(:inactive_projects_deletion, project.root_namespace) project.inactive? end @@ -452,9 +455,9 @@ module ProjectsHelper def clusters_deprecation_alert_message if has_active_license? - s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of November 2022. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd} or reach out to GitLab support.') + s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd} or reach out to GitLab support.') else - s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of November 2022. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.') + s_('ClusterIntegration|The certificate-based Kubernetes integration has been deprecated and will be turned off at the end of February 2023. Please %{linkStart}migrate to the GitLab agent for Kubernetes%{linkEnd}.') end end @@ -635,6 +638,7 @@ module ProjectsHelper emailsDisabled: project.emails_disabled?, metricsDashboardAccessLevel: feature.metrics_dashboard_access_level, operationsAccessLevel: feature.operations_access_level, + monitorAccessLevel: feature.monitor_access_level, showDefaultAwardEmojis: project.show_default_award_emojis?, warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?, enforceAuthChecksOnUploads: project.enforce_auth_checks_on_uploads?, diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index dc53be330fe..b16235893ae 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -239,26 +239,26 @@ module SearchHelper if can?(current_user, :download_code, @project) result.concat([ - { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) }, - { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) } - ]) + { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) }, + { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) } + ]) end if can?(current_user, :read_repository_graphs, @project) result.concat([ - { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) }, - { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) } - ]) + { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) }, + { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) } + ]) end result.concat([ - { category: "In this project", label: _("Issues"), url: project_issues_path(@project) }, - { category: "In this project", label: _("Merge requests"), url: project_merge_requests_path(@project) }, - { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) }, - { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) }, - { category: "In this project", label: _("Members"), url: project_project_members_path(@project) }, - { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) } - ]) + { category: "In this project", label: _("Issues"), url: project_issues_path(@project) }, + { category: "In this project", label: _("Merge requests"), url: project_merge_requests_path(@project) }, + { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) }, + { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) }, + { category: "In this project", label: _("Members"), url: project_project_members_path(@project) }, + { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) } + ]) if can?(current_user, :read_feature_flag, @project) result << { category: "In this project", label: _("Feature Flags"), url: project_feature_flags_path(@project) } @@ -294,13 +294,13 @@ module SearchHelper return [] unless issue && Ability.allowed?(current_user, :read_issue, issue) [ - { - category: 'In this project', - id: issue.id, - label: search_result_sanitize("#{issue.title} (#{issue.to_reference})"), - url: issue_path(issue), - avatar_url: issue.project.avatar_url || '' - } + { + category: 'In this project', + id: issue.id, + label: search_result_sanitize("#{issue.title} (#{issue.to_reference})"), + url: issue_path(issue), + avatar_url: issue.project.avatar_url || '' + } ] end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 58f0af883f5..a711f36fe05 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -157,7 +157,9 @@ module SortingHelper { sort_value_name => sort_title_name, sort_value_oldest_updated => sort_title_oldest_updated, - sort_value_recently_updated => sort_title_recently_updated + sort_value_recently_updated => sort_title_recently_updated, + sort_value_version_desc => sort_title_version_desc, + sort_value_version_asc => sort_title_version_asc } end diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb index 4dfa7689110..b49cb617d80 100644 --- a/app/helpers/sorting_titles_values_helper.rb +++ b/app/helpers/sorting_titles_values_helper.rb @@ -86,6 +86,14 @@ module SortingTitlesValuesHelper s_('SortOptions|Name, descending') end + def sort_title_version_desc + s_('SortOptions|Latest version') + end + + def sort_title_version_asc + s_('SortOptions|Oldest version') + end + def sort_title_oldest_activity s_('SortOptions|Oldest updated') end @@ -275,6 +283,14 @@ module SortingTitlesValuesHelper 'updated_asc' end + def sort_value_version_asc + 'version_asc' + end + + def sort_value_version_desc + 'version_desc' + end + def sort_value_popularity 'popularity' end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index 9e516d726c1..a60143db739 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -23,119 +23,4 @@ module StorageHelper _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters end - - def storage_enforcement_banner_info(context) - root_ancestor = context.root_ancestor - - return unless should_show_storage_enforcement_banner?(context, current_user, root_ancestor) - - text_args = storage_enforcement_banner_text_args(root_ancestor, context) - - text_paragraph_2 = if root_ancestor.user_namespace? - html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \ - "View and manage your usage from %{strong_start}User settings > Usage quotas%{strong_end}. %{docs_link_start}Learn more%{link_end} " \ - "about how to reduce your storage.")).html_safe % text_args[:p2] - else - html_escape_once(s_("UsageQuota|The namespace is currently using %{strong_start}%{used_storage}%{strong_end} of namespace storage. " \ - "Group owners can view namespace storage usage and purchase more from %{strong_start}Group settings > Usage quotas%{strong_end}. %{docs_link_start}Learn more.%{link_end}" \ - )).html_safe % text_args[:p2] - end - - { - text_paragraph_1: html_escape_once(s_("UsageQuota|Effective %{storage_enforcement_date}, namespace storage limits will apply " \ - "to the %{strong_start}%{namespace_name}%{strong_end} namespace. %{extra_message}" \ - "View the %{rollout_link_start}rollout schedule for this change%{link_end}.")).html_safe % text_args[:p1], - text_paragraph_2: text_paragraph_2, - text_paragraph_3: html_escape_once(s_("UsageQuota|See our %{faq_link_start}FAQ%{link_end} for more information.")).html_safe % text_args[:p3], - variant: 'warning', - namespace_id: root_ancestor.id, - callouts_path: root_ancestor.user_namespace? ? callouts_path : group_callouts_path, - callouts_feature_name: storage_enforcement_banner_user_callouts_feature_name(root_ancestor) - } - end - - private - - def should_show_storage_enforcement_banner?(context, current_user, root_ancestor) - return false unless user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor) - return false if root_ancestor.paid? - return false unless future_enforcement_date?(root_ancestor) - return false if user_dismissed_storage_enforcement_banner?(root_ancestor) - - ::Feature.enabled?(:namespace_storage_limit_show_preenforcement_banner, root_ancestor) - end - - def user_allowed_storage_enforcement_banner?(context, current_user, root_ancestor) - return can?(current_user, :maintainer_access, context) unless context.respond_to?(:user_namespace?) && context.user_namespace? - - can?(current_user, :owner_access, context) - end - - def storage_enforcement_banner_text_args(root_ancestor, context) - strong_tags = { - strong_start: "<strong>".html_safe, - strong_end: "</strong>".html_safe - } - - extra_message = if context.is_a?(Project) - html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} project will be affected by this. ")) - .html_safe % strong_tags.merge(context_name: context.name) - elsif !context.root? - html_escape_once(s_("UsageQuota|The %{strong_start}%{context_name}%{strong_end} group will be affected by this. ")) - .html_safe % strong_tags.merge(context_name: context.name) - else - '' - end - - { - p1: { - storage_enforcement_date: root_ancestor.storage_enforcement_date, - namespace_name: root_ancestor.name, - extra_message: extra_message, - rollout_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'namespace-storage-limit-enforcement-schedule') }, - link_end: "</a>".html_safe - }.merge(strong_tags), - p2: { - used_storage: storage_counter(root_ancestor.root_storage_statistics&.storage_size || 0), - docs_link_start: '<a href="%{url}" >'.html_safe % { url: help_page_path('user/usage_quotas', anchor: 'manage-your-storage-usage') }, - link_end: "</a>".html_safe - }.merge(strong_tags), - p3: { - faq_link_start: '<a href="%{url}" >'.html_safe % { url: "#{Gitlab::Saas.about_pricing_url}faq-efficient-free-tier/#storage-limits-on-gitlab-saas-free-tier" }, - link_end: "</a>".html_safe - } - } - end - - def storage_enforcement_banner_user_callouts_feature_name(namespace) - "storage_enforcement_banner_#{storage_enforcement_banner_threshold(namespace)}_enforcement_threshold" - end - - def storage_enforcement_banner_threshold(namespace) - days_to_enforcement_date = (namespace.storage_enforcement_date - Date.today) - - return :first if days_to_enforcement_date > 30 - return :second if days_to_enforcement_date > 15 && days_to_enforcement_date <= 30 - return :third if days_to_enforcement_date > 7 && days_to_enforcement_date <= 15 - return :fourth if days_to_enforcement_date >= 0 && days_to_enforcement_date <= 7 - end - - def user_dismissed_storage_enforcement_banner?(namespace) - return false unless current_user - - if namespace.user_namespace? - current_user.dismissed_callout?(feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace)) - else - current_user.dismissed_callout_for_group?( - feature_name: storage_enforcement_banner_user_callouts_feature_name(namespace), - group: namespace - ) - end - end - - def future_enforcement_date?(namespace) - return true if ::Feature.enabled?(:namespace_storage_limit_bypass_date_check, namespace) - - namespace.storage_enforcement_date.present? && namespace.storage_enforcement_date >= Date.today - end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index a957c9ce9e0..3e5f63796b2 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -45,7 +45,11 @@ module SystemNoteHelper 'attention_requested' => 'user', 'attention_request_removed' => 'user', 'contact' => 'users', - 'timeline_event' => 'clock' + 'timeline_event' => 'clock', + 'relate_to_child' => 'link', + 'unrelate_from_child' => 'link', + 'relate_to_parent' => 'link', + 'unrelate_from_parent' => 'link' }.freeze def system_note_icon_name(note) diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index 39993bbfb44..11d09a79dcf 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -172,18 +172,19 @@ module TimeboxesHelper def timebox_date_range(timebox) if timebox.start_date && timebox.due_date - "#{timebox.start_date.to_s(:medium)}–#{timebox.due_date.to_s(:medium)}" + s_("DateRange|%{start_date}–%{end_date}") % { start_date: l(timebox.start_date, format: Date::DATE_FORMATS[:medium]), + end_date: l(timebox.due_date, format: Date::DATE_FORMATS[:medium]) } elsif timebox.due_date if timebox.due_date.past? - _("expired on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) } + _("expired on %{timebox_due_date}") % { timebox_due_date: l(timebox.due_date, format: Date::DATE_FORMATS[:medium]) } else - _("expires on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) } + _("expires on %{timebox_due_date}") % { timebox_due_date: l(timebox.due_date, format: Date::DATE_FORMATS[:medium]) } end elsif timebox.start_date if timebox.start_date.past? - _("started on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) } + _("started on %{timebox_start_date}") % { timebox_start_date: l(timebox.start_date, format: Date::DATE_FORMATS[:medium]) } else - _("starts on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) } + _("starts on %{timebox_start_date}") % { timebox_start_date: l(timebox.start_date, format: Date::DATE_FORMATS[:medium]) } end end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 5977f51cab1..ecf29c41100 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -142,6 +142,16 @@ module TodosHelper todos_filter_params.values.none? end + def no_todos_messages + [ + s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'), + s_('Todos|Isn\'t an empty To-Do List beautiful?'), + s_('Todos|Give yourself a pat on the back!'), + s_('Todos|Nothing left to do. High five!'), + s_('Todos|Henceforth, you shall be known as "To-Do Destroyer"') + ] + end + def todos_filter_path(options = {}) without = options.delete(:without) diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 3dd6b3f4a80..d8baa185370 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -10,8 +10,10 @@ module Users REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' + MERGE_REQUEST_SETTINGS_MOVED_CALLOUT = 'merge_request_settings_moved_callout' REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze WEB_HOOK_DISABLED = 'web_hook_disabled' + ULTIMATE_FEATURE_REMOVAL_BANNER = 'ultimate_feature_removal_banner' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -71,18 +73,28 @@ module Users last_failure = DateTime.parse(last_failure) if last_failure - user_dismissed?(WEB_HOOK_DISABLED, last_failure, namespace: project.namespace) + user_dismissed?(WEB_HOOK_DISABLED, last_failure, project: project) + end + + def show_merge_request_settings_callout? + !user_dismissed?(MERGE_REQUEST_SETTINGS_MOVED_CALLOUT) + end + + def ultimate_feature_removal_banner_dismissed?(project) + return false unless project + + user_dismissed?(ULTIMATE_FEATURE_REMOVAL_BANNER, project: project) end private - def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, namespace: nil) + def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, project: nil) return false unless current_user query = { feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than } - if namespace - current_user.dismissed_callout_for_namespace?(namespace: namespace, **query) + if project + current_user.dismissed_callout_for_project?(project: project, **query) else current_user.dismissed_callout?(**query) end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index cae2addea9c..271fa47dd97 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -18,10 +18,11 @@ module UsersHelper return _('We also use email for avatar detection if no avatar is uploaded.') unless user.unconfirmed_email.present? confirmation_link = link_to _('Resend confirmation e-mail'), user_confirmation_path(user: { email: user.unconfirmed_email }), method: :post - - h(_('Please click the link in the confirmation email before continuing. It was sent to ')) + - content_tag(:strong) { user.unconfirmed_email } + h('.') + - content_tag(:p) { confirmation_link } + h(_('Please click the link in the confirmation email before continuing. It was sent to %{html_tag_strong_start}%{email}%{html_tag_strong_end}.')) % { + html_tag_strong_start: '<strong>'.html_safe, + html_tag_strong_end: '</strong>'.html_safe, + email: user.unconfirmed_email + } + content_tag(:p) { confirmation_link } end def profile_tabs @@ -93,6 +94,7 @@ module UsersHelper [].tap do |badges| badges << blocked_user_badge(user) if user.blocked? badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin? + badges << { text: s_('AdminUsers|Bot'), variant: 'muted' } if user.bot? badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external? badges << { text: s_("AdminUsers|It's you!"), variant: 'muted' } if current_user == user badges << { text: s_("AdminUsers|Locked"), variant: 'warning' } if user.access_locked? @@ -197,6 +199,9 @@ module UsersHelper banned_badge = { text: s_('AdminUsers|Banned'), variant: 'danger' } return banned_badge if user.banned? + ldap_blocked_badge = { text: s_('AdminUsers|LDAP Blocked'), variant: 'danger' } + return ldap_blocked_badge if user.ldap_blocked? + { text: s_('AdminUsers|Blocked'), variant: 'danger' } end diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb index 95122750c2f..e95b90c69ef 100644 --- a/app/helpers/web_hooks/web_hooks_helper.rb +++ b/app/helpers/web_hooks/web_hooks_helper.rb @@ -5,6 +5,7 @@ module WebHooks EXPIRY_TTL = 1.hour def show_project_hook_failed_callout?(project:) + return false if project_hook_page? return false unless current_user return false unless Feature.enabled?(:webhooks_failed_callout, project) return false unless Feature.enabled?(:web_hooks_disable_failed, project) @@ -23,5 +24,9 @@ module WebHooks ProjectHook.for_projects(project).disabled.exists? end end + + def project_hook_page? + current_controller?('projects/hooks') || current_controller?('projects/hook_logs') + end end end diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb index 1fa85064c57..1bf7deec542 100644 --- a/app/mailers/abuse_report_mailer.rb +++ b/app/mailers/abuse_report_mailer.rb @@ -10,7 +10,7 @@ class AbuseReportMailer < ApplicationMailer @abuse_report = AbuseReport.find(abuse_report_id) - mail( + mail_with_locale( to: Gitlab::CurrentSettings.abuse_notification_email, subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse" ) diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 94ed83a7d4a..bb8d20b8301 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -34,4 +34,23 @@ class ApplicationMailer < ActionMailer::Base address.display_name = Gitlab.config.gitlab.email_display_name address end + + def mail_with_locale(headers = {}, &block) + locale = recipient_locale headers + + Gitlab::I18n.with_locale(locale) do + mail(headers, &block) + end + end + + def recipient_locale(headers = {}) + to = Array(headers[:to]) + locale = I18n.locale + locale = preferred_language_by_email(to.first) if to.one? + locale + end + + def preferred_language_by_email(email) + User.find_by_any_email(email)&.preferred_language || I18n.locale + end end diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb index 25721658285..f681aa67a77 100644 --- a/app/mailers/email_rejection_mailer.rb +++ b/app/mailers/email_rejection_mailer.rb @@ -22,6 +22,6 @@ class EmailRejectionMailer < ApplicationMailer headers['Reply-To'] = @original_message.to.first if can_retry - mail(headers) + mail_with_locale(headers) end end diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb index 3766b4447d1..5c5497d8eb5 100644 --- a/app/mailers/emails/admin_notification.rb +++ b/app/mailers/emails/admin_notification.rb @@ -7,13 +7,13 @@ module Emails email = user.notification_email_or_default @unsubscribe_url = unsubscribe_url(email: Base64.urlsafe_encode64(email)) @body = body - mail to: email, subject: subject + mail_with_locale to: email, subject: subject end def send_unsubscribed_notification(user_id) user = User.find(user_id) email = user.notification_email_or_default - mail to: email, subject: "Unsubscribed from GitLab administrator notifications" + mail_with_locale to: email, subject: "Unsubscribed from GitLab administrator notifications" end end end diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 07812a01202..3c9bf41c208 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -13,7 +13,7 @@ module Emails def group_email(current_user, group, subj, errors: nil) @group = group @errors = errors - mail(to: current_user.notification_email_for(@group), subject: subject(subj)) + mail_with_locale(to: current_user.notification_email_for(@group), subject: subject(subj)) end end end diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb index 2fc8cae06fe..e3089fdef9b 100644 --- a/app/mailers/emails/identity_verification.rb +++ b/app/mailers/emails/identity_verification.rb @@ -13,3 +13,5 @@ module Emails end end end + +Emails::IdentityVerification.prepend_mod diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb index 1b46d4841b0..972c1da065a 100644 --- a/app/mailers/emails/in_product_marketing.rb +++ b/app/mailers/emails/in_product_marketing.rb @@ -31,7 +31,7 @@ module Emails def mail_to(to:, subject:) custom_headers = Gitlab.com? ? CUSTOM_HEADERS : {} - mail(to: to, subject: subject, **custom_headers) do |format| + mail_with_locale(to: to, subject: subject, **custom_headers) do |format| format.html do @message.format = :html diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index c885e41671c..33c955f94ee 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -61,7 +61,7 @@ module Emails Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s) - mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format| + mail_with_locale(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format| format.html { render layout: 'unknown_user_mailer' } format.text { render layout: 'unknown_user_mailer' } end diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb index 6c3dcf8746b..a6e9da18689 100644 --- a/app/mailers/emails/pages_domains.rb +++ b/app/mailers/emails/pages_domains.rb @@ -6,7 +6,7 @@ module Emails @domain = domain @project = domain.project - mail( + mail_with_locale( to: recipient.notification_email_for(@project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled") ) @@ -16,7 +16,7 @@ module Emails @domain = domain @project = domain.project - mail( + mail_with_locale( to: recipient.notification_email_for(@project.group), subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled") ) @@ -26,7 +26,7 @@ module Emails @domain = domain @project = domain.project - mail( + mail_with_locale( to: recipient.notification_email_for(@project.group), subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'") ) @@ -36,7 +36,7 @@ module Emails @domain = domain @project = domain.project - mail( + mail_with_locale( to: recipient.notification_email_for(@project.group), subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'") ) @@ -47,7 +47,7 @@ module Emails @project = domain.project subject_text = _("ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'") % { domain: domain.domain } - mail( + mail_with_locale( to: recipient.notification_email_for(@project.group), subject: subject(subject_text) ) diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 81f082b9680..8fe471a48f2 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -6,7 +6,7 @@ module Emails @current_user = @user = User.find(user_id) @target_url = user_url(@user) @token = token - mail(to: @user.notification_email_or_default, subject: subject("Account was created for you")) + mail_with_locale(to: @user.notification_email_or_default, subject: subject("Account was created for you")) end def instance_access_request_email(user, recipient) @@ -42,7 +42,7 @@ module Emails @current_user = @user = @key.user @target_url = user_url(@user) - mail(to: @user.notification_email_or_default, subject: subject("SSH key was added to your account")) + mail_with_locale(to: @user.notification_email_or_default, subject: subject("SSH key was added to your account")) end # rubocop: enable CodeReuse/ActiveRecord @@ -54,7 +54,7 @@ module Emails @current_user = @user = @gpg_key.user @target_url = user_url(@user) - mail(to: @user.notification_email_or_default, subject: subject("GPG key was added to your account")) + mail_with_locale(to: @user.notification_email_or_default, subject: subject("GPG key was added to your account")) end # rubocop: enable CodeReuse/ActiveRecord @@ -66,7 +66,7 @@ module Emails @token_name = token_name Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("A new personal access token has been created"))) end end @@ -79,7 +79,7 @@ module Emails @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) end end @@ -90,7 +90,7 @@ module Emails @target_url = profile_personal_access_tokens_url Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has expired"))) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has expired"))) end end @@ -102,7 +102,7 @@ module Emails @target_url = profile_keys_url Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired"))) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired"))) end end @@ -114,7 +114,7 @@ module Emails @target_url = profile_keys_url Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon."))) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon."))) end end @@ -137,7 +137,7 @@ module Emails @user = user Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled"))) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled"))) end end @@ -148,7 +148,7 @@ module Emails @email = email Gitlab::I18n.with_locale(@user.preferred_language) do - mail(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) + mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("New email address added"))) end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 5b8471abb0f..4bb624c27e9 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -7,28 +7,29 @@ module Emails @project = Project.find project_id @target_url = project_url(@project) @old_path_with_namespace = old_path_with_namespace - mail(to: @user.notification_email_for(@project.group), - subject: subject("Project was moved")) + mail_with_locale(to: @user.notification_email_for(@project.group), + subject: subject("Project was moved")) end def project_was_exported_email(current_user, project) @project = project - mail(to: current_user.notification_email_for(project.group), - subject: subject("Project was exported")) + mail_with_locale(to: current_user.notification_email_for(project.group), + subject: subject("Project was exported")) end def project_was_not_exported_email(current_user, project, errors) @project = project @errors = errors - mail(to: current_user.notification_email_for(@project.group), - subject: subject("Project export error")) + mail_with_locale(to: current_user.notification_email_for(@project.group), + subject: subject("Project export error")) end def repository_cleanup_success_email(project, user) @project = project @user = user - mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup has completed")) + mail_with_locale(to: user.notification_email_for(project.group), + subject: subject("Project cleanup has completed")) end def repository_cleanup_failure_email(project, user, error) @@ -36,7 +37,7 @@ module Emails @user = user @error = error - mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup failure")) + mail_with_locale(to: user.notification_email_for(project.group), subject: subject("Project cleanup failure")) end def repository_push_email(project_id, opts = {}) @@ -51,9 +52,9 @@ module Emails add_project_headers headers['X-GitLab-Author'] = @message.author_username - mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?), - reply_to: @message.reply_to, - subject: @message.subject) + mail_with_locale(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?), + reply_to: @message.reply_to, + subject: @message.subject) end def prometheus_alert_fired_email(project, user, alert) @@ -65,7 +66,7 @@ module Emails add_alert_headers subject_text = "Alert: #{@alert.email_title}" - mail(to: user.notification_email_for(@project.group), subject: subject(subject_text)) + mail_with_locale(to: user.notification_email_for(@project.group), subject: subject(subject_text)) end def inactive_project_deletion_warning_email(project, user, deletion_date) diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb index 4875abafe8d..8fe93f59662 100644 --- a/app/mailers/emails/releases.rb +++ b/app/mailers/emails/releases.rb @@ -11,7 +11,7 @@ module Emails ) @recipient = User.find(user_id) - mail( + mail_with_locale( to: @recipient.notification_email_for(@project.group), subject: subject(release_email_subject) ) diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb index 9cde53918b9..791ab7103b4 100644 --- a/app/mailers/emails/remote_mirrors.rb +++ b/app/mailers/emails/remote_mirrors.rb @@ -7,7 +7,7 @@ module Emails @project = @remote_mirror.project user = User.find(recipient_id) - mail(to: user.notification_email_for(@project.group), subject: subject('Remote mirror update failed')) + mail_with_locale(to: user.notification_email_for(@project.group), subject: subject('Remote mirror update failed')) end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index ed7681e595f..5a3fc70832c 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -38,11 +38,11 @@ class Notify < ApplicationMailer helper InProductMarketingHelper def test_email(recipient_email, subject, body) - mail(to: recipient_email, - subject: subject, - body: body.html_safe, - content_type: 'text/html' - ) + mail_with_locale(to: recipient_email, + subject: subject, + body: body.html_safe, + content_type: 'text/html' + ) end # Splits "gitlab.corp.company.com" up into "gitlab.corp.company.com", @@ -139,7 +139,7 @@ class Notify < ApplicationMailer @reply_by_email = true end - mail(headers) + mail_with_locale(headers) end # `model` is used on EE code @@ -225,7 +225,7 @@ class Notify < ApplicationMailer end def email_with_layout(to:, subject:, layout: 'mailer') - mail(to: to, subject: subject) do |format| + mail_with_locale(to: to, subject: subject) do |format| format.html { render layout: layout } format.text { render layout: layout } end diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index be8d96012cc..15b6fec3548 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -60,8 +60,12 @@ class NotifyPreview < ActionMailer::Preview end end + def user_cap_reached + Notify.user_cap_reached(user.id).message + end + def new_mention_in_merge_request_email - Notify.new_mention_in_merge_request_email(user.id, issue.id, user.id).message + Notify.new_mention_in_merge_request_email(user.id, merge_request.id, user.id).message end def closed_issue_email @@ -97,7 +101,7 @@ class NotifyPreview < ActionMailer::Preview end def closed_merge_request_email - Notify.closed_merge_request_email(user.id, issue.id, user.id).message + Notify.closed_merge_request_email(user.id, merge_request.id, user.id).message end def merge_request_status_email @@ -205,14 +209,6 @@ class NotifyPreview < ActionMailer::Preview Notify.inactive_project_deletion_warning_email(project, user, '2022-04-22').message end - def user_auto_banned_instance_email - ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600).message - end - - def user_auto_banned_namespace_email - ::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600, group: group).message - end - def verification_instructions_email Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message end @@ -220,7 +216,7 @@ class NotifyPreview < ActionMailer::Preview private def project - @project ||= Project.find_by_full_path('gitlab-org/gitlab-test') + @project ||= Project.first end def issue diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb index b8f990f26c8..17c36c19955 100644 --- a/app/mailers/repository_check_mailer.rb +++ b/app/mailers/repository_check_mailer.rb @@ -14,7 +14,7 @@ class RepositoryCheckMailer < ApplicationMailer "#{failed_count} projects failed their last repository check" end - mail( + mail_with_locale( to: User.admins.active.pluck(:email), subject: "GitLab Admin | #{@message}" ) diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 9f634e70ff4..7dbc95c251b 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -83,21 +83,21 @@ class ActiveSession is_impersonated: request.session[:impersonator_id].present? ) - redis.pipelined do - redis.setex( + redis.pipelined do |pipeline| + pipeline.setex( key_name(user.id, session_private_id), expiry, active_user_session.dump ) # Deprecated legacy format - temporary to support mixed deployments - redis.setex( + pipeline.setex( key_name_v1(user.id, session_private_id), expiry, Marshal.dump(active_user_session) ) - redis.sadd( + pipeline.sadd( lookup_key_name(user.id), session_private_id ) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 579f2c38ae6..edb9a2053b1 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -10,11 +10,7 @@ class ApplicationSetting < ApplicationRecord ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22' ignore_columns %i[static_objects_external_storage_auth_token], remove_with: '14.9', remove_after: '2022-03-22' - ignore_column %i[max_package_files_for_package_destruction], remove_with: '14.9', remove_after: '2022-03-22' ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18' - ignore_column :pseudonymizer_enabled, remove_with: '15.1', remove_after: '2022-06-22' - ignore_column :enforce_ssh_key_expiration, remove_with: '15.2', remove_after: '2022-07-22' - ignore_column :enforce_pat_expiration, remove_with: '15.2', remove_after: '2022-07-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -221,6 +217,10 @@ class ApplicationSetting < ApplicationRecord numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte } + validates :max_pages_custom_domains_per_project, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :jobs_per_stage_page_size, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -406,6 +406,10 @@ class ApplicationSetting < ApplicationRecord validates :invisible_captcha_enabled, inclusion: { in: [true, false], message: _('must be a boolean value') } + validates :invitation_flow_enforcement, + allow_nil: false, + inclusion: { in: [true, false], message: _('must be a boolean value') } + Gitlab::SSHPublicKey.supported_types.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -621,6 +625,10 @@ class ApplicationSetting < ApplicationRecord validates :inactive_projects_send_warning_email_after_months, numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months } + validates :cube_api_base_url, + addressable_url: { allow_localhost: true, allow_local_network: false }, + allow_blank: true + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -658,6 +666,7 @@ class ApplicationSetting < ApplicationRecord attr_encrypted :database_grafana_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :arkose_labs_public_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm validates :disable_feed_token, inclusion: { in: [true, false], message: _('must be a boolean value') } diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 3fda8693a58..323d759510e 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -75,9 +75,9 @@ module Ci def self.clone_accessors %i[pipeline project ref tag options name - allow_failure stage stage_id stage_idx + allow_failure stage stage_idx yaml_variables when description needs_attributes - scheduling_type].freeze + scheduling_type ci_stage partition_id].freeze end def inherit_status_from_downstream!(pipeline) @@ -183,6 +183,10 @@ module Ci false end + def prevent_rollback_deployment? + false + end + def expanded_environment_name end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index bf8817e6e78..4e58f877217 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -11,7 +11,7 @@ module Ci include Presentable include Importable include Ci::HasRef - include HasDeploymentName + include Ci::TrackEnvironmentUsage extend ::Gitlab::Utils::Override @@ -34,7 +34,7 @@ module Ci DEPLOYMENT_NAMES = %w[deploy release rollout].freeze - has_one :deployment, as: :deployable, class_name: 'Deployment' + has_one :deployment, as: :deployable, class_name: 'Deployment', inverse_of: :deployable has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id @@ -194,7 +194,7 @@ module Ci after_save :stick_build_if_status_changed after_create unless: :importing? do |build| - run_after_commit { build.feature_flagged_execute_hooks } + run_after_commit { build.execute_hooks } end class << self @@ -214,10 +214,11 @@ module Ci def clone_accessors %i[pipeline project ref tag options name - allow_failure stage stage_id stage_idx trigger_request + allow_failure stage stage_idx trigger_request yaml_variables when environment coverage_regex description tag_list protected needs_attributes - job_variables_attributes resource_group scheduling_type].freeze + job_variables_attributes resource_group scheduling_type + ci_stage partition_id].freeze end end @@ -285,7 +286,7 @@ module Ci build.run_after_commit do BuildQueueWorker.perform_async(id) - build.feature_flagged_execute_hooks + build.execute_hooks end end @@ -313,7 +314,7 @@ module Ci build.run_after_commit do build.ensure_persistent_ref - build.feature_flagged_execute_hooks + build.execute_hooks end end @@ -442,6 +443,15 @@ module Ci manual? && starts_environment? && deployment&.blocked? end + def prevent_rollback_deployment? + strong_memoize(:prevent_rollback_deployment) do + Feature.enabled?(:prevent_outdated_deployment_jobs, project) && + starts_environment? && + project.ci_forward_deployment_enabled? && + deployment&.older_than_last_successful_deployment? + end + end + def schedulable? self.when == 'delayed' && options[:start_in].present? end @@ -703,25 +713,7 @@ module Ci end def has_test_reports? - job_artifacts.test_reports.exists? - end - - def has_old_trace? - old_trace.present? - end - - def trace=(data) - raise NotImplementedError - end - - def old_trace - read_attribute(:trace) - end - - def erase_old_trace! - return unless has_old_trace? - - update_column(:trace, nil) + job_artifacts.of_report_type(:test).exists? end def ensure_trace_metadata! @@ -780,14 +772,6 @@ module Ci pending? && !any_runners_online? end - def feature_flagged_execute_hooks - if Feature.enabled?(:execute_build_hooks_inline, project) - execute_hooks - else - BuildHooksWorker.perform_async(self) - end - end - def execute_hooks return unless project return if user&.blocked? @@ -823,41 +807,6 @@ module Ci end end - def erase_erasable_artifacts! - if project.refreshing_build_artifacts_size? - Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh( - method: 'Ci::Build#erase_erasable_artifacts!', - project_id: project_id - ) - end - - destroyed_artifacts = job_artifacts.erasable.destroy_all # rubocop: disable Cop/DestroyAll - - Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase_erasable_artifacts!') - - destroyed_artifacts - end - - def erase(opts = {}) - return false unless erasable? - - if project.refreshing_build_artifacts_size? - Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh( - method: 'Ci::Build#erase', - project_id: project_id - ) - end - - # TODO: We should use DestroyBatchService here - # See https://gitlab.com/gitlab-org/gitlab/-/issues/369132 - destroyed_artifacts = job_artifacts.destroy_all # rubocop: disable Cop/DestroyAll - - Gitlab::Ci::Artifacts::Logger.log_deleted(destroyed_artifacts, 'Ci::Build#erase') - - erase_trace! - update_erased!(opts[:erased_by]) - end - def erasable? complete? && (artifacts? || has_job_artifacts? || has_trace?) end @@ -1004,15 +953,11 @@ module Ci end def collect_test_reports!(test_reports) - test_reports.get_suite(test_suite_name).tap do |test_suite| - each_report(Ci::JobArtifact.file_types_for_report(:test)) do |file_type, blob| - Gitlab::Ci::Parsers.fabricate!(file_type).parse!( - blob, - test_suite, - job: self - ) - end + each_report(Ci::JobArtifact.file_types_for_report(:test)) do |file_type, blob| + Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, test_reports, job: self) end + + test_reports end def collect_accessibility_reports!(accessibility_report) @@ -1154,18 +1099,6 @@ module Ci .include?(exit_code) end - def track_deployment_usage - Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment? - end - - def track_verify_usage - Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id) if user_id.present? && count_user_verification? - end - - def count_user_verification? - has_environment? && environment_action == 'verify' - end - def each_report(report_types) job_artifacts_for_types(report_types).each do |report_artifact| report_artifact.each_blob do |blob| @@ -1189,6 +1122,14 @@ module Ci job_artifacts.map(&:file_type) end + def test_suite_name + if matrix_build? + name + else + group_name + end + end + protected def run_status_commit_hooks! @@ -1199,14 +1140,6 @@ module Ci private - def test_suite_name - if matrix_build? - name - else - group_name - end - end - def matrix_build? options.dig(:parallel, :matrix).present? end @@ -1245,14 +1178,6 @@ module Ci job_artifacts.select { |artifact| artifact.file_type.in?(report_types) } end - def erase_trace! - trace.erase! - end - - def update_erased!(user = nil) - self.update(erased_by: user, erased_at: Time.current, artifacts_expire_at: nil) - end - def environment_url options&.dig(:environment, :url) || persisted_environment&.external_url end @@ -1298,7 +1223,7 @@ module Ci end def observe_report_types - return unless ::Gitlab.com? && Feature.enabled?(:report_artifact_build_completed_metrics_on_build_completion) + return unless ::Gitlab.com? report_types = options&.dig(:artifacts, :reports)&.keys || [] diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 5fc21ba3f28..3bdf2f90acb 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -9,7 +9,6 @@ module Ci include Presentable include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize - include IgnorableColumns self.table_name = 'ci_builds_metadata' @@ -39,8 +38,6 @@ module Ci job_timeout_source: 4 } - ignore_columns :runner_features, remove_with: '15.1', remove_after: '2022-05-22' - def update_timeout_state timeout = timeout_with_highest_precedence diff --git a/app/models/ci/freeze_period_status.rb b/app/models/ci/freeze_period_status.rb index befa935e750..e810bb3f229 100644 --- a/app/models/ci/freeze_period_status.rb +++ b/app/models/ci/freeze_period_status.rb @@ -13,32 +13,16 @@ module Ci end def within_freeze_period?(period) - # previous_freeze_end, ..., previous_freeze_start, ..., NOW, ..., next_freeze_end, ..., next_freeze_start - # Current time is within a freeze period if - # it falls between a previous freeze start and next freeze end - start_freeze = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone) - end_freeze = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone) - - previous_freeze_start = previous_time(start_freeze) - previous_freeze_end = previous_time(end_freeze) - next_freeze_start = next_time(start_freeze) - next_freeze_end = next_time(end_freeze) - - previous_freeze_end < previous_freeze_start && - previous_freeze_start <= time_zone_now && - time_zone_now <= next_freeze_end && - next_freeze_end < next_freeze_start - end + start_freeze_cron = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone) + end_freeze_cron = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone) - private + start_freeze = start_freeze_cron.previous_time_from(time_zone_now) + end_freeze = end_freeze_cron.next_time_from(start_freeze) - def previous_time(cron_parser) - cron_parser.previous_time_from(time_zone_now) + start_freeze <= time_zone_now && time_zone_now <= end_freeze end - def next_time(cron_parser) - cron_parser.next_time_from(time_zone_now) - end + private def time_zone_now @time_zone_now ||= Time.zone.now diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 71d33f0bb63..922806a21c3 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -2,6 +2,7 @@ module Ci class JobArtifact < Ci::ApplicationRecord + include Ci::Partitionable include IgnorableColumns include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -9,6 +10,7 @@ module Ci include UsageStatistics include Sortable include Artifactable + include Lockable include FileStoreMounter include EachBatch include Gitlab::Utils::StrongMemoize @@ -22,8 +24,7 @@ module Ci accessibility: %w[accessibility], coverage: %w[cobertura], codequality: %w[codequality], - terraform: %w[terraform], - sbom: %w[cyclonedx] + terraform: %w[terraform] }.freeze DEFAULT_FILE_NAMES = { @@ -54,7 +55,7 @@ module Ci requirements: 'requirements.json', coverage_fuzzing: 'gl-coverage-fuzzing.json', api_fuzzing: 'gl-api-fuzzing-report.json', - cyclonedx: 'gl-sbom.cdx.zip' + cyclonedx: 'gl-sbom.cdx.json' }.freeze INTERNAL_TYPES = { @@ -72,6 +73,7 @@ module Ci cobertura: :gzip, cluster_applications: :gzip, # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/361094 lsif: :zip, + cyclonedx: :gzip, # Security reports and license scanning reports are raw artifacts # because they used to be fetched by the frontend, but this is not the case anymore. @@ -94,8 +96,7 @@ module Ci terraform: :raw, requirements: :raw, coverage_fuzzing: :raw, - api_fuzzing: :raw, - cyclonedx: :zip + api_fuzzing: :raw }.freeze DOWNLOADABLE_TYPES = %w[ @@ -134,14 +135,16 @@ module Ci mount_file_store_uploader JobArtifactUploader, skip_store_file: true + before_save :set_size, if: :file_changed? after_save :store_file_in_transaction!, unless: :store_after_commit? after_commit :store_file_after_transaction!, on: [:create, :update], if: :store_after_commit? + validates :job, presence: true validates :file_format, presence: true, unless: :trace?, on: :create validate :validate_file_format!, unless: :trace?, on: :create - before_save :set_size, if: :file_changed? update_project_statistics project_statistics_name: :build_artifacts_size + partitionable scope: :job scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) } scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) } @@ -160,12 +163,6 @@ module Ci where(file_type: types) end - REPORT_FILE_TYPES.each do |report_type, file_types| - scope "#{report_type}_reports", -> do - with_file_types(file_types) - end - end - scope :all_reports, -> do with_file_types(REPORT_TYPES.keys.map(&:to_s)) end @@ -229,25 +226,20 @@ module Ci hashed_path: 2 } - # `locked` will be populated from the source of truth on Ci::Pipeline - # in order to clean up expired job artifacts in a performant way. - # The values should be the same as `Ci::Pipeline.lockeds` with the - # additional value of `unknown` to indicate rows that have not - # yet been populated from the parent Ci::Pipeline - enum locked: { - unlocked: 0, - artifacts_locked: 1, - unknown: 2 - }, _prefix: :artifact - def validate_file_format! unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym errors.add(:base, _('Invalid file format with specified file type')) end end + def self.of_report_type(report_type) + file_types = file_types_for_report(report_type) + + with_file_types(file_types) + end + def self.file_types_for_report(report_type) - REPORT_FILE_TYPES.fetch(report_type) + REPORT_FILE_TYPES.fetch(report_type) { raise ArgumentError, "Unrecognized report type: #{report_type}" } end def self.associated_file_types_for(file_type) diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index 3a5765aa00c..26a49d6a730 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -30,10 +30,7 @@ module Ci end def all_projects - Project.from_union([ - Project.id_in(source_project), - Project.id_in(target_project_ids) - ], remove_duplicates: false) + Project.from_union(target_projects, remove_duplicates: false) end private @@ -41,6 +38,13 @@ module Ci def target_project_ids Ci::JobToken::ProjectScopeLink.from_project(source_project).pluck(:target_project_id) end + + def target_projects + [ + Project.id_in(source_project), + Project.id_in(target_project_ids) + ] + end end end end diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb index e8f08db597f..5ea51fbe0a7 100644 --- a/app/models/ci/namespace_mirror.rb +++ b/app/models/ci/namespace_mirror.rb @@ -43,20 +43,6 @@ module Ci upsert({ namespace_id: event.namespace_id, traversal_ids: traversal_ids }, unique_by: :namespace_id) - - # It won't be necessary once we remove `sync_traversal_ids`. - # More info: https://gitlab.com/gitlab-org/gitlab/-/issues/347541 - sync_children_namespaces!(event.namespace_id, traversal_ids) - end - - private - - def sync_children_namespaces!(namespace_id, traversal_ids) - by_group_and_descendants(namespace_id) - .where.not(namespace_id: namespace_id) - .update_all( - "traversal_ids = ARRAY[#{sanitize_sql(traversal_ids.join(','))}]::int[] || traversal_ids[array_position(traversal_ids, #{sanitize_sql(namespace_id)}) + 1:]" - ) end end end diff --git a/app/models/ci/partition.rb b/app/models/ci/partition.rb new file mode 100644 index 00000000000..d773038df01 --- /dev/null +++ b/app/models/ci/partition.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Ci + class Partition < Ci::ApplicationRecord + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a94330270e2..1e328c3c573 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -2,6 +2,7 @@ module Ci class Pipeline < Ci::ApplicationRecord + include Ci::Partitionable include Ci::HasStatus include Importable include AfterCommitQueue @@ -31,7 +32,7 @@ module Ci sha_attribute :source_sha sha_attribute :target_sha - + partitionable scope: ->(_) { Ci::Pipeline.current_partition_value } # Ci::CreatePipelineService returns Ci::Pipeline so this is the only place # where we can pass additional information from the service. This accessor # is used for storing the processed metadata for linting purposes. @@ -296,6 +297,12 @@ module Ci end end + after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline| + pipeline.run_after_commit do + ::Ci::JobArtifacts::TrackArtifactReportWorker.perform_async(pipeline.id) + end + end + after_transition any => ::Ci::Pipeline.stopped_statuses do |pipeline| pipeline.run_after_commit do pipeline.persistent_ref.delete @@ -422,6 +429,10 @@ module Ci end def self.jobs_count_in_alive_pipelines + created_after(24.hours.ago).alive.joins(:statuses).count + end + + def self.builds_count_in_alive_pipelines created_after(24.hours.ago).alive.joins(:builds).count end @@ -472,8 +483,12 @@ module Ci @auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines') end + def self.current_partition_value + 100 + end + def uses_needs? - builds.where(scheduling_type: :dag).any? + processables.where(scheduling_type: :dag).any? end def stages_count @@ -605,7 +620,7 @@ module Ci if cascade_to_children # cancel any bridges that could spin up new child pipelines - cancel_jobs(bridges_in_self_and_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) + cancel_jobs(bridges_in_self_and_project_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id) cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async) end end @@ -937,26 +952,26 @@ module Ci ).base_and_descendants.select(:id) end - def build_with_artifacts_in_self_and_descendants(name) - builds_in_self_and_descendants + def build_with_artifacts_in_self_and_project_descendants(name) + builds_in_self_and_project_descendants .ordered_by_pipeline # find job in hierarchical order .with_downloadable_artifacts .find_by_name(name) end - def builds_in_self_and_descendants - Ci::Build.latest.where(pipeline: self_and_descendants) + def builds_in_self_and_project_descendants + Ci::Build.latest.where(pipeline: self_and_project_descendants) end - def bridges_in_self_and_descendants - Ci::Bridge.latest.where(pipeline: self_and_descendants) + def bridges_in_self_and_project_descendants + Ci::Bridge.latest.where(pipeline: self_and_project_descendants) end - def environments_in_self_and_descendants(deployment_status: nil) + def environments_in_self_and_project_descendants(deployment_status: nil) # We limit to 100 unique environments for application safety. # See: https://gitlab.com/gitlab-org/gitlab/-/issues/340781#note_699114700 expanded_environment_names = - builds_in_self_and_descendants.joins(:metadata) + builds_in_self_and_project_descendants.joins(:metadata) .where.not('ci_builds_metadata.expanded_environment_name' => nil) .distinct('ci_builds_metadata.expanded_environment_name') .limit(100) @@ -971,17 +986,22 @@ module Ci end # With multi-project and parent-child pipelines - def all_pipelines_in_hierarchy + def self_and_downstreams + object_hierarchy.base_and_descendants + end + + # With multi-project and parent-child pipelines + def upstream_and_all_downstreams object_hierarchy.all_objects end # With only parent-child pipelines - def self_and_ancestors + def self_and_project_ancestors object_hierarchy(project_condition: :same).base_and_ancestors end # With only parent-child pipelines - def self_and_descendants + def self_and_project_descendants object_hierarchy(project_condition: :same).base_and_descendants end @@ -990,8 +1010,8 @@ module Ci object_hierarchy(project_condition: :same).descendants end - def self_and_descendants_complete? - self_and_descendants.all?(&:complete?) + def self_and_project_descendants_complete? + self_and_project_descendants.all?(&:complete?) end # Follow the parent-child relationships and return the top-level parent @@ -1006,7 +1026,12 @@ module Ci # Follow the upstream pipeline relationships, regardless of multi-project or # parent-child, and return the top-level ancestor. def upstream_root - object_hierarchy.base_and_ancestors(hierarchy_order: :desc).first + @upstream_root ||= object_hierarchy.base_and_ancestors(hierarchy_order: :desc).first + end + + # Applies to all parent-child and multi-project pipelines + def complete_hierarchy_count + upstream_root.self_and_downstreams.count end def bridge_triggered? @@ -1052,11 +1077,11 @@ module Ci end def latest_test_report_builds - latest_report_builds(Ci::JobArtifact.test_reports).preload(:project, :metadata) + latest_report_builds(Ci::JobArtifact.of_report_type(:test)).preload(:project, :metadata) end - def latest_report_builds_in_self_and_descendants(reports_scope = ::Ci::JobArtifact.all_reports) - builds_in_self_and_descendants.with_artifacts(reports_scope) + def latest_report_builds_in_self_and_project_descendants(reports_scope = ::Ci::JobArtifact.all_reports) + builds_in_self_and_project_descendants.with_artifacts(reports_scope) end def builds_with_coverage @@ -1068,10 +1093,14 @@ module Ci end def has_reports?(reports_scope) + latest_report_builds(reports_scope).exists? + end + + def complete_and_has_reports?(reports_scope) if Feature.enabled?(:mr_show_reports_immediately, project, type: :development) latest_report_builds(reports_scope).exists? else - complete? && latest_report_builds(reports_scope).exists? + complete? && has_reports?(reports_scope) end end @@ -1084,7 +1113,7 @@ module Ci end def can_generate_codequality_reports? - has_reports?(Ci::JobArtifact.codequality_reports) + complete_and_has_reports?(Ci::JobArtifact.of_report_type(:codequality)) end def test_report_summary @@ -1103,7 +1132,7 @@ module Ci def accessibility_reports Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports| - latest_report_builds(Ci::JobArtifact.accessibility_reports).each do |build| + latest_report_builds(Ci::JobArtifact.of_report_type(:accessibility)).each do |build| build.collect_accessibility_reports!(accessibility_reports) end end @@ -1111,7 +1140,7 @@ module Ci def codequality_reports Gitlab::Ci::Reports::CodequalityReports.new.tap do |codequality_reports| - latest_report_builds(Ci::JobArtifact.codequality_reports).each do |build| + latest_report_builds(Ci::JobArtifact.of_report_type(:codequality)).each do |build| build.collect_codequality_reports!(codequality_reports) end end @@ -1119,7 +1148,7 @@ module Ci def terraform_reports ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports| - latest_report_builds(::Ci::JobArtifact.terraform_reports).each do |build| + latest_report_builds(::Ci::JobArtifact.of_report_type(:terraform)).each do |build| build.collect_terraform_reports!(terraform_reports) end end @@ -1307,7 +1336,7 @@ module Ci def has_test_reports? strong_memoize(:has_test_reports) do - has_reports?(::Ci::JobArtifact.test_reports) + has_reports?(::Ci::JobArtifact.of_report_type(:test)) end end diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index cdc3d69f754..6d22a875aab 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -7,6 +7,7 @@ module Ci include UpdateProjectStatistics include Artifactable include FileStoreMounter + include Lockable include Presentable FILE_SIZE_LIMIT = 10.megabytes.freeze @@ -52,7 +53,7 @@ module Ci find_by(file_type: file_type) end - def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:) + def create_or_replace_for_pipeline!(pipeline:, file_type:, file:, size:, locked: :unknown) transaction do pipeline.pipeline_artifacts.find_by_file_type(file_type)&.destroy! @@ -62,7 +63,8 @@ module Ci size: size, file: file, file_format: REPORT_TYPES[file_type], - expire_at: EXPIRATION_DATE.from_now + expire_at: EXPIRATION_DATE.from_now, + locked: locked ) end rescue ActiveRecord::ActiveRecordError => err diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index 3dca77af051..6e4418bc360 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -2,13 +2,16 @@ module Ci class PipelineVariable < Ci::ApplicationRecord + include Ci::Partitionable include Ci::HasVariable belongs_to :pipeline + partitionable scope: :pipeline + alias_attribute :secret_value, :value - validates :key, presence: true + validates :key, :pipeline, presence: true def hook_attrs { key: key, value: value } diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index a2ff49077be..09dc9d4bce1 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -3,6 +3,7 @@ module Ci class Processable < ::CommitStatus include Gitlab::Utils::StrongMemoize + include FromUnion extend ::Gitlab::Utils::Override has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 6c3754d84d0..28d9edcc135 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -8,15 +8,12 @@ module Ci include ChronicDurationAttribute include FromUnion include TokenAuthenticatable - include IgnorableColumns include FeatureGate include Gitlab::Utils::StrongMemoize include TaggableQueries include Presentable include EachBatch - ignore_column :semver, remove_with: '15.4', remove_after: '2022-08-22' - add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced? enum access_level: { @@ -351,6 +348,12 @@ module Ci end end + def owner_project + return unless project_type? + + runner_projects.order(:id).first.project + end + def belongs_to_one_project? runner_projects.count == 1 end @@ -359,14 +362,6 @@ module Ci runner_projects.limit(2).count(:all) > 1 end - def assigned_to_group? - runner_namespaces.any? - end - - def assigned_to_project? - runner_projects.any? - end - def match_build_if_online?(build) active? && online? && matches_build?(build) end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index f03d1e96a4b..46a9e3f6494 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -2,22 +2,31 @@ module Ci class Stage < Ci::ApplicationRecord + include Ci::Partitionable include Importable include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable + partitionable scope: :pipeline + enum status: Ci::HasStatus::STATUSES_ENUM belongs_to :project belongs_to :pipeline - has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id - has_many :latest_statuses, -> { ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id - has_many :retried_statuses, -> { ordered.retried }, class_name: 'CommitStatus', foreign_key: :stage_id - has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id - has_many :builds, foreign_key: :stage_id - has_many :bridges, foreign_key: :stage_id + has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id, inverse_of: :ci_stage + has_many :latest_statuses, -> { ordered.latest }, + class_name: 'CommitStatus', + foreign_key: :stage_id, + inverse_of: :ci_stage + has_many :retried_statuses, -> { ordered.retried }, + class_name: 'CommitStatus', + foreign_key: :stage_id, + inverse_of: :ci_stage + has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id, inverse_of: :ci_stage + has_many :builds, foreign_key: :stage_id, inverse_of: :ci_stage + has_many :bridges, foreign_key: :stage_id, inverse_of: :ci_stage scope :ordered, -> { order(position: :asc) } scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index c4db4754c52..1092b9c9564 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -6,6 +6,8 @@ module Ci include Limitable include IgnorableColumns + TRIGGER_TOKEN_PREFIX = 'glptt-' + ignore_column :ref, remove_with: '15.4', remove_after: '2022-08-22' self.limit_name = 'pipeline_triggers' @@ -22,7 +24,7 @@ module Ci before_validation :set_default_values def set_default_values - self.token = SecureRandom.hex(15) if self.token.blank? + self.token = "#{TRIGGER_TOKEN_PREFIX}#{SecureRandom.hex(20)}" if self.token.blank? end def last_trigger_request @@ -34,7 +36,7 @@ module Ci end def short_token - token[0...4] if token.present? + token.delete_prefix(TRIGGER_TOKEN_PREFIX)[0...4] if token.present? end def can_access_project? diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 3a8c314efe4..27550616002 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -16,14 +16,10 @@ module Clusters include ::Clusters::Concerns::ApplicationData include AfterCommitQueue include UsageStatistics - include IgnorableColumns default_value_for :ingress_type, :nginx default_value_for :version, VERSION - ignore_column :modsecurity_enabled, remove_with: '14.2', remove_after: '2021-07-22' - ignore_column :modsecurity_mode, remove_with: '14.2', remove_after: '2021-07-22' - enum ingress_type: { nginx: 1 } diff --git a/app/models/commit.rb b/app/models/commit.rb index bd60f02b532..54de45ebba7 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -133,6 +133,22 @@ class Commit def parent_class ::Project end + + def build_from_sidekiq_hash(project, hash) + hash = hash.dup + date_suffix = '_date' + + # When processing Sidekiq payloads various timestamps are stored as Strings. + # Commit in turn expects Time-like instances upon input, so we have to + # manually parse these values. + hash.each do |key, value| + if key.to_s.end_with?(date_suffix) && value.is_a?(String) + hash[key] = Time.zone.parse(value) + end + end + + from_hash(hash, project) + end end attr_accessor :raw diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index afe4927ee73..05a258e6e26 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class CommitStatus < Ci::ApplicationRecord + include Ci::Partitionable include Ci::HasStatus include Importable include AfterCommitQueue @@ -11,13 +12,14 @@ class CommitStatus < Ci::ApplicationRecord include IgnorableColumns self.table_name = 'ci_builds' - - ignore_column :token, remove_with: '15.4', remove_after: '2022-08-22' + partitionable scope: :pipeline + ignore_column :trace, remove_with: '15.6', remove_after: '2022-10-22' belongs_to :user belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' + belongs_to :ci_stage, class_name: 'Ci::Stage', foreign_key: :stage_id has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build @@ -318,6 +320,10 @@ class CommitStatus < Ci::ApplicationRecord Gitlab::EtagCaching::Store.new.touch(job_path) end + def stage_name + ci_stage&.name + end + private def unrecoverable_failure? diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable.rb index 8240f9bd6ea..1566c53217d 100644 --- a/app/models/concerns/approvable_base.rb +++ b/app/models/concerns/approvable.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ApprovableBase +module Approvable extend ActiveSupport::Concern include FromUnion @@ -27,12 +27,11 @@ module ApprovableBase scope :not_approved_by_users_with_usernames, -> (usernames) do users = User.where(username: usernames).select(:id) - self_table = self.arel_table app_table = Approval.arel_table where( Approval.where(approvals: { user_id: users }) - .where(app_table[:merge_request_id].eq(self_table[:id])) + .where(app_table[:merge_request_id].eq(arel_table[:id])) .select('true') .arel.exists.not ) @@ -48,7 +47,7 @@ module ApprovableBase def approved_by?(user) return false unless user - approved_by_users.include?(user) + approvals.where(user: user).any? end def can_be_approved_by?(user) @@ -59,3 +58,5 @@ module ApprovableBase user && approved_by?(user) && user.can?(:approve_merge_request, self) end end + +Approvable.prepend_mod diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index ee8e98ec1bf..3fdbd6a8789 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -10,8 +10,17 @@ module Ci STORE_COLUMN = :file_store NotSupportedAdapterError = Class.new(StandardError) FILE_FORMAT_ADAPTERS = { + # While zip is a streamable file format, performing streaming + # reads requires that each entry in the zip has certain headers + # present at the front of the entry. These headers are OPTIONAL + # according to the file format specification. GitLab Runner uses + # Go's `archive/zip` to create zip archives, which does not include + # these headers. Go maintainers have expressed that they don't intend + # to support them: https://github.com/golang/go/issues/23301#issuecomment-363240781 + # + # If you need GitLab to be able to read Artifactables, store them in + # raw or gzip format instead of zip. gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream, - zip: Gitlab::Ci::Build::Artifacts::Adapters::ZipStream, raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream }.freeze diff --git a/app/models/concerns/ci/has_deployment_name.rb b/app/models/concerns/ci/has_deployment_name.rb deleted file mode 100644 index 887653e846e..00000000000 --- a/app/models/concerns/ci/has_deployment_name.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Ci - module HasDeploymentName - extend ActiveSupport::Concern - - def count_user_deployment? - deployment_name? - end - - def deployment_name? - self.class::DEPLOYMENT_NAMES.any? { |n| name.downcase.include?(n) } - end - end -end diff --git a/app/models/concerns/ci/lockable.rb b/app/models/concerns/ci/lockable.rb new file mode 100644 index 00000000000..31ba93775e2 --- /dev/null +++ b/app/models/concerns/ci/lockable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + module Lockable + extend ActiveSupport::Concern + + included do + # `locked` will be populated from the source of truth on Ci::Pipeline + # in order to clean up expired job artifacts in a performant way. + # The values should be the same as `Ci::Pipeline.lockeds` with the + # additional value of `unknown` to indicate rows that have not + # yet been populated from the parent Ci::Pipeline + enum locked: { + unlocked: 0, + artifacts_locked: 1, + unknown: 2 + }, _prefix: :artifact + end + end +end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 8c3a05c23f0..71b26b70bbf 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -34,7 +34,7 @@ module Ci end def ensure_metadata - metadata || build_metadata(project: project) + metadata || build_metadata(project: project, partition_id: partition_id) end def degenerated? diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb new file mode 100644 index 00000000000..710ee1ba64f --- /dev/null +++ b/app/models/concerns/ci/partitionable.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements a way to set the `partion_id` value on a dependent + # resource from a parent record. + # Usage: + # + # class PipelineVariable < Ci::ApplicationRecord + # include Ci::Partitionable + # + # belongs_to :pipeline + # partitionable scope: :pipeline + # # Or + # partitionable scope: ->(record) { record.partition_value } + # + # + module Partitionable + extend ActiveSupport::Concern + include ::Gitlab::Utils::StrongMemoize + + included do + before_validation :set_partition_id, on: :create + validates :partition_id, presence: true + + def set_partition_id + return if partition_id_changed? && partition_id.present? + return unless partition_scope_value + + self.partition_id = partition_scope_value + end + end + + class_methods do + private + + def partitionable(scope:) + define_method(:partition_scope_value) do + strong_memoize(:partition_scope_value) do + record = scope.to_proc.call(self) + record.respond_to?(:partition_id) ? record.partition_id : record + end + end + end + end + end +end diff --git a/app/models/concerns/ci/track_environment_usage.rb b/app/models/concerns/ci/track_environment_usage.rb new file mode 100644 index 00000000000..45d9cdeeb59 --- /dev/null +++ b/app/models/concerns/ci/track_environment_usage.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + module TrackEnvironmentUsage + extend ActiveSupport::Concern + + def track_deployment_usage + return unless user_id.present? && count_user_deployment? + + Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) + end + + def track_verify_environment_usage + return unless user_id.present? && verifies_environment? + + Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_verify_environment_job', user_id) + end + + def verifies_environment? + has_environment? && environment_action == 'verify' + end + + def count_user_deployment? + deployment_name? + end + + def deployment_name? + self.class::DEPLOYMENT_NAMES.any? { |n| name.downcase.include?(n) } + end + end +end diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 65cf3246d11..64d178b7507 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -65,6 +65,10 @@ module CounterAttribute def counter_attribute_after_flush(&callback) after_flush_callbacks << callback end + + def counter_attribute_enabled?(attribute) + counter_attributes.include?(attribute) + end end # This method must only be called by FlushCounterIncrementsWorker @@ -103,16 +107,14 @@ module CounterAttribute end def delayed_increment_counter(attribute, increment) + raise ArgumentError, "#{attribute} is not a counter attribute" unless counter_attribute_enabled?(attribute) + return if increment == 0 run_after_commit_or_now do - if counter_attribute_enabled?(attribute) - increment_counter(attribute, increment) + increment_counter(attribute, increment) - FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute) - else - legacy_increment!(attribute, increment) - end + FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute) end true @@ -157,7 +159,7 @@ module CounterAttribute end def counter_attribute_enabled?(attribute) - self.class.counter_attributes.include?(attribute) + self.class.counter_attribute_enabled?(attribute) end private @@ -168,10 +170,6 @@ module CounterAttribute end end - def legacy_increment!(attribute, increment) - increment!(attribute, increment) - end - def unsafe_update_counters(id, increments) self.class.update_counters(id, increments) end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index ecb120d8013..9de2da5aac3 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -19,7 +19,7 @@ module Enums unmet_prerequisites: 10, scheduler_failure: 11, data_integrity_failure: 12, - forward_deployment_failure: 13, + forward_deployment_failure: 13, # Deprecated in favor of failed_outdated_deployment_job. user_blocked: 14, project_deleted: 15, ci_quota_exceeded: 16, @@ -29,6 +29,7 @@ module Enums builds_disabled: 20, environment_creation_failure: 21, deployment_rejected: 22, + failed_outdated_deployment_job: 23, protected_environment_failure: 1_000, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, @@ -39,7 +40,8 @@ module Enums downstream_pipeline_creation_failed: 1_007, secrets_provider_not_found: 1_008, reached_max_descendant_pipelines_depth: 1_009, - ip_restriction_failure: 1_010 + ip_restriction_failure: 1_010, + reached_max_pipeline_hierarchy_size: 1_011 } end end diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index 71c86bab136..a8227363a22 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -16,7 +16,8 @@ module Enums alert_management_alerts: 8, sprints: 9, # iterations design_management_designs: 10, - incident_management_oncall_schedules: 11 + incident_management_oncall_schedules: 11, + ml_experiments: 12 } end end diff --git a/app/models/concerns/from_set_operator.rb b/app/models/concerns/from_set_operator.rb index ce3a83e9fa1..56b788eb1ab 100644 --- a/app/models/concerns/from_set_operator.rb +++ b/app/models/concerns/from_set_operator.rb @@ -10,7 +10,9 @@ module FromSetOperator raise "Trying to redefine method '#{method(method_name)}'" if methods.include?(method_name) - define_method(method_name) do |members, remove_duplicates: true, remove_order: true, alias_as: table_name| + define_method(method_name) do |*members, remove_duplicates: true, remove_order: true, alias_as: table_name| + members = flatten_ar_array(members) + operator_sql = if members.any? operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql @@ -20,5 +22,26 @@ module FromSetOperator from(Arel.sql("(#{operator_sql}) #{alias_as}")) end + + # Array#flatten with ActiveRecord::Relation items will load the ActiveRecord::Relation. + # Therefore we need to roll our own flatten method. + unless method_defined?(:flatten_ar_array) # rubocop:disable Style/GuardClause + define_method :flatten_ar_array do |ary| + arrays = ary.dup + result = [] + + until arrays.empty? + item = arrays.shift + if item.is_a?(Array) + arrays.concat(item.dup) + else + result.push(item) + end + end + + result + end + private :flatten_ar_array + end end end diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb index 142e62bb501..1ecddc015ab 100644 --- a/app/models/concerns/integrations/slack_mattermost_notifier.rb +++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb @@ -21,13 +21,13 @@ module Integrations ) responses.each do |response| - unless response.success? - log_error('SlackMattermostNotifier HTTP error response', - request_host: response.request.uri.host, - response_code: response.code, - response_body: response.body - ) - end + next if response.success? + + log_error('SlackMattermostNotifier HTTP error response', + request_host: response.request.uri.host, + response_code: response.code, + response_body: response.body + ) end end diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb index 18ec1c253e1..412b1da55da 100644 --- a/app/models/concerns/merge_request_reviewer_state.rb +++ b/app/models/concerns/merge_request_reviewer_state.rb @@ -6,20 +6,11 @@ module MergeRequestReviewerState included do enum state: { unreviewed: 0, - reviewed: 1, - attention_requested: 2 + reviewed: 1 } validates :state, presence: true, inclusion: { in: self.states.keys } - - belongs_to :updated_state_by, class_name: 'User', foreign_key: :updated_state_by_user_id - - def attention_requested_by - return unless attention_requested? - - updated_state_by - end end end diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb index 813827478da..335fcec2611 100644 --- a/app/models/concerns/pg_full_text_searchable.rb +++ b/app/models/concerns/pg_full_text_searchable.rb @@ -108,6 +108,7 @@ module PgFullTextSearchable # This fixes an inconsistency with how to_tsvector and websearch_to_tsquery process URLs # See https://gitlab.com/gitlab-org/gitlab/-/issues/354784#note_905431920 search_term = remove_url_scheme(search_term) + search_term = ActiveSupport::Inflector.transliterate(search_term) joins(:search_data).where( Arel::Nodes::InfixOperation.new( diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 7613691bc2e..2976b6f02a7 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -86,6 +86,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:operations_access_level, value) end + def monitor_access_level=(value) + write_feature_attribute_string(:monitor_access_level, value) + end + def security_and_compliance_access_level=(value) write_feature_attribute_string(:security_and_compliance_access_level, value) end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index 65fb62a814f..eccb004b503 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -43,6 +43,33 @@ module Sortable } end + def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:) + reversed_direction = direction == :asc ? :desc : :asc + + # rubocop: disable GitlabSecurity/PublicSend + order = ::Gitlab::Pagination::Keyset::Order.build( + [ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: attribute_name, + column_expression: column, + order_expression: column.send(direction).send(nullable), + reversed_order_expression: column.send(reversed_direction).send(nullable), + order_direction: direction, + distinct: false, + add_to_projections: true, + nullable: nullable + ), + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table['id'].desc + ) + ] + ) + # rubocop: enable GitlabSecurity/PublicSend + + order.apply_cursor_conditions(scope).reorder(order) + end + private def highest_label_priority(target_type_column: nil, target_type: nil, target_column:, project_column:, excluded_labels: []) diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index e10452c1081..14520b2da26 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -263,10 +263,10 @@ class ContainerRepository < ApplicationRecord .with_migration_import_started_at_nil_or_before(before_timestamp) union = ::Gitlab::SQL::Union.new([ - stale_pre_importing, - stale_pre_import_done, - stale_importing - ]) + stale_pre_importing, + stale_pre_import_done, + stale_importing + ]) from("(#{union.to_sql}) #{ContainerRepository.table_name}") end @@ -598,6 +598,7 @@ class ContainerRepository < ApplicationRecord tags_response_body.map do |raw_tag| tag = ContainerRegistry::Tag.new(self, raw_tag['name']) tag.force_created_at_from_iso8601(raw_tag['created_at']) + tag.updated_at = raw_tag['updated_at'] tag end end diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index f6455da890b..16c741d340f 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -79,22 +79,23 @@ class CustomerRelations::Contact < ApplicationRecord end def self.sort_by_name - order(Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'last_name', - order_expression: arel_table[:last_name].asc, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'first_name', - order_expression: arel_table[:first_name].asc, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'id', - order_expression: arel_table[:id].asc - ) - ])) + order(Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'last_name', + order_expression: arel_table[:last_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'first_name', + order_expression: arel_table[:first_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].asc + ) + ])) end def self.find_ids_by_emails(group, emails) @@ -117,22 +118,14 @@ class CustomerRelations::Contact < ApplicationRecord JOIN #{table_name} AS new_contacts ON new_contacts.group_id = :old_group_id AND LOWER(new_contacts.email) = LOWER(existing_contacts.email) WHERE existing_contacts.group_id = :new_group_id AND contact_id = existing_contacts.id SQL - connection.execute(sanitize_sql([ - update_query, - old_group_id: group.root_ancestor.id, - new_group_id: group.id - ])) + connection.execute(sanitize_sql([update_query, old_group_id: group.root_ancestor.id, new_group_id: group.id])) dupes_query = <<~SQL DELETE FROM #{table_name} AS existing_contacts USING #{table_name} AS new_contacts WHERE existing_contacts.group_id = :new_group_id AND new_contacts.group_id = :old_group_id AND LOWER(new_contacts.email) = LOWER(existing_contacts.email) SQL - connection.execute(sanitize_sql([ - dupes_query, - old_group_id: group.root_ancestor.id, - new_group_id: group.id - ])) + connection.execute(sanitize_sql([dupes_query, old_group_id: group.root_ancestor.id, new_group_id: group.id])) where(group: group).update_all(group_id: group.root_ancestor.id) end diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb index 705e84250c9..5eda9b4bf15 100644 --- a/app/models/customer_relations/organization.rb +++ b/app/models/customer_relations/organization.rb @@ -23,6 +23,9 @@ class CustomerRelations::Organization < ApplicationRecord validates :description, length: { maximum: 1024 } validate :validate_root_group + scope :order_scope_asc, ->(field) { order(arel_table[field].asc.nulls_last) } + scope :order_scope_desc, ->(field) { order(arel_table[field].desc.nulls_last) } + # Searches for organizations with a matching name or description. # # This method uses ILIKE on PostgreSQL @@ -38,6 +41,14 @@ class CustomerRelations::Organization < ApplicationRecord where(state: state) end + def self.sort_by_field(field, direction) + if direction == :asc + order_scope_asc(field) + else + order_scope_desc(field) + end + end + def self.sort_by_name order(name: :asc) end @@ -55,28 +66,30 @@ class CustomerRelations::Organization < ApplicationRecord JOIN #{table_name} AS new_organizations ON new_organizations.group_id = :old_group_id AND LOWER(new_organizations.name) = LOWER(existing_organizations.name) WHERE existing_organizations.group_id = :new_group_id AND organization_id = existing_organizations.id SQL - connection.execute(sanitize_sql([ - update_query, - old_group_id: group.root_ancestor.id, - new_group_id: group.id - ])) + connection.execute(sanitize_sql([update_query, old_group_id: group.root_ancestor.id, new_group_id: group.id])) dupes_query = <<~SQL DELETE FROM #{table_name} AS existing_organizations USING #{table_name} AS new_organizations WHERE existing_organizations.group_id = :new_group_id AND new_organizations.group_id = :old_group_id AND LOWER(new_organizations.name) = LOWER(existing_organizations.name) SQL - connection.execute(sanitize_sql([ - dupes_query, - old_group_id: group.root_ancestor.id, - new_group_id: group.id - ])) + connection.execute(sanitize_sql([dupes_query, old_group_id: group.root_ancestor.id, new_group_id: group.id])) where(group: group).update_all(group_id: group.root_ancestor.id) end + def self.counts_by_state + default_state_counts.merge(group(:state).count) + end + private + def self.default_state_counts + states.keys.each_with_object({}) do |key, memo| + memo[key] = 0 + end + end + def validate_root_group return if group&.root? diff --git a/app/models/deployment.rb b/app/models/deployment.rb index a3213a59bed..dafcbc593be 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -18,7 +18,7 @@ class Deployment < ApplicationRecord belongs_to :environment, optional: false belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user - belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations + belongs_to :deployable, polymorphic: true, optional: true, inverse_of: :deployment # rubocop:disable Cop/PolymorphicAssociations has_many :deployment_merge_requests has_many :merge_requests, @@ -36,6 +36,7 @@ class Deployment < ApplicationRecord delegate :name, to: :environment, prefix: true delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true + scope :for_iid, -> (project, iid) { where(project: project, iid: iid) } scope :for_environment, -> (environment) { where(environment_id: environment) } scope :for_environment_name, -> (project, name) do where('deployments.environment_id = (?)', @@ -58,9 +59,11 @@ class Deployment < ApplicationRecord scope :finished_before, ->(date) { where('finished_at < ?', date) } scope :ordered, -> { order(finished_at: :desc) } + scope :ordered_as_upcoming, -> { order(id: :desc) } VISIBLE_STATUSES = %i[running success failed canceled blocked].freeze FINISHED_STATUSES = %i[success failed canceled].freeze + UPCOMING_STATUSES = %i[created blocked running].freeze state_machine :status, initial: :created do event :run do @@ -220,6 +223,10 @@ class Deployment < ApplicationRecord Ci::Build.where(id: deployable_ids) end + def build + deployable if deployable.is_a?(::Ci::Build) + end + class << self ## # FastDestroyAll concerns @@ -310,6 +317,16 @@ class Deployment < ApplicationRecord project.repository.ancestor?(ancestor_sha, sha) end + def older_than_last_successful_deployment? + last_deployment_id = environment.last_deployment&.id + + return false unless last_deployment_id.present? + + return false if self.id == last_deployment_id + + self.id < last_deployment_id + end + def update_merge_request_metrics! return unless environment.production? && success? @@ -436,6 +453,12 @@ class Deployment < ApplicationRecord deployable.environment_tier_from_options end + # default tag limit is 100, 0 means no limit + def tags(limit: 100) + project.repository.tag_names_contains(sha, limit: limit) + end + strong_memoize_attr :tags + private def update_status!(status) diff --git a/app/models/environment.rb b/app/models/environment.rb index 1950431446b..4b98cd02e3b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -13,6 +13,7 @@ class Environment < ApplicationRecord self.reactive_cache_work_type = :external_dependency belongs_to :project, optional: false + belongs_to :merge_request, optional: true use_fast_destroy :all_deployments nullify_if_blank :external_url @@ -30,6 +31,16 @@ class Environment < ApplicationRecord has_one :last_deployment, -> { success.ordered }, class_name: 'Deployment', inverse_of: :environment has_one :last_visible_deployment, -> { visible.order(id: :desc) }, inverse_of: :environment, class_name: 'Deployment' + Deployment::FINISHED_STATUSES.each do |status| + has_one :"last_#{status}_deployment", -> { where(status: status).ordered }, + class_name: 'Deployment', inverse_of: :environment + end + + Deployment::UPCOMING_STATUSES.each do |status| + has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming }, + class_name: 'Deployment', inverse_of: :environment + end + has_one :upcoming_deployment, -> { upcoming.order(id: :desc) }, class_name: 'Deployment', inverse_of: :environment has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment @@ -58,6 +69,7 @@ class Environment < ApplicationRecord allow_nil: true validate :safe_external_url + validate :merge_request_not_changed delegate :manual_actions, :other_manual_actions, to: :last_deployment, allow_nil: true delegate :auto_rollback_enabled?, to: :project @@ -84,11 +96,12 @@ class Environment < ApplicationRecord # Search environments which have names like the given query. # Do not set a large limit unless you've confirmed that it works on gitlab.com scale. scope :for_name_like, -> (query, limit: 5) do - where(arel_table[:name].matches("#{sanitize_sql_like query}%")).limit(limit) + where('LOWER(environments.name) LIKE LOWER(?) || \'%\'', sanitize_sql_like(query)).limit(limit) end scope :for_project, -> (project) { where(project_id: project) } scope :for_tier, -> (tier) { where(tier: tier).where.not(tier: nil) } + scope :for_type, -> (type) { where(environment_type: type) } scope :unfoldered, -> { where(environment_type: nil) } scope :with_rank, -> do select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)') @@ -431,9 +444,13 @@ class Environment < ApplicationRecord return unless value parser = ::Gitlab::Ci::Build::DurationParser.new(value) - return if parser.seconds_from_now.nil? + + return if parser.seconds_from_now.nil? && auto_stop_at.nil? self.auto_stop_at = parser.seconds_from_now + rescue ChronicDuration::DurationParseError => ex + Gitlab::ErrorTracking.track_exception(ex, project_id: self.project_id, environment_id: self.id) + raise ex end def rollout_status @@ -509,6 +526,12 @@ class Environment < ApplicationRecord self.tier ||= guess_tier end + def merge_request_not_changed + if merge_request_id_changed? && persisted? + errors.add(:merge_request, 'merge_request cannot be changed') + end + end + # Guessing the tier of the environment if it's not explicitly specified by users. # See https://en.wikipedia.org/wiki/Deployment_environment for industry standard deployment environments def guess_tier diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 43b2c7899a1..d06d0a99948 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -100,7 +100,7 @@ class EnvironmentStatus def self.build_environments_status(mr, user, pipeline) return [] unless pipeline - pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment| + pipeline.environments_in_self_and_project_descendants.includes(:project).available.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 4953f24755c..12d73ef0d72 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -23,6 +23,7 @@ module ErrorTracking self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] } self.reactive_cache_work_type = :external_dependency + self.reactive_cache_hard_limit = ErrorTracking::SentryClient::RESPONSE_SIZE_LIMIT self.table_name = 'project_error_tracking_settings' @@ -103,9 +104,18 @@ module ErrorTracking api_host end + def sentry_response_limit_enabled? + Feature.enabled?(:error_tracking_sentry_limit, project) + end + + def reactive_cache_limit_enabled? + sentry_response_limit_enabled? + end + def sentry_client strong_memoize(:sentry_client) do - ::ErrorTracking::SentryClient.new(api_url, token) + ::ErrorTracking::SentryClient + .new(api_url, token, validate_size_guarded_by_feature_flag: sentry_response_limit_enabled?) end end @@ -127,14 +137,14 @@ module ErrorTracking def issue_details(opts = {}) with_reactive_cache('issue_details', opts.stringify_keys) do |result| - ensure_issue_belongs_to_project!(result[:issue].project_id) + ensure_issue_belongs_to_project!(result[:issue].project_id) if result[:issue] result end end def issue_latest_event(opts = {}) with_reactive_cache('issue_latest_event', opts.stringify_keys) do |result| - ensure_issue_belongs_to_project!(result[:latest_event].project_id) + ensure_issue_belongs_to_project!(result[:latest_event].project_id) if result[:latest_event] result end end diff --git a/app/models/group.rb b/app/models/group.rb index 55455d85531..1445e71b0bc 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -153,7 +153,7 @@ class Group < Namespace after_create :post_create_hook after_destroy :post_destroy_hook - after_save :update_two_factor_requirement + after_commit :update_two_factor_requirement after_update :path_changed_hook, if: :saved_change_to_path? after_create -> { create_or_load_association(:group_feature) } @@ -186,6 +186,27 @@ class Group < Namespace where(project_creation_level: permitted_levels) end + scope :shared_into_ancestors, -> (group) do + joins(:shared_group_links) + .where(group_group_links: { shared_group_id: group.self_and_ancestors }) + end + + # WARNING: This method should never be used on its own + # please do make sure the number of rows you are filtering is small + # enough for this query + # + # It's a replacement for `public_or_visible_to_user` that correctly + # supports subgroup permissions + scope :accessible_to_user, -> (user) do + if user + Preloaders::GroupPolicyPreloader.new(self, user).execute + + select { |group| user.can?(:read_group, group) } + else + public_to_user + end + end + class << self def sort_by_attribute(method) if method == 'storage_size_desc' @@ -614,11 +635,11 @@ class Group < Namespace # 4. They belong to an ancestor group def direct_and_indirect_users User.from_union([ - User - .where(id: direct_and_indirect_members.select(:user_id)) - .reorder(nil), - project_users_with_descendants - ]) + User + .where(id: direct_and_indirect_members.select(:user_id)) + .reorder(nil), + project_users_with_descendants + ]) end # Returns all users (also inactive) that are members of the group because: @@ -628,11 +649,11 @@ class Group < Namespace # 4. They belong to an ancestor group def direct_and_indirect_users_with_inactive User.from_union([ - User - .where(id: direct_and_indirect_members_with_inactive.select(:user_id)) - .reorder(nil), - project_users_with_descendants - ]) + User + .where(id: direct_and_indirect_members_with_inactive.select(:user_id)) + .reorder(nil), + project_users_with_descendants + ]) end def users_count @@ -672,14 +693,6 @@ class Group < Namespace } end - def ci_variables_for(ref, project, environment: nil) - cache_key = "ci_variables_for:group:#{self&.id}:project:#{project&.id}:ref:#{ref}:environment:#{environment}" - - ::Gitlab::SafeRequestStore.fetch(cache_key) do - uncached_ci_variables_for(ref, project, environment: environment) - end - end - def member(user) if group_members.loaded? group_members.find { |gm| gm.user_id == user.id } @@ -890,6 +903,18 @@ class Group < Namespace end end + def packages_policy_subject + if Feature.enabled?(:read_package_policy_rule, self) + ::Packages::Policies::Group.new(self) + else + self + end + end + + def update_two_factor_requirement_for_members + direct_and_indirect_members.find_each(&:update_two_factor_requirement) + end + private def feature_flag_enabled_for_self_or_ancestor?(feature_flag) @@ -912,7 +937,7 @@ class Group < Namespace def update_two_factor_requirement return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period? - direct_and_indirect_members.find_each(&:update_two_factor_requirement) + Groups::UpdateTwoFactorRequirementForMembersWorker.perform_async(self.id) end def path_changed_hook @@ -1031,26 +1056,6 @@ class Group < Namespace def enable_shared_runners! update!(shared_runners_enabled: true) end - - def uncached_ci_variables_for(ref, project, environment: nil) - list_of_ids = if root_ancestor.use_traversal_ids? - [self] + ancestors(hierarchy_order: :asc) - else - [self] + ancestors - end - - variables = Ci::GroupVariable.where(group: list_of_ids) - variables = variables.unprotected unless project.protected_for?(ref) - - variables = if environment - variables.on_environment(environment) - else - variables.where(environment_scope: '*') - end - - variables = variables.group_by(&:group_id) - list_of_ids.reverse.flat_map { |group| variables[group.id] }.compact - end end Group.prepend_mod_with('Group') diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index 8dd245a6ab5..7005c8593bd 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -19,6 +19,10 @@ class GroupGroupLink < ApplicationRecord where(group_access: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER]) end + scope :with_owner_access, -> do + where(group_access: [Gitlab::Access::OWNER]) + end + scope :groups_accessible_via, -> (shared_with_group_ids) do links = where(shared_with_group_id: shared_with_group_ids) # a group share also gives you access to the descendants of the group being shared, diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index 24e5f193a32..3fc3f193f19 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -25,7 +25,7 @@ class WebHookLog < ApplicationRecord before_save :redact_author_email def self.recent - where('created_at >= ?', 2.days.ago.beginning_of_day) + where(created_at: 2.days.ago.beginning_of_day..Time.zone.now) .order(created_at: :desc) end diff --git a/app/models/incident_management/timeline_event.rb b/app/models/incident_management/timeline_event.rb index d30d6906e14..dd0d3c6585d 100644 --- a/app/models/incident_management/timeline_event.rb +++ b/app/models/incident_management/timeline_event.rb @@ -20,6 +20,6 @@ module IncidentManagement validates :action, presence: true, length: { maximum: 128 } validates :note, :note_html, presence: true, length: { maximum: 10_000 } - scope :order_occurred_at_asc, -> { reorder(occurred_at: :asc) } + scope :order_occurred_at_asc_id_asc, -> { reorder(occurred_at: :asc, id: :asc) } end end diff --git a/app/models/integration.rb b/app/models/integration.rb index 6d755016380..aecf9529a14 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -401,9 +401,9 @@ class Integration < ApplicationRecord .or(where(type: integration.type, instance: true)).select(:id) from_union([ - where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants), - where(type: integration.type, inherit_from_id: inherit_from_ids, project: Project.in_namespace(integration.group.self_and_descendants)) - ]) + where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants), + where(type: integration.type, inherit_from_id: inherit_from_ids, project: Project.in_namespace(integration.group.self_and_descendants)) + ]) end def activated? diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index bb0fb6b9079..4479725a33b 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -10,7 +10,7 @@ module Integrations URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_DOMAIN}/account_management/api-app-keys/" SUPPORTED_EVENTS = %w[ - pipeline job + pipeline job archive_trace ].freeze TAG_KEY_VALUE_RE = %r{\A [\w-]+ : .*\S.* \z}x.freeze @@ -38,14 +38,6 @@ module Integrations SUPPORTED_EVENTS end - def supported_events - events = super - - return events + ['archive_trace'] if Feature.enabled?(:datadog_integration_logs_collection, parent) - - events - end - def self.default_test_event 'pipeline' end @@ -77,7 +69,7 @@ module Integrations end def fields - f = [ + [ { type: 'text', name: 'datadog_site', @@ -110,21 +102,15 @@ module Integrations linkClose: '</a>'.html_safe }, required: true - } - ] - - if Feature.enabled?(:datadog_integration_logs_collection, parent) - f.append({ + }, + { type: 'checkbox', name: 'archive_trace_events', title: s_('Logs'), checkbox_label: s_('Enable logs collection'), help: s_('When enabled, job logs are collected by Datadog and displayed along with pipeline execution traces.'), required: false - }) - end - - f += [ + }, { type: 'text', name: 'datadog_service', @@ -161,8 +147,6 @@ module Integrations } } ] - - f end override :hook_url diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index ec8a12e4760..d0389b82410 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -6,6 +6,24 @@ module Integrations class Discord < BaseChatNotification ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze + undef :notify_only_broken_pipelines + + field :webhook, + section: SECTION_TYPE_CONNECTION, + placeholder: 'https://discordapp.com/api/webhooks/…', + help: 'URL to the webhook for the Discord channel.', + required: true + + field :notify_only_broken_pipelines, + type: 'checkbox', + section: SECTION_TYPE_CONFIGURATION + + field :branches_to_be_notified, + type: 'select', + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + choices: -> { branch_choices } + def title s_("DiscordService|Discord Notifications") end @@ -18,6 +36,10 @@ module Integrations "discord" end + def fields + self.class.fields + build_event_channels + end + def help docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } @@ -31,30 +53,6 @@ module Integrations %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] end - def default_fields - [ - { - type: 'text', - section: SECTION_TYPE_CONNECTION, - name: 'webhook', - placeholder: 'https://discordapp.com/api/webhooks/…', - help: 'URL to the webhook for the Discord channel.' - }, - { - type: 'checkbox', - section: SECTION_TYPE_CONFIGURATION, - name: 'notify_only_broken_pipelines' - }, - { - type: 'select', - section: SECTION_TYPE_CONFIGURATION, - name: 'branches_to_be_notified', - title: s_('Integrations|Branches for which notifications are to be sent'), - choices: self.class.branch_choices - } - ] - end - def sections [ { diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index df112ad6ca8..6e7f31aa030 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -47,8 +47,31 @@ module Integrations private def notify(message, opts) + url = webhook.dup + + key = parse_thread_key(message) + url = Gitlab::Utils.add_url_parameters(url, { threadKey: key }) if key + simple_text = parse_simple_text_message(message) - ::HangoutsChat::Sender.new(webhook).simple(simple_text) + ::HangoutsChat::Sender.new(url).simple(simple_text) + end + + # Returns an appropriate key for threading messages in google chat + def parse_thread_key(message) + case message + when Integrations::ChatMessage::NoteMessage + message.target + when Integrations::ChatMessage::IssueMessage + "issue #{Issue.reference_prefix}#{message.issue_iid}" + when Integrations::ChatMessage::MergeMessage + "merge request #{MergeRequest.reference_prefix}#{message.merge_request_iid}" + when Integrations::ChatMessage::PushMessage + "push #{message.project_name}_#{message.ref}" + when Integrations::ChatMessage::PipelineMessage + "pipeline #{message.pipeline_id}" + when Integrations::ChatMessage::WikiPageMessage + "wiki_page #{message.wiki_page_url}" + end end def parse_simple_text_message(message) diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 03913a71d47..58eabcfd378 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -24,6 +24,10 @@ module Integrations s_("HarborIntegration|After the Harbor integration is activated, global variables '$HARBOR_USERNAME', '$HARBOR_HOST', '$HARBOR_OCI', '$HARBOR_PASSWORD', '$HARBOR_URL' and '$HARBOR_PROJECT' will be created for CI/CD use.") end + def hostname + Gitlab::Utils.parse_url(url).hostname + end + class << self def to_param name.demodulize.downcase diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index 8bc296e0320..f5b6595fff2 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -9,8 +9,6 @@ module Integrations required: true def render? - return false unless Feature.enabled?(:shimo_integration, project) - valid? && activated? end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index b502d5e354d..d141061062a 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -143,10 +143,7 @@ class InternalId < ApplicationRecord def track_greatest(new_value) InternalId.internal_id_transactions_increment(operation: :track_greatest, usage: usage) - function = Arel::Nodes::NamedFunction.new('GREATEST', [ - arel_table[:last_value], - new_value.to_i - ]) + function = Arel::Nodes::NamedFunction.new('GREATEST', [arel_table[:last_value], new_value.to_i]) next_iid = update_record!(subject, scope, usage, function) return next_iid if next_iid diff --git a/app/models/issue.rb b/app/models/issue.rb index df8ee34b3c3..153747c75df 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -254,31 +254,6 @@ class Issue < ApplicationRecord alias_method :with_state, :with_state_id alias_method :with_states, :with_state_ids - def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:) - reversed_direction = direction == :asc ? :desc : :asc - - # rubocop: disable GitlabSecurity/PublicSend - order = ::Gitlab::Pagination::Keyset::Order.build([ - ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: attribute_name, - column_expression: column, - order_expression: column.send(direction).send(nullable), - reversed_order_expression: column.send(reversed_direction).send(nullable), - order_direction: direction, - distinct: false, - add_to_projections: true, - nullable: nullable - ), - ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'id', - order_expression: arel_table['id'].desc - ) - ]) - # rubocop: enable GitlabSecurity/PublicSend - - order.apply_cursor_conditions(scope).order(order) - end - override :order_upvotes_desc def order_upvotes_desc reorder(upvotes_count: :desc) @@ -293,16 +268,6 @@ class Issue < ApplicationRecord def pg_full_text_search(search_term) super.where('issue_search_data.project_id = issues.project_id') end - - override :full_search - def full_search(query, matched_columns: nil, use_minimum_char_limit: true) - return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX) - - super.where( - 'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern', - pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN - ) - end end def next_object_by_relative_position(ignoring: nil, order: :asc) @@ -406,8 +371,6 @@ class Issue < ApplicationRecord attribute_name: 'relative_position', column_expression: arel_table[:relative_position], order_expression: Issue.arel_table[:relative_position].asc.nulls_last, - reversed_order_expression: Issue.arel_table[:relative_position].desc.nulls_last, - order_direction: :asc, nullable: :nulls_last, distinct: false ) @@ -695,11 +658,11 @@ class Issue < ApplicationRecord return unless persisted? if confidential? && WorkItems::ParentLink.has_public_children?(id) - errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.')) + errors.add(:base, _('A confidential issue cannot have a parent that already has non-confidential children.')) end if !confidential? && WorkItems::ParentLink.has_confidential_parent?(id) - errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.')) + errors.add(:base, _('A non-confidential issue cannot have a confidential parent.')) end end @@ -722,7 +685,7 @@ class Issue < ApplicationRecord end def record_create_action - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author, project: project) end # Returns `true` if this Issue is visible to everybody. diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index 8befe9a9230..0a2d3ba0749 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -24,4 +24,10 @@ class JiraConnectInstallation < ApplicationRecord def client Atlassian::JiraConnect::Client.new(base_url, shared_secret) end + + def oauth_authorization_url + return Gitlab.config.gitlab.url if instance_url.blank? || Feature.disabled?(:jira_connect_oauth_self_managed) + + instance_url + end end diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index 94444f4b6d3..f28e8f81b40 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -12,7 +12,7 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel next_partition_if: -> (active_partition) do oldest_record_in_partition = LooseForeignKeys::DeletedRecord .select(:id, :created_at) - .for_partition(active_partition) + .for_partition(active_partition.value) .order(:id) .limit(1) .take @@ -22,7 +22,7 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel end, detach_partition_if: -> (partition) do !LooseForeignKeys::DeletedRecord - .for_partition(partition) + .for_partition(partition.value) .status_pending .exists? end diff --git a/app/models/member.rb b/app/models/member.rb index 0cd1e022617..c5351d5447b 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -60,6 +60,7 @@ class Member < ApplicationRecord if: :project_bot? validate :access_level_inclusion validate :validate_member_role_access_level + validate :validate_access_level_locked_for_member_role, on: :update scope :with_invited_user_state, -> do joins('LEFT JOIN users as invited_user ON invited_user.email = members.invite_email') @@ -73,10 +74,7 @@ class Member < ApplicationRecord projects = source.root_ancestor.all_projects project_members = Member.default_scoped.where(source: projects).select(*Member.cached_column_list) - Member.default_scoped.from_union([ - group_members, - project_members - ]).merge(self) + Member.default_scoped.from_union([group_members, project_members]).merge(self) end scope :excluding_users, ->(user_ids) do @@ -186,14 +184,85 @@ class Member < ApplicationRecord unscoped.from(distinct_members, :members) end - scope :order_name_asc, -> { left_join_users.reorder(User.arel_table[:name].asc.nulls_last) } - scope :order_name_desc, -> { left_join_users.reorder(User.arel_table[:name].desc.nulls_last) } - scope :order_recent_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].desc.nulls_last) } - scope :order_oldest_sign_in, -> { left_join_users.reorder(User.arel_table[:last_sign_in_at].asc.nulls_last) } - scope :order_recent_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].desc.nulls_last) } - scope :order_oldest_last_activity, -> { left_join_users.reorder(User.arel_table[:last_activity_on].asc.nulls_first) } - scope :order_recent_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].desc.nulls_last) } - scope :order_oldest_created_user, -> { left_join_users.reorder(User.arel_table[:created_at].asc.nulls_first) } + scope :order_name_asc, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_full_name', + column: User.arel_table[:name], + direction: :asc, + nullable: :nulls_last + ) + end + + scope :order_name_desc, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_full_name', + column: User.arel_table[:name], + direction: :desc, + nullable: :nulls_last + ) + end + + scope :order_oldest_sign_in, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_last_sign_in_at', + column: User.arel_table[:last_sign_in_at], + direction: :asc, + nullable: :nulls_last + ) + end + + scope :order_recent_sign_in, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_last_sign_in_at', + column: User.arel_table[:last_sign_in_at], + direction: :desc, + nullable: :nulls_last + ) + end + + scope :order_oldest_last_activity, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_last_activity_on', + column: User.arel_table[:last_activity_on], + direction: :asc, + nullable: :nulls_first + ) + end + + scope :order_recent_last_activity, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_last_activity_on', + column: User.arel_table[:last_activity_on], + direction: :desc, + nullable: :nulls_last + ) + end + + scope :order_oldest_created_user, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_created_at', + column: User.arel_table[:created_at], + direction: :asc, + nullable: :nulls_first + ) + end + + scope :order_recent_created_user, -> do + build_keyset_order_on_joined_column( + scope: left_join_users, + attribute_name: 'member_user_created_at', + column: User.arel_table[:created_at], + direction: :desc, + nullable: :nulls_last + ) + end scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) } @@ -438,6 +507,14 @@ class Member < ApplicationRecord end end + def validate_access_level_locked_for_member_role + return unless member_role_id + + if access_level_changed? + errors.add(:access_level, _("cannot be changed since member is associated with a custom role")) + end + end + def send_invite # override in subclass end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 3c06e1aa983..a57cb97e936 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -20,7 +20,7 @@ class MergeRequest < ApplicationRecord include IgnorableColumns include MilestoneEventable include StateEventable - include ApprovableBase + include Approvable include IdInOrdered include Todoable @@ -67,6 +67,8 @@ class MergeRequest < ApplicationRecord has_one :merge_head_diff, -> { merge_head }, inverse_of: :merge_request, class_name: 'MergeRequestDiff' has_one :cleanup_schedule, inverse_of: :merge_request + has_one :predictions, inverse_of: :merge_request + delegate :suggested_reviewers, to: :predictions belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff' manual_inverse_association :latest_merge_request_diff, :merge_request @@ -116,6 +118,7 @@ class MergeRequest < ApplicationRecord has_many :draft_notes has_many :reviews, inverse_of: :merge_request + has_many :created_environments, class_name: 'Environment', foreign_key: :merge_request_id, inverse_of: :merge_request KNOWN_MERGE_PARAMS = [ :auto_merge_strategy, @@ -343,23 +346,24 @@ class MergeRequest < ApplicationRecord column_expression = MergeRequest::Metrics.arel_table[metric] column_expression_with_direction = direction == 'ASC' ? column_expression.asc : column_expression.desc - order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: "merge_request_metrics_#{metric}", - column_expression: column_expression, - order_expression: column_expression_with_direction.nulls_last, - reversed_order_expression: column_expression_with_direction.reverse.nulls_first, - order_direction: direction, - nullable: :nulls_last, - distinct: false, - add_to_projections: true - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'merge_request_metrics_id', - order_expression: MergeRequest::Metrics.arel_table[:id].desc, - add_to_projections: true - ) - ]) + order = Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: "merge_request_metrics_#{metric}", + column_expression: column_expression, + order_expression: column_expression_with_direction.nulls_last, + reversed_order_expression: column_expression_with_direction.reverse.nulls_first, + order_direction: direction, + nullable: :nulls_last, + distinct: false, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'merge_request_metrics_id', + order_expression: MergeRequest::Metrics.arel_table[:id].desc, + add_to_projections: true + ) + ]) order.apply_cursor_conditions(join_metrics).order(order) end @@ -417,17 +421,6 @@ class MergeRequest < ApplicationRecord ) end - scope :attention, ->(user) do - # rubocop: disable Gitlab/Union - union = Gitlab::SQL::Union.new([ - MergeRequestReviewer.select(:merge_request_id).where(user_id: user.id, state: MergeRequestReviewer.states[:attention_requested]), - MergeRequestAssignee.select(:merge_request_id).where(user_id: user.id, state: MergeRequestAssignee.states[:attention_requested]) - ]) - # rubocop: enable Gitlab/Union - - with(Gitlab::SQL::CTE.new(:reviewers_and_assignees, union).to_arel).where('merge_requests.id in (select merge_request_id from reviewers_and_assignees)') - end - def self.total_time_to_merge join_metrics .merge(MergeRequest::Metrics.with_valid_time_to_merge) @@ -1187,41 +1180,13 @@ class MergeRequest < ApplicationRecord ] end - def detailed_merge_status - if cannot_be_merged_rechecking? || preparing? || checking? - return :checking - elsif unchecked? - return :unchecked - end - - checks = execute_merge_checks - - if checks.success? - :mergeable - else - checks.failure_reason - end - end - - # rubocop: disable CodeReuse/ServiceClass def mergeable_state?(skip_ci_check: false, skip_discussions_check: false) - if Feature.enabled?(:improved_mergeability_checks, self.project) - additional_checks = execute_merge_checks(params: { - skip_ci_check: skip_ci_check, - skip_discussions_check: skip_discussions_check - }) - additional_checks.execute.success? - else - return false unless open? - return false if draft? - return false if broken? - return false unless skip_discussions_check || mergeable_discussions_state? - return false unless skip_ci_check || mergeable_ci_state? - - true - end + additional_checks = execute_merge_checks(params: { + skip_ci_check: skip_ci_check, + skip_discussions_check: skip_discussions_check + }) + additional_checks.success? end - # rubocop: enable CodeReuse/ServiceClass def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) @@ -1318,7 +1283,6 @@ class MergeRequest < ApplicationRecord # running `ReferenceExtractor` on each of them separately. # This optimization does not apply to issues from external sources. def cache_merge_request_closes_issues!(current_user = self.author) - return unless project.issues_enabled? return if closed? || merged? transaction do @@ -1489,7 +1453,7 @@ class MergeRequest < ApplicationRecord end def environments_in_head_pipeline(deployment_status: nil) - actual_head_pipeline&.environments_in_self_and_descendants(deployment_status: deployment_status) || Environment.none + actual_head_pipeline&.environments_in_self_and_project_descendants(deployment_status: deployment_status) || Environment.none end def fetch_ref! @@ -1589,7 +1553,7 @@ class MergeRequest < ApplicationRecord end def has_test_reports? - actual_head_pipeline&.has_reports?(Ci::JobArtifact.test_reports) + actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)) end def predefined_variables @@ -1619,7 +1583,7 @@ class MergeRequest < ApplicationRecord end def has_accessibility_reports? - actual_head_pipeline.present? && actual_head_pipeline.has_reports?(Ci::JobArtifact.accessibility_reports) + actual_head_pipeline.present? && actual_head_pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:accessibility)) end def has_coverage_reports? @@ -1627,7 +1591,7 @@ class MergeRequest < ApplicationRecord end def has_terraform_reports? - actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports) + actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:terraform)) end def compare_accessibility_reports @@ -1667,7 +1631,7 @@ class MergeRequest < ApplicationRecord end def has_codequality_reports? - actual_head_pipeline&.has_reports?(Ci::JobArtifact.codequality_reports) + actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:codequality)) end def compare_codequality_reports @@ -1717,11 +1681,11 @@ class MergeRequest < ApplicationRecord end def has_sast_reports? - !!actual_head_pipeline&.has_reports?(::Ci::JobArtifact.sast_reports) + !!actual_head_pipeline&.complete_and_has_reports?(::Ci::JobArtifact.of_report_type(:sast)) end def has_secret_detection_reports? - !!actual_head_pipeline&.has_reports?(::Ci::JobArtifact.secret_detection_reports) + !!actual_head_pipeline&.complete_and_has_reports?(::Ci::JobArtifact.of_report_type(:secret_detection)) end def compare_sast_reports(current_user) @@ -2019,6 +1983,12 @@ class MergeRequest < ApplicationRecord false # Overridden in EE end + def execute_merge_checks(params: {}) + # rubocop: disable CodeReuse/ServiceClass + MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute + # rubocop: enable CodeReuse/ServiceClass + end + private attr_accessor :skip_fetch_ref @@ -2072,12 +2042,6 @@ class MergeRequest < ApplicationRecord def report_type_enabled?(report_type) !!actual_head_pipeline&.batch_lookup_report_artifact_for_file_type(report_type) end - - def execute_merge_checks(params: {}) - # rubocop: disable CodeReuse/ServiceClass - MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: params).execute - # rubocop: enable CodeReuse/ServiceClass - end end MergeRequest.prepend_mod_with('MergeRequest') diff --git a/app/models/merge_request/predictions.rb b/app/models/merge_request/predictions.rb new file mode 100644 index 00000000000..ef9e00b5f74 --- /dev/null +++ b/app/models/merge_request/predictions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MergeRequest::Predictions < ApplicationRecord # rubocop:disable Style/ClassAndModuleChildren + belongs_to :merge_request, inverse_of: :predictions + + validates :suggested_reviewers, json_schema: { filename: 'merge_request_predictions_suggested_reviewers' } +end diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index fd8e5860040..be3a1d42eac 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class MergeRequestAssignee < ApplicationRecord - include MergeRequestReviewerState + include IgnorableColumns + ignore_column %i[state updated_state_by_user_id], remove_with: '15.6', remove_after: '2022-10-22' belongs_to :merge_request, touch: true belongs_to :assignee, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees @@ -11,6 +12,6 @@ class MergeRequestAssignee < ApplicationRecord scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) } def cache_key - [model_name.cache_key, id, state, assignee.cache_key] + [model_name.cache_key, id, assignee.cache_key] end end diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb index 4abf0fa09f0..4b5b71481d3 100644 --- a/app/models/merge_request_reviewer.rb +++ b/app/models/merge_request_reviewer.rb @@ -2,6 +2,8 @@ class MergeRequestReviewer < ApplicationRecord include MergeRequestReviewerState + include IgnorableColumns + ignore_column :updated_state_by_user_id, remove_with: '15.6', remove_after: '2022-10-22' belongs_to :merge_request belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index e181217f01c..29e1ba88528 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -2,11 +2,24 @@ module Ml class Candidate < ApplicationRecord + enum status: { running: 0, scheduled: 1, finished: 2, failed: 3, killed: 4 } + validates :iid, :experiment, presence: true + validates :status, inclusion: { in: statuses.keys } belongs_to :experiment, class_name: 'Ml::Experiment' belongs_to :user has_many :metrics, class_name: 'Ml::CandidateMetric' has_many :params, class_name: 'Ml::CandidateParam' + + default_value_for(:iid) { SecureRandom.uuid } + + class << self + def with_project_id_and_iid(project_id, iid) + return unless project_id.present? && iid.present? + + joins(:experiment).find_by(experiment: { project_id: project_id }, iid: iid) + end + end end end diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index 7ef9c70ba7e..e4e9baac4c8 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -2,11 +2,33 @@ module Ml class Experiment < ApplicationRecord - validates :name, :iid, :project, presence: true - validates :iid, :name, uniqueness: { scope: :project, message: "should be unique in the project" } + include AtomicInternalId + + validates :name, :project, presence: true + validates :name, uniqueness: { scope: :project, message: "should be unique in the project" } belongs_to :project belongs_to :user has_many :candidates, class_name: 'Ml::Candidate' + + has_internal_id :iid, scope: :project + + def artifact_location + 'not_implemented' + end + + class << self + def by_project_id_and_iid(project_id, iid) + find_by(project_id: project_id, iid: iid) + end + + def by_project_id_and_name(project_id, name) + find_by(project_id: project_id, name: name) + end + + def has_record?(project_id, name) + where(project_id: project_id, name: name).exists? + end + end end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 06f49f16d66..0ffd5c446d3 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -43,6 +43,8 @@ class Namespace < ApplicationRecord # The first date in https://docs.gitlab.com/ee/user/usage_quotas.html#namespace-storage-limit-enforcement-schedule # Determines when we start enforcing namespace storage MIN_STORAGE_ENFORCEMENT_DATE = Date.new(2022, 10, 19) + # https://gitlab.com/gitlab-org/gitlab/-/issues/367531 + MIN_STORAGE_ENFORCEMENT_USAGE = 5.gigabytes cache_markdown_field :description, pipeline: :description @@ -59,7 +61,7 @@ class Namespace < ApplicationRecord has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' has_many :pending_builds, class_name: 'Ci::PendingBuild' - has_one :onboarding_progress + has_one :onboarding_progress, class_name: 'Onboarding::Progress' # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. @@ -126,8 +128,9 @@ class Namespace < ApplicationRecord delegate :avatar_url, to: :owner, allow_nil: true delegate :prevent_sharing_groups_outside_hierarchy, :prevent_sharing_groups_outside_hierarchy=, to: :namespace_settings, allow_nil: true + delegate :show_diff_preview_in_email, :show_diff_preview_in_email?, :show_diff_preview_in_email=, + to: :namespace_settings - after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_parent_id? } after_save :reload_namespace_details after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') } @@ -136,6 +139,7 @@ class Namespace < ApplicationRecord before_update :sync_share_with_group_lock_with_parent, if: :parent_changed? after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? } after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? } + after_sync_traversal_ids :schedule_sync_event_worker # custom callback defined in Namespaces::Traversal::Linear # Legacy Storage specific hooks @@ -172,13 +176,17 @@ class Namespace < ApplicationRecord end scope :sorted_by_similarity_and_parent_id_desc, -> (search) do - order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ - { column: arel_table["path"], multiplier: 1 }, - { column: arel_table["name"], multiplier: 0.7 } - ]) + order_expression = Gitlab::Database::SimilarityScore.build_expression( + search: search, + rules: [ + { column: arel_table["path"], multiplier: 1 }, + { column: arel_table["name"], multiplier: 0.7 } + ]) reorder(order_expression.desc, Namespace.arel_table['parent_id'].desc.nulls_last, Namespace.arel_table['id'].desc) end + scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) } + # Make sure that the name is same as strong_memoize name in root_ancestor # method attr_writer :root_ancestor, :emails_disabled_memoized @@ -362,7 +370,7 @@ class Namespace < ApplicationRecord end def any_project_with_shared_runners_enabled? - projects.with_shared_runners.any? + projects.with_shared_runners_enabled.any? end def user_ids_for_project_authorizations diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 595e34821af..6a87fba57ac 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -4,6 +4,11 @@ class NamespaceSetting < ApplicationRecord include CascadingNamespaceSettingAttribute include Sanitizable include ChronicDurationAttribute + include IgnorableColumns + + ignore_columns %i[exclude_from_free_user_cap include_for_free_user_cap_preview], + remove_with: '15.5', + remove_after: '2022-09-23' cascading_attr :delayed_project_removal @@ -53,8 +58,18 @@ class NamespaceSetting < ApplicationRecord namespace.root_ancestor.prevent_sharing_groups_outside_hierarchy end + def show_diff_preview_in_email? + return show_diff_preview_in_email unless namespace.has_parent? + + all_ancestors_allow_diff_preview_in_email? + end + private + def all_ancestors_allow_diff_preview_in_email? + !self.class.where(namespace_id: namespace.self_and_ancestors, show_diff_preview_in_email: false).exists? + end + def normalize_default_branch_name self.default_branch_name = default_branch_name.presence end diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 687fa6a5334..16a9c20dfdc 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -47,6 +47,8 @@ module Namespaces # This uses rails internal before_commit API to sync traversal_ids on namespace create, right before transaction is committed. # This helps reduce the time during which the root namespace record is locked to ensure updated traversal_ids are valid before_commit :sync_traversal_ids, on: [:create] + + define_model_callbacks :sync_traversal_ids end class_methods do @@ -208,10 +210,12 @@ module Namespaces # # NOTE: self.traversal_ids will be stale. Reload for a fresh record. def sync_traversal_ids - # Clear any previously memoized root_ancestor as our ancestors have changed. - clear_memoization(:root_ancestor) + run_callbacks :sync_traversal_ids do + # Clear any previously memoized root_ancestor as our ancestors have changed. + clear_memoization(:root_ancestor) - Namespace::TraversalHierarchy.for_namespace(self).sync_traversal_ids! + Namespace::TraversalHierarchy.for_namespace(self).sync_traversal_ids! + end end # Lock the root of the hierarchy we just left, and lock the root of the hierarchy diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index 81ac026d7ff..843de9bce33 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -41,24 +41,13 @@ module Namespaces def self_and_descendants(include_self: true) return super unless use_traversal_ids_for_descendants_scopes? - if Feature.enabled?(:traversal_ids_btree) - self_and_descendants_with_comparison_operators(include_self: include_self) - else - records = self_and_descendants_with_duplicates_with_array_operator(include_self: include_self) - distinct = records.select('DISTINCT on(namespaces.id) namespaces.*') - distinct.normal_select - end + self_and_descendants_with_comparison_operators(include_self: include_self) end def self_and_descendant_ids(include_self: true) return super unless use_traversal_ids_for_descendants_scopes? - if Feature.enabled?(:traversal_ids_btree) - self_and_descendants_with_comparison_operators(include_self: include_self).as_ids - else - self_and_descendants_with_duplicates_with_array_operator(include_self: include_self) - .select('DISTINCT namespaces.id') - end + self_and_descendants(include_self: include_self).as_ids end def self_and_hierarchy @@ -181,20 +170,6 @@ module Namespaces Arel::Nodes::NamedFunction.new('unnest', args) end - def self_and_descendants_with_duplicates_with_array_operator(include_self: true) - base_ids = select(:id) - - records = unscoped - .from("namespaces, (#{base_ids.to_sql}) base") - .where('namespaces.traversal_ids @> ARRAY[base.id]') - - if include_self - records - else - records.where('namespaces.id <> base.id') - end - end - def superset_cte(base_name) superset_sql = <<~SQL SELECT d1.traversal_ids diff --git a/app/models/note.rb b/app/models/note.rb index 1715f7cdc3b..daac489757b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -164,6 +164,9 @@ class Note < ApplicationRecord scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } before_validation :nullify_blank_type, :nullify_blank_line_code + # Syncs `confidential` with `internal` as we rename the column. + # https://gitlab.com/gitlab-org/gitlab/-/issues/367923 + before_create :set_internal_flag after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits } after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? @@ -813,6 +816,10 @@ class Note < ApplicationRecord def noteable_can_have_confidential_note? for_issue? end + + def set_internal_flag + self.internal = confidential if confidential + end end Note.prepend_mod_with('Note') diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index b3eaed154e2..caa24377791 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -38,6 +38,7 @@ class NotificationRecipient return !unsubscribed? if @type == :subscription return false unless suitable_notification_level? + return false if email_blocked? # check this last because it's expensive # nobody should receive notifications if they've specifically unsubscribed @@ -95,6 +96,15 @@ class NotificationRecipient end end + def email_blocked? + return false if Feature.disabled?(:block_emails_with_failures) + + recipient_email = user.notification_email_for(@group) + + Gitlab::ApplicationRateLimiter.peek(:permanent_email_failure, scope: recipient_email) || + Gitlab::ApplicationRateLimiter.peek(:temporary_email_failure, scope: recipient_email) + end + def has_access? DeclarativePolicy.subject_scope do break false unless user.can?(:receive_notifications) diff --git a/app/models/oauth_access_token.rb b/app/models/oauth_access_token.rb index 7d71e15d3c5..eac99e8d441 100644 --- a/app/models/oauth_access_token.rb +++ b/app/models/oauth_access_token.rb @@ -26,4 +26,13 @@ class OauthAccessToken < Doorkeeper::AccessToken super end + + # Override Doorkeeper::AccessToken.matching_token_for since we + # have `reuse_access_tokens` disabled and we also hash tokens. + # This ensures we don't accidentally return a hashed token value. + def self.matching_token_for(application, resource_owner, scopes) + return if Feature.enabled?(:hash_oauth_tokens) + + super + end end diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb new file mode 100644 index 00000000000..49fdb102209 --- /dev/null +++ b/app/models/onboarding/completion.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Onboarding + class Completion + include Gitlab::Utils::StrongMemoize + include Gitlab::Experiment::Dsl + + ACTION_ISSUE_IDS = { + pipeline_created: 7, + trial_started: 2, + required_mr_approvals_enabled: 11, + code_owners_enabled: 10 + }.freeze + + ACTION_PATHS = [ + :issue_created, + :git_write, + :merge_request_created, + :user_added + ].freeze + + def initialize(namespace, current_user = nil) + @namespace = namespace + @current_user = current_user + end + + def percentage + return 0 unless onboarding_progress + + attributes = onboarding_progress.attributes.symbolize_keys + + total_actions = action_columns.count + completed_actions = action_columns.count { |column| attributes[column].present? } + + (completed_actions.to_f / total_actions * 100).round + end + + private + + def onboarding_progress + strong_memoize(:onboarding_progress) do + ::Onboarding::Progress.find_by(namespace: namespace) + end + end + + def action_columns + strong_memoize(:action_columns) do + tracked_actions.map { |action_key| ::Onboarding::Progress.column_name(action_key) } + end + end + + def tracked_actions + ACTION_ISSUE_IDS.keys + ACTION_PATHS + deploy_section_tracked_actions + end + + def deploy_section_tracked_actions + experiment( + :security_actions_continuous_onboarding, + namespace: namespace, + user: current_user, + sticky_to: current_user + ) do |e| + e.control { [:security_scan_enabled] } + e.candidate { [:license_scanning_run, :secure_dependency_scanning_run, :secure_dast_run] } + end.run + end + + attr_reader :namespace, :current_user + end +end diff --git a/app/models/onboarding/learn_gitlab.rb b/app/models/onboarding/learn_gitlab.rb new file mode 100644 index 00000000000..d7a189ed6e2 --- /dev/null +++ b/app/models/onboarding/learn_gitlab.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Onboarding + class LearnGitlab + PROJECT_NAME = 'Learn GitLab' + PROJECT_NAME_ULTIMATE_TRIAL = 'Learn GitLab - Ultimate trial' + BOARD_NAME = 'GitLab onboarding' + LABEL_NAME = 'Novice' + + def initialize(current_user) + @current_user = current_user + end + + def available? + project && board && label + end + + def project + @project ||= current_user.projects.find_by_name([PROJECT_NAME, PROJECT_NAME_ULTIMATE_TRIAL]) + end + + def board + return unless project + + @board ||= project.boards.find_by_name(BOARD_NAME) + end + + def label + return unless project + + @label ||= project.labels.find_by_name(LABEL_NAME) + end + + private + + attr_reader :current_user + end +end diff --git a/app/models/onboarding/progress.rb b/app/models/onboarding/progress.rb new file mode 100644 index 00000000000..ecc78418256 --- /dev/null +++ b/app/models/onboarding/progress.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Onboarding + class Progress < ApplicationRecord + self.table_name = 'onboarding_progresses' + + belongs_to :namespace, optional: false + + validate :namespace_is_root_namespace + + ACTIONS = [ + :git_pull, + :git_write, + :merge_request_created, + :pipeline_created, + :user_added, + :trial_started, + :subscription_created, + :required_mr_approvals_enabled, + :code_owners_enabled, + :scoped_label_created, + :security_scan_enabled, + :issue_created, + :issue_auto_closed, + :repository_imported, + :repository_mirrored, + :secure_dependency_scanning_run, + :secure_container_scanning_run, + :secure_dast_run, + :secure_secret_detection_run, + :secure_coverage_fuzzing_run, + :secure_api_fuzzing_run, + :secure_cluster_image_scanning_run, + :license_scanning_run + ].freeze + + scope :incomplete_actions, ->(actions) do + Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) } + end + + scope :completed_actions, ->(actions) do + Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) } + end + + scope :completed_actions_with_latest_in_range, ->(actions, range) do + actions = Array(actions) + if actions.size == 1 + where(column_name(actions[0]) => range) + else + action_columns = actions.map { |action| arel_table[column_name(action)] } + completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range)) + end + end + + class << self + def onboard(namespace) + return unless root_namespace?(namespace) + + create(namespace: namespace) + end + + def onboarding?(namespace) + where(namespace: namespace).any? + end + + def register(namespace, actions) + actions = Array(actions) + return unless root_namespace?(namespace) && actions.difference(ACTIONS).empty? + + onboarding_progress = find_by(namespace: namespace) + return unless onboarding_progress + + now = Time.current + nil_actions = actions.select { |action| onboarding_progress[column_name(action)].nil? } + return if nil_actions.empty? + + updates = nil_actions.inject({}) { |sum, action| sum.merge!({ column_name(action) => now }) } + onboarding_progress.update!(updates) + end + + def completed?(namespace, action) + return unless root_namespace?(namespace) && ACTIONS.include?(action) + + action_column = column_name(action) + where(namespace: namespace).where.not(action_column => nil).exists? + end + + def not_completed?(namespace_id, action) + return unless ACTIONS.include?(action) + + action_column = column_name(action) + exists?(namespace_id: namespace_id, action_column => nil) + end + + def column_name(action) + :"#{action}_at" + end + + private + + def root_namespace?(namespace) + namespace&.root? + end + end + + def number_of_completed_actions + attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size + end + + private + + def namespace_is_root_namespace + return unless namespace + + errors.add(:namespace, _('must be a root namespace')) if namespace.has_parent? + end + end +end diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb deleted file mode 100644 index e5851c5cfc5..00000000000 --- a/app/models/onboarding_progress.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -class OnboardingProgress < ApplicationRecord - belongs_to :namespace, optional: false - - validate :namespace_is_root_namespace - - ACTIONS = [ - :git_pull, - :git_write, - :merge_request_created, - :pipeline_created, - :user_added, - :trial_started, - :subscription_created, - :required_mr_approvals_enabled, - :code_owners_enabled, - :scoped_label_created, - :security_scan_enabled, - :issue_created, - :issue_auto_closed, - :repository_imported, - :repository_mirrored, - :secure_dependency_scanning_run, - :secure_container_scanning_run, - :secure_dast_run, - :secure_secret_detection_run, - :secure_coverage_fuzzing_run, - :secure_api_fuzzing_run, - :secure_cluster_image_scanning_run, - :license_scanning_run - ].freeze - - scope :incomplete_actions, -> (actions) do - Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) } - end - - scope :completed_actions, -> (actions) do - Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) } - end - - scope :completed_actions_with_latest_in_range, -> (actions, range) do - actions = Array(actions) - if actions.size == 1 - where(column_name(actions[0]) => range) - else - action_columns = actions.map { |action| arel_table[column_name(action)] } - completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range)) - end - end - - class << self - def onboard(namespace) - return unless root_namespace?(namespace) - - create(namespace: namespace) - end - - def onboarding?(namespace) - where(namespace: namespace).any? - end - - def register(namespace, actions) - actions = Array(actions) - return unless root_namespace?(namespace) && actions.difference(ACTIONS).empty? - - onboarding_progress = find_by(namespace: namespace) - return unless onboarding_progress - - now = Time.current - nil_actions = actions.select { |action| onboarding_progress[column_name(action)].nil? } - return if nil_actions.empty? - - updates = nil_actions.inject({}) { |sum, action| sum.merge!({ column_name(action) => now }) } - onboarding_progress.update!(updates) - end - - def completed?(namespace, action) - return unless root_namespace?(namespace) && ACTIONS.include?(action) - - action_column = column_name(action) - where(namespace: namespace).where.not(action_column => nil).exists? - end - - def not_completed?(namespace_id, action) - return unless ACTIONS.include?(action) - - action_column = column_name(action) - where(namespace_id: namespace_id).where(action_column => nil).exists? - end - - def column_name(action) - :"#{action}_at" - end - - private - - def root_namespace?(namespace) - namespace && namespace.root? - end - end - - def number_of_completed_actions - attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size - end - - private - - def namespace_is_root_namespace - return unless namespace - - errors.add(:namespace, _('must be a root namespace')) if namespace.has_parent? - end -end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index afd55b4f143..b4c09d99bb0 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -22,7 +22,8 @@ class Packages::Package < ApplicationRecord debian: 9, rubygems: 10, helm: 11, - terraform_module: 12 + terraform_module: 12, + rpm: 13 } enum status: { default: 0, hidden: 1, processing: 2, error: 3, pending_destruction: 4 } @@ -43,6 +44,7 @@ class Packages::Package < ApplicationRecord has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum' has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum' has_one :rubygems_metadatum, inverse_of: :package, class_name: 'Packages::Rubygems::Metadatum' + has_one :rpm_metadatum, inverse_of: :package, class_name: 'Packages::Rpm::Metadatum' has_one :npm_metadatum, inverse_of: :package, class_name: 'Packages::Npm::Metadatum' has_many :build_infos, inverse_of: :package has_many :pipelines, through: :build_infos, disable_joins: true @@ -242,22 +244,23 @@ class Packages::Package < ApplicationRecord reverse_order_direction = direction == :asc ? desc_order_expression : asc_order_expression arel_order_classes = ::Gitlab::Pagination::Keyset::ColumnOrderDefinition::AREL_ORDER_CLASSES.invert - ::Gitlab::Pagination::Keyset::Order.build([ - ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: "#{join_table}_#{column_name}", - column_expression: join_class.arel_table[column_name], - order_expression: order_direction, - reversed_order_expression: reverse_order_direction, - order_direction: direction, - distinct: false, - add_to_projections: true - ), - ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'id', - order_expression: arel_order_classes[direction].new(Packages::Package.arel_table[:id]), - add_to_projections: true - ) - ]) + ::Gitlab::Pagination::Keyset::Order.build( + [ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: "#{join_table}_#{column_name}", + column_expression: join_class.arel_table[column_name], + order_expression: order_direction, + reversed_order_expression: reverse_order_direction, + order_direction: direction, + distinct: false, + add_to_projections: true + ), + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_order_classes[direction].new(Packages::Package.arel_table[:id]), + add_to_projections: true + ) + ]) end def versions @@ -330,6 +333,12 @@ class Packages::Package < ApplicationRecord name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase end + def touch_last_downloaded_at + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do + update_column(:last_downloaded_at, Time.zone.now) + end + end + private def composer_tag_version? diff --git a/app/models/packages/policies/group.rb b/app/models/packages/policies/group.rb new file mode 100644 index 00000000000..66cd361f2ed --- /dev/null +++ b/app/models/packages/policies/group.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Packages + module Policies + class Group + attr_accessor :group + + delegate_missing_to :group + + def initialize(group) + @group = group + end + end + end +end diff --git a/app/models/packages/policies/project.rb b/app/models/packages/policies/project.rb new file mode 100644 index 00000000000..a5c6703be42 --- /dev/null +++ b/app/models/packages/policies/project.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Packages + module Policies + class Project + attr_accessor :project + + delegate_missing_to :project + + def initialize(project) + @project = project + end + end + end +end diff --git a/app/models/packages/rpm.rb b/app/models/packages/rpm.rb new file mode 100644 index 00000000000..fc66e7ec5c8 --- /dev/null +++ b/app/models/packages/rpm.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Rpm + def self.table_name_prefix + 'packages_rpm_' + end + end +end diff --git a/app/models/packages/rpm/metadatum.rb b/app/models/packages/rpm/metadatum.rb new file mode 100644 index 00000000000..07361995a12 --- /dev/null +++ b/app/models/packages/rpm/metadatum.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Packages + module Rpm + class Metadatum < ApplicationRecord + self.primary_key = :package_id + + belongs_to :package, -> { where(package_type: :rpm) }, inverse_of: :rpm_metadatum + + validates :package, presence: true + + validates :epoch, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + validates :release, + presence: true, + length: { maximum: 128 } + + validates :summary, + presence: true, + length: { maximum: 1000 } + + validates :description, + presence: true, + length: { maximum: 5000 } + + validates :arch, + presence: true, + length: { maximum: 255 } + + validates :license, + allow_nil: true, + length: { maximum: 1000 } + + validates :url, + allow_nil: true, + length: { maximum: 1000 } + + validate :rpm_package_type + + private + + def rpm_package_type + return if package&.rpm? + + errors.add(:base, _('Package type must be RPM')) + end + end + end +end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 2e25839c47f..16d5492a65e 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -33,6 +33,7 @@ class PagesDomain < ApplicationRecord validate :validate_pages_domain validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } + validate :validate_custom_domain_count_per_project, on: :create default_value_for(:auto_ssl_enabled, allows_nil: false) { ::Gitlab::LetsEncrypt.enabled? } default_value_for :scope, allows_nil: false, value: :project @@ -57,6 +58,7 @@ class PagesDomain < ApplicationRecord where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold)))) end + scope :verified, -> { where.not(verified_at: nil) } scope :need_auto_ssl_renewal, -> do enabled_and_not_failed = where(auto_ssl_enabled: true, auto_ssl_failed: false) @@ -224,6 +226,16 @@ class PagesDomain < ApplicationRecord self.auto_ssl_failed = false end + def validate_custom_domain_count_per_project + return unless project + + unless project.can_create_custom_domains? + self.errors.add( + :base, + _("This project reached the limit of custom domains. (Max %d)") % Gitlab::CurrentSettings.max_pages_custom_domains_per_project) + end + end + private def pages_deployed? diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 7e6e366f8da..9ed25c56ed6 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -24,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) } scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") } + scope :created_before, -> (date) { where("personal_access_tokens.created_at < :date", date: date) } + scope :last_used_before_or_unused, -> (date) { where("personal_access_tokens.created_at < :date AND (last_used_at < :date OR last_used_at IS NULL)", date: date) } scope :with_impersonation, -> { where(impersonation: true) } scope :without_impersonation, -> { where(impersonation: false) } scope :revoked, -> { where(revoked: true) } diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 3461104ae35..f22a63ee980 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -81,8 +81,8 @@ class PoolRepository < ApplicationRecord object_pool.link(repository.raw) end - def unlink_repository(repository) - repository.disconnect_alternates + def unlink_repository(repository, disconnect: true) + repository.disconnect_alternates if disconnect if member_projects.where.not(id: repository.project.id).exists? true diff --git a/app/models/preloaders/environments/deployment_preloader.rb b/app/models/preloaders/environments/deployment_preloader.rb index 251d1837f19..84aa7bc834f 100644 --- a/app/models/preloaders/environments/deployment_preloader.rb +++ b/app/models/preloaders/environments/deployment_preloader.rb @@ -41,11 +41,11 @@ module Preloaders environment.association(association_name).target = associated_deployment environment.association(association_name).loaded! - if associated_deployment - # `last?` in DeploymentEntity requires this environment to be loaded - associated_deployment.association(:environment).target = environment - associated_deployment.association(:environment).loaded! - end + next unless associated_deployment + + # `last?` in DeploymentEntity requires this environment to be loaded + associated_deployment.association(:environment).target = environment + associated_deployment.association(:environment).loaded! end end end diff --git a/app/models/preloaders/group_policy_preloader.rb b/app/models/preloaders/group_policy_preloader.rb index 44030140ce3..23632a9b6c2 100644 --- a/app/models/preloaders/group_policy_preloader.rb +++ b/app/models/preloaders/group_policy_preloader.rb @@ -17,4 +17,4 @@ module Preloaders end end -Preloaders::GroupPolicyPreloader.prepend_mod_with('Preloaders::GroupPolicyPreloader') +Preloaders::GroupPolicyPreloader.prepend_mod diff --git a/app/models/preloaders/project_policy_preloader.rb b/app/models/preloaders/project_policy_preloader.rb new file mode 100644 index 00000000000..fe9db3464c7 --- /dev/null +++ b/app/models/preloaders/project_policy_preloader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Preloaders + class ProjectPolicyPreloader + def initialize(projects, current_user) + @projects = projects + @current_user = current_user + end + + def execute + return if projects.is_a?(ActiveRecord::NullRelation) + + ActiveRecord::Associations::Preloader.new.preload(projects, { group: :route, namespace: :owner }) + ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + end + + private + + attr_reader :projects, :current_user + end +end + +Preloaders::ProjectPolicyPreloader.prepend_mod diff --git a/app/models/preloaders/project_root_ancestor_preloader.rb b/app/models/preloaders/project_root_ancestor_preloader.rb new file mode 100644 index 00000000000..8d04e71774c --- /dev/null +++ b/app/models/preloaders/project_root_ancestor_preloader.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Preloaders + class ProjectRootAncestorPreloader + def initialize(projects, namespace_sti_name = :namespace, root_ancestor_preloads = []) + @projects = projects + @namespace_sti_name = namespace_sti_name + @root_ancestor_preloads = root_ancestor_preloads + end + + def execute + return if @projects.is_a?(ActiveRecord::NullRelation) + return unless ::Feature.enabled?(:use_traversal_ids) + + root_query = Namespace.joins("INNER JOIN (#{join_sql}) as root_query ON root_query.root_id = namespaces.id") + .select('namespaces.*, root_query.id as source_id') + + root_query = root_query.preload(*@root_ancestor_preloads) if @root_ancestor_preloads.any? + + root_ancestors_by_id = root_query.group_by(&:source_id) + + ActiveRecord::Associations::Preloader.new.preload(@projects, :namespace) + @projects.each do |project| + project.namespace.root_ancestor = root_ancestors_by_id[project.id]&.first + end + end + + private + + def join_sql + @projects + .joins(@namespace_sti_name) + .select('projects.id, namespaces.traversal_ids[1] as root_id') + .to_sql + end + end +end diff --git a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb index 99a31a620c5..f32184f168d 100644 --- a/app/models/preloaders/users_max_access_level_in_projects_preloader.rb +++ b/app/models/preloaders/users_max_access_level_in_projects_preloader.rb @@ -51,4 +51,4 @@ module Preloaders end end -# Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod +Preloaders::UsersMaxAccessLevelInProjectsPreloader.prepend_mod diff --git a/app/models/project.rb b/app/models/project.rb index 0c49cc24a8d..c5fad189f87 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -46,13 +46,9 @@ class Project < ApplicationRecord extend Gitlab::ConfigHelper - ignore_columns :container_registry_enabled, remove_after: '2021-09-22', remove_with: '14.4' - BoardLimitExceeded = Class.new(StandardError) ExportLimitExceeded = Class.new(StandardError) - ignore_columns :mirror_last_update_at, :mirror_last_successful_update_at, remove_after: '2021-09-22', remove_with: '14.4' - ignore_columns :pull_mirror_branch_prefix, remove_after: '2021-09-22', remove_with: '14.4' ignore_columns :build_coverage_regex, remove_after: '2022-10-22', remove_with: '15.5' STATISTICS_ATTRIBUTE = 'repositories_count' @@ -123,6 +119,7 @@ class Project < ApplicationRecord before_validation :ensure_project_namespace_in_sync before_validation :set_package_registry_access_level, if: :packages_enabled_changed? + before_validation :remove_leading_spaces_on_name after_save :update_project_statistics, if: :saved_change_to_namespace_id? @@ -453,7 +450,7 @@ class Project < ApplicationRecord :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, - :releases_access_level, + :monitor_access_level, :releases_access_level, to: :project_feature, allow_nil: true delegate :show_default_award_emojis, :show_default_award_emojis=, @@ -461,6 +458,9 @@ class Project < ApplicationRecord :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, to: :project_setting, allow_nil: true + delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?, + to: :project_setting + delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting delegate :squash_option, :squash_option=, to: :project_setting delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting @@ -565,26 +565,29 @@ class Project < ApplicationRecord scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } scope :sorted_by_similarity_desc, -> (search, include_in_select: false) do - order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ - { column: arel_table["path"], multiplier: 1 }, - { column: arel_table["name"], multiplier: 0.7 }, - { column: arel_table["description"], multiplier: 0.2 } - ]) - - order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'similarity', - column_expression: order_expression, - order_expression: order_expression.desc, - order_direction: :desc, - distinct: false, - add_to_projections: true - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'id', - order_expression: Project.arel_table[:id].desc - ) - ]) + order_expression = Gitlab::Database::SimilarityScore.build_expression( + search: search, + rules: [ + { column: arel_table["path"], multiplier: 1 }, + { column: arel_table["name"], multiplier: 0.7 }, + { column: arel_table["description"], multiplier: 0.2 } + ]) + + order = Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'similarity', + column_expression: order_expression, + order_expression: order_expression.desc, + order_direction: :desc, + distinct: false, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: Project.arel_table[:id].desc + ) + ]) order.apply_cursor_conditions(reorder(order)) end @@ -611,7 +614,7 @@ class Project < ApplicationRecord scope :include_integration, -> (integration_association_name) { includes(integration_association_name) } scope :with_integration, -> (integration_class) { joins(:integrations).merge(integration_class.all) } scope :with_active_integration, -> (integration_class) { with_integration(integration_class).merge(integration_class.active) } - scope :with_shared_runners, -> { where(shared_runners_enabled: true) } + scope :with_shared_runners_enabled, -> { where(shared_runners_enabled: true) } scope :inside_path, ->(path) do # We need routes alias rs for JOIN so it does not conflict with # includes(:route) which we use in ProjectsFinder. @@ -1163,7 +1166,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_ref(ref) return unless latest_pipeline - latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) + latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name) end def latest_successful_build_for_sha(job_name, sha) @@ -1172,7 +1175,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_sha(sha) return unless latest_pipeline - latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) + latest_pipeline.build_with_artifacts_in_self_and_project_descendants(job_name) end def latest_successful_build_for_ref!(job_name, ref = default_branch) @@ -1564,9 +1567,7 @@ class Project < ApplicationRecord end def disabled_integrations - disabled_integrations = [] - disabled_integrations << 'shimo' unless Feature.enabled?(:shimo_integration, self) - disabled_integrations + [] end def find_or_initialize_integration(name) @@ -2369,28 +2370,6 @@ class Project < ApplicationRecord .first end - def ci_variables_for(ref:, environment: nil) - cache_key = "ci_variables_for:project:#{self&.id}:ref:#{ref}:environment:#{environment}" - - ::Gitlab::SafeRequestStore.fetch(cache_key) do - uncached_ci_variables_for(ref: ref, environment: environment) - end - end - - def uncached_ci_variables_for(ref:, environment: nil) - result = if protected_for?(ref) - variables - else - variables.unprotected - end - - if environment - result.on_environment(environment) - else - result.where(environment_scope: '*') - end - end - def protected_for?(ref) raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref) @@ -2582,10 +2561,7 @@ class Project < ApplicationRecord def badges return project_badges unless group - Badge.from_union([ - project_badges, - GroupBadge.where(group: group.self_and_ancestors) - ]) + Badge.from_union([project_badges, GroupBadge.where(group: group.self_and_ancestors)]) end def merge_requests_allowing_push_to_user(user) @@ -2631,11 +2607,7 @@ class Project < ApplicationRecord def gitlab_deploy_token strong_memoize(:gitlab_deploy_token) do - if Feature.enabled?(:ci_variable_for_group_gitlab_deploy_token, self) - deploy_tokens.gitlab_deploy_token || group&.gitlab_deploy_token - else - deploy_tokens.gitlab_deploy_token - end + deploy_tokens.gitlab_deploy_token || group&.gitlab_deploy_token end end @@ -2693,7 +2665,12 @@ class Project < ApplicationRecord end def leave_pool_repository - pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil) + return if pool_repository.blank? + + # Disconnecting the repository can be expensive, so let's skip it if + # this repository is being deleted anyway. + pool_repository.unlink_repository(repository, disconnect: !pending_delete?) + update_column(:pool_repository_id, nil) end def link_pool_repository @@ -3045,10 +3022,24 @@ class Project < ApplicationRecord licensed_feature_available?(:security_training) end + def packages_policy_subject + if Feature.enabled?(:read_package_policy_rule, group) + ::Packages::Policies::Project.new(self) + else + self + end + end + def destroy_deployment_by_id(deployment_id) deployments.where(id: deployment_id).fast_destroy_all end + def can_create_custom_domains? + return true if Gitlab::CurrentSettings.max_pages_custom_domains_per_project == 0 + + pages_domains.count < Gitlab::CurrentSettings.max_pages_custom_domains_per_project + end + private # overridden in EE @@ -3300,6 +3291,10 @@ class Project < ApplicationRecord end end + def remove_leading_spaces_on_name + name&.lstrip! + end + def set_package_registry_access_level return if !project_feature || project_feature.package_registry_access_level_changed? diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 8623e477c06..dad8aaf0625 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -17,6 +17,7 @@ class ProjectFeature < ApplicationRecord pages metrics_dashboard analytics + monitor operations security_and_compliance container_registry diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 59d2e3deb4f..f5c346eda30 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProjectSetting < ApplicationRecord + include ::Gitlab::Utils::StrongMemoize + ALLOWED_TARGET_PLATFORMS = %w(ios osx tvos watchos android).freeze belongs_to :project, inverse_of: :project_setting @@ -47,6 +49,15 @@ class ProjectSetting < ApplicationRecord end end + def show_diff_preview_in_email? + if project.group + super && project.group&.show_diff_preview_in_email? + else + !!super + end + end + strong_memoize_attr :show_diff_preview_in_email + private def validates_mr_default_target_self diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index a0af1b47d01..a91e0291438 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -11,9 +11,10 @@ class ProjectStatistics < ApplicationRecord default_value_for :snippets_size, 0 counter_attribute :build_artifacts_size - counter_attribute :storage_size counter_attribute_after_flush do |project_statistic| + project_statistic.refresh_storage_size! + Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id) end @@ -21,7 +22,6 @@ class ProjectStatistics < ApplicationRecord COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size, :uploads_size, :container_registry_size].freeze INCREMENTABLE_COLUMNS = { - build_artifacts_size: %i[storage_size], packages_size: %i[storage_size], pipeline_artifacts_size: %i[storage_size], snippets_size: %i[storage_size] @@ -109,21 +109,25 @@ class ProjectStatistics < ApplicationRecord self.storage_size = storage_size end - # Since this incremental update method does not call update_storage_size above, - # we have to update the storage_size here as additional column. - # Additional columns are updated depending on key => [columns], which allows - # to update statistics which are and also those which aren't included in storage_size - # or any other additional summary column in the future. + def refresh_storage_size! + update_storage_size + save! + end + + # Since this incremental update method does not call update_storage_size above through before_save, + # we have to update the storage_size separately. + # + # For counter attributes, storage_size will be refreshed after the counter is flushed, + # through counter_attribute_after_flush + # + # For non-counter attributes, storage_size is updated depending on key => [columns] in INCREMENTABLE_COLUMNS def self.increment_statistic(project, key, amount) - raise ArgumentError, "Cannot increment attribute: #{key}" unless INCREMENTABLE_COLUMNS.key?(key) + raise ArgumentError, "Cannot increment attribute: #{key}" unless incrementable_attribute?(key) return if amount == 0 project.statistics.try do |project_statistics| - if project_statistics.counter_attribute_enabled?(key) - statistics_to_increment = [key] + INCREMENTABLE_COLUMNS[key].to_a - statistics_to_increment.each do |statistic| - project_statistics.delayed_increment_counter(statistic, amount) - end + if counter_attribute_enabled?(key) + project_statistics.delayed_increment_counter(key, amount) else legacy_increment_statistic(project, key, amount) end @@ -149,6 +153,10 @@ class ProjectStatistics < ApplicationRecord update_all(updates.join(', ')) end + def self.incrementable_attribute?(key) + INCREMENTABLE_COLUMNS.key?(key) || counter_attribute_enabled?(key) + end + private def schedule_namespace_aggregation_worker diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb index dee4afdefa6..e66e1d5b42f 100644 --- a/app/models/projects/build_artifacts_size_refresh.rb +++ b/app/models/projects/build_artifacts_size_refresh.rb @@ -2,6 +2,7 @@ module Projects class BuildArtifactsSizeRefresh < ApplicationRecord + include AfterCommitQueue include BulkInsertSafe STALE_WINDOW = 2.hours @@ -52,6 +53,8 @@ module Projects scope :remaining, -> { with_state(:created, :pending).or(stale) } scope :processing_queue, -> { remaining.order(state: :desc) } + after_destroy :schedule_namespace_aggregation_worker + def self.enqueue_refresh(projects) now = Time.zone.now @@ -93,5 +96,13 @@ module Projects def started? !created? end + + private + + def schedule_namespace_aggregation_worker + run_after_commit do + Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id) + end + end end end diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index b0f138714a0..3155eede2bd 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -18,9 +18,11 @@ module Projects scope :without_assigned_projects, -> { where(total_projects_count: 0) } scope :order_by_non_private_projects_count, -> { order(non_private_projects_count: :desc).order(id: :asc) } scope :reorder_by_similarity, -> (search) do - order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ - { column: arel_table['name'] } - ]) + order_expression = Gitlab::Database::SimilarityScore.build_expression( + search: search, + rules: [ + { column: arel_table['name'] } + ]) reorder(order_expression.desc, arel_table['non_private_projects_count'].desc, arel_table['id']) end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 76c277e4b86..b3a918d8952 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -25,10 +25,12 @@ class ProtectedBranch < ApplicationRecord end # Check if branch name is marked as protected in the system - def self.protected?(project, ref_name, dry_run: true) + def self.protected?(project, ref_name) return true if project.empty_repo? && project.default_branch_protected? return false if ref_name.blank? + dry_run = Feature.disabled?(:rely_on_protected_branches_cache, project) + new_cache_result = new_cache(project, ref_name, dry_run: dry_run) return new_cache_result unless new_cache_result.nil? diff --git a/app/models/repository.rb b/app/models/repository.rb index 26c3b01a46e..ee1bea0e8d2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -194,6 +194,18 @@ class Repository CommitCollection.new(container, commits, ref) end + def list_commits_by(query, ref, author: nil, before: nil, after: nil, limit: 1000) + return [] unless exists? + return [] unless has_visible_content? + return [] unless query.present? && ref.present? + + commits = raw_repository.list_commits_by( + query, ref, author: author, before: before, after: after, limit: limit).map do |c| + commit(c) + end + CommitCollection.new(container, commits, ref) + end + def find_branch(name) raw_repository.find_branch(name) end @@ -779,8 +791,8 @@ class Repository raw_repository.branch_names_contains_sha(sha) end - def tag_names_contains(sha) - raw_repository.tag_names_contains_sha(sha) + def tag_names_contains(sha, limit: 0) + raw_repository.tag_names_contains_sha(sha, limit: limit) end def local_branches @@ -796,7 +808,7 @@ class Repository def create_dir(user, path, **options) options[:actions] = [{ action: :create_dir, file_path: path }] - multi_action(user, **options) + commit_files(user, **options) end def create_file(user, path, content, **options) @@ -808,7 +820,7 @@ class Repository options[:actions].push({ action: :chmod, file_path: path, execute_filemode: execute_filemode }) end - multi_action(user, **options) + commit_files(user, **options) end def update_file(user, path, content, **options) @@ -823,13 +835,13 @@ class Repository options[:actions].push({ action: :chmod, file_path: path, execute_filemode: execute_filemode }) end - multi_action(user, **options) + commit_files(user, **options) end def delete_file(user, path, **options) options[:actions] = [{ action: :delete, file_path: path }] - multi_action(user, **options) + commit_files(user, **options) end def with_cache_hooks @@ -843,14 +855,14 @@ class Repository result.newrev end - def multi_action(user, **options) + def commit_files(user, **options) start_project = options.delete(:start_project) if start_project options[:start_repository] = start_project.repository.raw_repository end - with_cache_hooks { raw.multi_action(user, **options) } + with_cache_hooks { raw.commit_files(user, **options) } end def merge(user, source_sha, merge_request, message) diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 689a9d8a8ae..6ebb9d5f176 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -3,8 +3,9 @@ class ResourceStateEvent < ResourceEvent include IssueResourceEvent include MergeRequestResourceEvent + include Importable - validate :exactly_one_issuable + validate :exactly_one_issuable, unless: :importing? belongs_to :source_merge_request, class_name: 'MergeRequest', foreign_key: :source_merge_request_id @@ -32,9 +33,9 @@ class ResourceStateEvent < ResourceEvent case state when 'closed' - issue_usage_counter.track_issue_closed_action(author: user) + issue_usage_counter.track_issue_closed_action(author: user, project: issue.project) when 'reopened' - issue_usage_counter.track_issue_reopened_action(author: user) + issue_usage_counter.track_issue_reopened_action(author: user, project: issue.project) else # no-op, nothing to do, not a state we're tracking end diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index db87ff09159..26bf2a225d4 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -5,8 +5,9 @@ class ResourceTimeboxEvent < ResourceEvent include IssueResourceEvent include MergeRequestResourceEvent + include Importable - validate :exactly_one_issuable + validate :exactly_one_issuable, unless: :importing? enum action: { add: 1, @@ -34,7 +35,8 @@ class ResourceTimeboxEvent < ResourceEvent case self when ResourceMilestoneEvent - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user, + project: issue.project) else # no-op end diff --git a/app/models/route.rb b/app/models/route.rb index 2f6b0a8e8f1..f2fe1664f9e 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -39,17 +39,17 @@ class Route < ApplicationRecord attributes[:name] = route.name.sub(name_before_last_save, name) end - if attributes.present? - old_path = route.path + next if attributes.empty? - # Callbacks must be run manually - route.update_columns(attributes.merge(updated_at: Time.current)) + old_path = route.path - # We are not calling route.delete_conflicting_redirects here, in hopes - # of avoiding deadlocks. The parent (self, in this method) already - # called it, which deletes conflicts for all descendants. - route.create_redirect(old_path) if attributes[:path] - end + # Callbacks must be run manually + route.update_columns(attributes.merge(updated_at: Time.current)) + + # We are not calling route.delete_conflicting_redirects here, in hopes + # of avoiding deadlocks. The parent (self, in this method) already + # called it, which deletes conflicts for all descendants. + route.create_redirect(old_path) if attributes[:path] end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 943d09d983b..9b7c37dd23e 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -84,7 +84,7 @@ class Snippet < ApplicationRecord participant :notes_with_associations attr_spammable :title, spam_title: true - attr_spammable :content, spam_description: true + attr_spammable :description, spam_description: true attr_encrypted :secret_token, key: Settings.attr_encrypted_db_key_base_truncated, @@ -269,13 +269,7 @@ class Snippet < ApplicationRecord def check_for_spam?(user:) visibility_level_changed?(to: Snippet::PUBLIC) || - (public? && (title_changed? || content_changed?)) - end - - # snippets are the biggest sources of spam - override :allow_possible_spam? - def allow_possible_spam? - false + (public? && (title_changed? || description_changed?)) end def spammable_entity_type diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 5ac159d9615..a959ad4d548 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -31,7 +31,7 @@ class SnippetRepository < ApplicationRecord options[:actions] = transform_file_entries(files) - capture_git_error { repository.multi_action(user, **options) } + capture_git_error { repository.commit_files(user, **options) } ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index cc389dbe3f4..4e86036952b 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -25,6 +25,7 @@ class SystemNoteMetadata < ApplicationRecord tag due_date start_date_or_due_date pinned_embed cherry_pick health_status approved unapproved status alert_issue_added relate unrelate new_alert_added severity attention_requested attention_request_removed contact timeline_event + issue_type relate_to_child unrelate_from_child relate_to_parent unrelate_from_parent ].freeze validates :note, presence: true, unless: :importing? diff --git a/app/models/todo.rb b/app/models/todo.rb index d165e60e4c3..634fa9e7eda 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -96,10 +96,11 @@ class Todo < ApplicationRecord def for_group_ids_and_descendants(group_ids) groups = Group.groups_including_descendants_by(group_ids) - from_union([ - for_project(Project.for_group(groups)), - for_group(groups) - ]) + from_union( + [ + for_project(Project.for_group(groups)), + for_group(groups) + ]) end # Returns `true` if the current user has any todos for the given target with the optional given state. diff --git a/app/models/user.rb b/app/models/user.rb index afee2d70844..8825c18ea48 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -92,7 +92,6 @@ class User < ApplicationRecord include ForcedEmailConfirmation include RequireEmailVerification - MINIMUM_INACTIVE_DAYS = 90 MINIMUM_DAYS_CREATED = 7 # Override Devise::Models::Trackable#update_tracked_fields! @@ -262,6 +261,7 @@ class User < ApplicationRecord presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } validates :username, presence: true + validate :check_password_weakness, if: :encrypted_password_changed? validates :namespace, presence: true validate :namespace_move_dir_allowed, if: :username_changed? @@ -488,7 +488,7 @@ class User < ApplicationRecord scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } - scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } + scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) } scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) } @@ -697,28 +697,29 @@ class User < ApplicationRecord scope = options[:with_private_emails] ? with_primary_or_secondary_email(query) : with_public_email(query) scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: options[:use_minimum_char_limit])) - order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_match_priority', - order_expression: sanitized_order_sql.asc, - add_to_projections: true, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_name', - order_expression: arel_table[:name].asc, - add_to_projections: true, - nullable: :not_nullable, - distinct: false - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'users_id', - order_expression: arel_table[:id].asc, - add_to_projections: true, - nullable: :not_nullable, - distinct: true - ) - ]) + order = Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_match_priority', + order_expression: sanitized_order_sql.asc, + add_to_projections: true, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_name', + order_expression: arel_table[:name].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'users_id', + order_expression: arel_table[:id].asc, + add_to_projections: true, + nullable: :not_nullable, + distinct: true + ) + ]) scope.reorder(order) end @@ -1358,10 +1359,11 @@ class User < ApplicationRecord end def accessible_deploy_keys - DeployKey.from_union([ - DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)), - DeployKey.are_public - ]) + DeployKey.from_union( + [ + DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)), + DeployKey.are_public + ]) end def created_by @@ -1662,10 +1664,11 @@ class User < ApplicationRecord strong_memoize(:forkable_namespaces) do personal_namespace = Namespace.where(id: namespace_id) - Namespace.from_union([ - manageable_groups(include_groups_with_developer_maintainer_access: true), - personal_namespace - ]) + Namespace.from_union( + [ + manageable_groups(include_groups_with_developer_maintainer_access: true), + personal_namespace + ]) end end @@ -2072,6 +2075,7 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end + # Deprecated: do not use. See: https://gitlab.com/gitlab-org/gitlab/-/issues/371017 def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil) source_feature_name = "#{feature_name}_#{namespace.id}" callout = namespace_callouts_by_feature_name[source_feature_name] @@ -2151,10 +2155,6 @@ class User < ApplicationRecord end end - def mr_attention_requests_enabled? - Feature.enabled?(:mr_attention_requests, self) - end - def account_age_in_days (Date.current - created_at.to_date).to_i end @@ -2247,10 +2247,11 @@ class User < ApplicationRecord end def authorized_groups_without_shared_membership - Group.from_union([ - groups.select(*Namespace.cached_column_list), - authorized_projects.joins(:namespace).select(*Namespace.cached_column_list) - ]) + Group.from_union( + [ + groups.select(*Namespace.cached_column_list), + authorized_projects.joins(:namespace).select(*Namespace.cached_column_list) + ]) end def authorized_groups_with_shared_membership @@ -2260,10 +2261,10 @@ class User < ApplicationRecord Group .with(cte.to_arel) .from_union([ - Group.from(cte_alias), - Group.joins(:shared_with_group_links) - .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) }) - ]) + Group.from(cte_alias), + Group.joins(:shared_with_group_links) + .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) }) + ]) end def default_private_profile_to_false @@ -2314,6 +2315,14 @@ class User < ApplicationRecord errors.add(:username, _('ending with a reserved file extension is not allowed.')) end + def check_password_weakness + if Feature.enabled?(:block_weak_passwords) && + password.present? && + Security::WeakPasswords.weak_for_user?(password, self) + errors.add(:password, _('must not contain commonly used combinations of words and letters')) + end + end + def groups_with_developer_maintainer_project_access project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS] @@ -2325,7 +2334,7 @@ class User < ApplicationRecord end def no_recent_activity? - last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i + last_active_at.to_i <= Gitlab::CurrentSettings.deactivate_dormant_users_period.days.ago.to_i end def update_highest_role? diff --git a/app/models/user_status.rb b/app/models/user_status.rb index dee976a4497..0c66f465356 100644 --- a/app/models/user_status.rb +++ b/app/models/user_status.rb @@ -29,6 +29,10 @@ class UserStatus < ApplicationRecord cache_markdown_field :message, pipeline: :emoji + def clear_status_after + clear_status_at + end + def clear_status_after=(value) self.clear_status_at = CLEAR_STATUS_QUICK_OPTIONS[value]&.from_now end diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 7b5c7fef7ba..03841ee48fa 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -43,12 +43,11 @@ module Users verification_reminder: 40, # EE-only ci_deprecation_warning_for_types_keyword: 41, security_training_feature_promotion: 42, # EE-only - storage_enforcement_banner_first_enforcement_threshold: 43, - storage_enforcement_banner_second_enforcement_threshold: 44, - storage_enforcement_banner_third_enforcement_threshold: 45, - storage_enforcement_banner_fourth_enforcement_threshold: 46, - attention_requests_top_nav: 47, - attention_requests_side_nav: 48, + storage_enforcement_banner_first_enforcement_threshold: 43, # EE-only + storage_enforcement_banner_second_enforcement_threshold: 44, # EE-only + storage_enforcement_banner_third_enforcement_threshold: 45, # EE-only + storage_enforcement_banner_fourth_enforcement_threshold: 46, # EE-only + # 47 and 48 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95446 # 49 was removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91533 # because the banner was no longer relevant. # Records will be migrated with https://gitlab.com/gitlab-org/gitlab/-/issues/367293 @@ -61,7 +60,8 @@ module Users namespace_storage_limit_banner_warning_threshold: 56, # EE-only namespace_storage_limit_banner_alert_threshold: 57, # EE-only namespace_storage_limit_banner_error_threshold: 58, # EE-only - project_quality_summary_feedback: 59 # EE-only + project_quality_summary_feedback: 59, # EE-only + merge_request_settings_moved_callout: 60 } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 998a5deb0fd..272f31aa9ce 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -21,5 +21,11 @@ module Users network: network ).order(credit_card_validated_at: :desc).includes(:user) end + + def similar_holder_names_count + return 0 unless holder_name + + self.class.where('lower(holder_name) = lower(:value)', value: holder_name).count + end end end diff --git a/app/models/users/ghost_user_migration.rb b/app/models/users/ghost_user_migration.rb new file mode 100644 index 00000000000..1d93498e88b --- /dev/null +++ b/app/models/users/ghost_user_migration.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Users + class GhostUserMigration < ApplicationRecord + self.table_name = 'ghost_user_migrations' + + belongs_to :user + belongs_to :initiator_user, class_name: 'User' + + validates :user_id, presence: true + end +end diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 70498ae83e0..3e3e424e9c9 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -11,10 +11,10 @@ module Users enum feature_name: { invite_members_banner: 1, approaching_seat_count_threshold: 2, # EE-only - storage_enforcement_banner_first_enforcement_threshold: 3, - storage_enforcement_banner_second_enforcement_threshold: 4, - storage_enforcement_banner_third_enforcement_threshold: 5, - storage_enforcement_banner_fourth_enforcement_threshold: 6, + storage_enforcement_banner_first_enforcement_threshold: 3, # EE-only + storage_enforcement_banner_second_enforcement_threshold: 4, # EE-only + storage_enforcement_banner_third_enforcement_threshold: 5, # EE-only + storage_enforcement_banner_fourth_enforcement_threshold: 6, # EE-only preview_user_over_limit_free_plan_alert: 7, # EE-only user_reached_limit_free_plan_alert: 8, # EE-only free_group_limited_alert: 9, # EE-only diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb index a20a196a4ef..4e655a96b57 100644 --- a/app/models/users/namespace_callout.rb +++ b/app/models/users/namespace_callout.rb @@ -11,10 +11,10 @@ module Users enum feature_name: { invite_members_banner: 1, approaching_seat_count_threshold: 2, # EE-only - storage_enforcement_banner_first_enforcement_threshold: 3, - storage_enforcement_banner_second_enforcement_threshold: 4, - storage_enforcement_banner_third_enforcement_threshold: 5, - storage_enforcement_banner_fourth_enforcement_threshold: 6, + storage_enforcement_banner_first_enforcement_threshold: 3, # EE-only + storage_enforcement_banner_second_enforcement_threshold: 4, # EE-only + storage_enforcement_banner_third_enforcement_threshold: 5, # EE-only + storage_enforcement_banner_fourth_enforcement_threshold: 6, # EE-only preview_user_over_limit_free_plan_alert: 7, # EE-only user_reached_limit_free_plan_alert: 8, # EE-only web_hook_disabled: 9 diff --git a/app/models/users/project_callout.rb b/app/models/users/project_callout.rb index ddc5f8fb4de..98dacbe394a 100644 --- a/app/models/users/project_callout.rb +++ b/app/models/users/project_callout.rb @@ -9,7 +9,9 @@ module Users belongs_to :project enum feature_name: { - awaiting_members_banner: 1 # EE-only + awaiting_members_banner: 1, # EE-only + web_hook_disabled: 2, + ultimate_feature_removal_banner: 3 } validates :project, presence: true diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb index 1549c099a64..9a514b82506 100644 --- a/app/models/users_star_project.rb +++ b/app/models/users_star_project.rb @@ -3,7 +3,7 @@ class UsersStarProject < ApplicationRecord include Sortable - belongs_to :project, counter_cache: :star_count, touch: true + belongs_to :project, counter_cache: :star_count belongs_to :user validates :user, presence: true diff --git a/app/models/wiki.rb b/app/models/wiki.rb index d28a73b644f..fac79a8194a 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -103,6 +103,17 @@ class Wiki def find_by_id(container_id) container_class.find_by_id(container_id)&.wiki end + + def sluggified_full_path(title, extension) + sluggified_title(title) + '.' + extension + end + + def sluggified_title(title) + title = Gitlab::EncodingHelper.encode_utf8_no_detect(title) + title = File.expand_path(title, '/') + title = Pathname.new(title).relative_path_from('/').to_s + title.tr(' ', '-') + end end def initialize(container, user = nil) @@ -206,10 +217,11 @@ class Wiki # # Returns an initialized WikiPage instance or nil def find_page(title, version = nil, load_content: true) - page_title, page_dir = page_title_and_dir(title) - - if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content) - WikiPage.new(self, page) + if find_page_with_repository_rpcs? + create_wiki_repository unless repository_exists? + find_page_with_repository_rpcs(title, version, load_content: load_content) + else + find_page_with_legacy_wiki_service(title, version, load_content: load_content) end end @@ -419,19 +431,83 @@ class Wiki end def sluggified_full_path(title, extension) - sluggified_title(title) + '.' + extension + self.class.sluggified_full_path(title, extension) end def sluggified_title(title) - utf8_encoded_title = Gitlab::EncodingHelper.encode_utf8_no_detect(title) + self.class.sluggified_title(title) + end - sanitized_title(utf8_encoded_title).tr(' ', '-') + def canonicalize_filename(filename) + Gitlab::Git::Wiki::GollumSlug.canonicalize_filename(filename) end - def sanitized_title(title) - clean_absolute_path = File.expand_path(title, '/') + def find_page_with_legacy_wiki_service(title, version, load_content: false) + page_title, page_dir = page_title_and_dir(title) + + if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content) + WikiPage.new(self, page) + end + end + + def find_matched_file(title, version) + escaped_path = RE2::Regexp.escape(sluggified_title(title)) + # We could not use ALLOWED_EXTENSIONS_REGEX constant or similar regexp with + # Regexp.union. The result combination complicated modifiers: + # /(?i-mx:md|mkdn?|mdown|markdown)|(?i-mx:rdoc).../ + # Regexp used by Gitaly is Go's Regexp package. It does not support those + # features. So, we have to compose another more-friendly regexp to pass to + # Gitaly side. + extension_regexp = Wiki::MARKUPS.map { |_, format| format[:extension_regex].source }.join("|") + path_regexp = Gitlab::EncodingHelper.encode_utf8_no_detect("(?i)^#{escaped_path}\\.(#{extension_regexp})$") + + matched_files = repository.search_files_by_regexp(path_regexp, version) + return if matched_files.blank? + + Gitlab::EncodingHelper.encode_utf8_no_detect(matched_files.first) + end + + def find_page_format(path) + ext = File.extname(path).downcase[1..] + MARKUPS.find { |_, markup| markup[:extension_regex].match?(ext) }&.first + end + + def check_page_historical(path, commit) + repository.last_commit_for_path('HEAD', path).id != commit.id + end + + def find_page_with_repository_rpcs(title, version, load_content: true) + version = version.presence || 'HEAD' + path = find_matched_file(title, version) + return if path.blank? + + blob_options = load_content ? {} : { limit: 0 } + blob = repository.blob_at(version, path, **blob_options) + commit = repository.commit(blob.commit_id) + format = find_page_format(path) + + page = Gitlab::Git::WikiPage.new( + url_path: sluggified_title(path.sub(/\.[^.]+\z/, "")), + title: canonicalize_filename(path), + format: format, + path: sluggified_title(path), + raw_data: blob.data, + name: canonicalize_filename(path), + historical: version == 'HEAD' ? false : check_page_historical(path, commit), + version: Gitlab::Git::WikiPageVersion.new(commit, format) + ) + WikiPage.new(self, page) + end + + def find_page_with_repository_rpcs? + group = + if container.is_a?(::Group) + container + else + container.group + end - Pathname.new(clean_absolute_path).relative_path_from('/').to_s + Feature.enabled?(:wiki_find_page_with_normal_repository_rpcs, group, type: :development) end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 451359c1f85..05e45fa5b29 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -37,11 +37,11 @@ class WorkItem < Issue override :parent_link_confidentiality def parent_link_confidentiality if confidential? && work_item_children.public_only.exists? - errors.add(:confidential, _('confidential parent can not be used if there are non-confidential children.')) + errors.add(:base, _('A confidential work item cannot have a parent that already has non-confidential children.')) end if !confidential? && work_item_parent&.confidential? - errors.add(:confidential, _('associated parent is confidential and can not have non-confidential children.')) + errors.add(:base, _('A non-confidential work item cannot have a confidential parent.')) end end diff --git a/app/models/work_items/widgets/description.rb b/app/models/work_items/widgets/description.rb index 1e84d172bef..ec3b7957c79 100644 --- a/app/models/work_items/widgets/description.rb +++ b/app/models/work_items/widgets/description.rb @@ -3,7 +3,13 @@ module WorkItems module Widgets class Description < Base - delegate :description, to: :work_item + delegate :description, :edited?, :last_edited_at, to: :work_item + + def last_edited_by + return unless work_item.edited? + + work_item.last_edited_by + end end end end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index f377ff85b5e..b657b569e3e 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -2,6 +2,8 @@ module Ci class BuildPolicy < CommitStatusPolicy + delegate { @subject.project } + condition(:protected_ref) do access = ::Gitlab::UserAccess.new(@user, container: @subject.project) @@ -25,6 +27,10 @@ module Ci false end + condition(:prevent_rollback) do + @subject.prevent_rollback_deployment? + end + condition(:owner_of_job) do @subject.triggered_by?(@user) end @@ -71,7 +77,7 @@ module Ci # Authorizing the user to access to protected entities. # There is a "jailbreak" mode to exceptionally bypass the authorization, # however, you should NEVER allow it, rather suspect it's a wrong feature/product design. - rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment) }.policy do + rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment | prevent_rollback) }.policy do prevent :update_build prevent :update_commit_status prevent :erase_build diff --git a/app/policies/ci/job_artifact_policy.rb b/app/policies/ci/job_artifact_policy.rb new file mode 100644 index 00000000000..e25c7311565 --- /dev/null +++ b/app/policies/ci/job_artifact_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class JobArtifactPolicy < BasePolicy + delegate { @subject.job.project } + end +end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 8a99f4d1a3e..a52dac446ea 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -9,19 +9,65 @@ module Ci @user.owns_runner?(@subject) end - condition(:belongs_to_multiple_projects) do + with_options scope: :subject, score: 0 + condition(:is_instance_runner) do + @subject.instance_type? + end + + with_options scope: :subject, score: 0 + condition(:is_group_runner) do + @subject.group_type? + end + + with_options scope: :user, score: 5 + condition(:any_developer_groups_inheriting_shared_runners) do + @user.developer_groups.with_shared_runners_enabled.any? + end + + with_options scope: :user, score: 5 + condition(:any_developer_projects_inheriting_shared_runners) do + @user.authorized_projects(Gitlab::Access::DEVELOPER).with_shared_runners_enabled.any? + end + + with_options score: 10 + condition(:any_associated_projects_in_group_runner_inheriting_group_runners) do + # Check if any projects where user is a developer are inheriting group runners + @subject.groups&.any? do |group| + group.all_projects + .with_group_runners_enabled + .visible_to_user_and_access_level(@user, Gitlab::Access::DEVELOPER) + .exists? + end + end + + condition(:belongs_to_multiple_projects, scope: :subject) do @subject.belongs_to_more_than_one_project? end rule { anonymous }.prevent_all - rule { admin }.policy do + rule { admin | owned_runner }.policy do enable :read_builds end rule { admin | owned_runner }.policy do - enable :assign_runner enable :read_runner + end + + rule { is_instance_runner & any_developer_groups_inheriting_shared_runners }.policy do + enable :read_runner + end + + rule { is_instance_runner & any_developer_projects_inheriting_shared_runners }.policy do + enable :read_runner + end + + rule { is_group_runner & any_associated_projects_in_group_runner_inheriting_group_runners }.policy do + enable :read_runner + end + + rule { admin | owned_runner }.policy do + enable :assign_runner enable :update_runner enable :delete_runner end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 44393539327..96da0518dc0 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -59,6 +59,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy access_level(for_any_session: true) >= GroupMember::GUEST || valid_dependency_proxy_deploy_token end + condition(:observability_enabled) do + Feature.enabled?(:observability_group_tab, @subject) + end + desc "Deploy token with read_package_registry scope" condition(:read_package_registry_deploy_token) do @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry @@ -82,10 +86,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') end - condition(:change_prevent_sharing_groups_outside_hierarchy_available) do - change_prevent_sharing_groups_outside_hierarchy_available? - end - rule { can?(:read_group) & design_management_enabled }.policy do enable :read_design_activity end @@ -196,6 +196,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :set_note_created_at enable :set_emails_disabled + enable :change_prevent_sharing_groups_outside_hierarchy + enable :set_show_diff_preview_in_email enable :change_new_user_signups_cap enable :update_default_branch_protection enable :create_deploy_token @@ -204,10 +206,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :owner_access end - rule { owner & change_prevent_sharing_groups_outside_hierarchy_available }.policy do - enable :change_prevent_sharing_groups_outside_hierarchy - end - rule { can?(:read_nested_project_resources) }.policy do enable :read_group_activity enable :read_group_issues @@ -299,6 +297,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :destroy_resource_access_tokens end + rule { can?(:developer_access) & observability_enabled }.policy do + enable :read_observability + end + def access_level(for_any_session: false) return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS unless user_is_user? @@ -335,10 +337,6 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy def valid_dependency_proxy_deploy_token @user.is_a?(DeployToken) && @user&.valid_for_dependency_proxy? && @user&.has_access_to_group?(@subject) end - - def change_prevent_sharing_groups_outside_hierarchy_available? - true - end end GroupPolicy.prepend_mod_with('GroupPolicy') diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 3c5e1020c8a..e5913bab726 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -5,6 +5,7 @@ class IssuablePolicy < BasePolicy condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? } condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) } + condition(:can_read_issuable) { can?(:"read_#{@subject.to_ability_name}") } desc "User is the assignee or author" condition(:assignee_or_author) do @@ -48,6 +49,10 @@ class IssuablePolicy < BasePolicy rule { can?(:reporter_access) }.policy do enable :create_timelog end + + rule { can_read_issuable }.policy do + enable :read_issuable + end end IssuablePolicy.prepend_mod_with('IssuablePolicy') diff --git a/app/policies/packages/package_policy.rb b/app/policies/packages/package_policy.rb index 8eef280c640..829d62a6430 100644 --- a/app/policies/packages/package_policy.rb +++ b/app/policies/packages/package_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true module Packages class PackagePolicy < BasePolicy - delegate { @subject.project } + delegate { @subject.project&.packages_policy_subject } end end diff --git a/app/policies/packages/policies/group_policy.rb b/app/policies/packages/policies/group_policy.rb new file mode 100644 index 00000000000..32dbcb1b65b --- /dev/null +++ b/app/policies/packages/policies/group_policy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Packages + module Policies + class GroupPolicy < BasePolicy + delegate(:group) { @subject.group } + + overrides(:read_package) + + rule { group.public_group }.policy do + enable :read_package + end + + rule { group.reporter }.policy do + enable :read_package + end + + rule { group.read_package_registry_deploy_token }.policy do + enable :read_package + end + + rule { group.write_package_registry_deploy_token }.policy do + enable :read_package + end + end + end +end diff --git a/app/policies/packages/policies/project_policy.rb b/app/policies/packages/policies/project_policy.rb new file mode 100644 index 00000000000..c754d24349a --- /dev/null +++ b/app/policies/packages/policies/project_policy.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Packages + module Policies + class ProjectPolicy < BasePolicy + delegate(:project) { @subject.project } + + overrides(:read_package) + + condition(:package_registry_access_level_feature_flag_enabled, scope: :subject) do + ::Feature.enabled?(:package_registry_access_level, @subject) + end + + condition(:packages_enabled_for_everyone, scope: :subject) do + @subject.package_registry_access_level == ProjectFeature::PUBLIC + end + + # This rule can be removed if the `package_registry_access_level` feature flag is removed. + # Reason: If the feature flag is globally enabled, this rule will never be executed. + rule { anonymous & ~project.public_project & ~package_registry_access_level_feature_flag_enabled }.prevent_all + + # This rule can be removed if the `package_registry_access_level` feature flag is removed. + # Reason: If the feature flag is globally enabled, this rule will never be executed. + rule do + ~project.public_project & ~project.internal_access & + ~project.project_allowed_for_job_token & ~package_registry_access_level_feature_flag_enabled + end.prevent_all + + rule { project.packages_disabled }.policy do + prevent(:read_package) + end + + rule { can?(:reporter_access) }.policy do + enable :read_package + end + + rule { can?(:public_access) }.policy do + enable :read_package + end + + rule { project.read_package_registry_deploy_token }.policy do + enable :read_package + end + + rule { project.write_package_registry_deploy_token }.policy do + enable :read_package + end + + rule { package_registry_access_level_feature_flag_enabled & packages_enabled_for_everyone }.policy do + enable :read_package + end + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index f4f7275a78a..fb162d03955 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -208,6 +208,7 @@ class ProjectPolicy < BasePolicy metrics_dashboard analytics operations + monitor security_and_compliance environments feature_flags @@ -267,6 +268,7 @@ class ProjectPolicy < BasePolicy enable :set_note_created_at enable :set_emails_disabled enable :set_show_default_award_emojis + enable :set_show_diff_preview_in_email enable :set_warn_about_potentially_unwanted_characters enable :register_project_runners @@ -401,6 +403,12 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:release)) end + rule { split_operations_visibility_permissions & monitor_disabled }.policy do + prevent(:metrics_dashboard) + prevent(*create_read_update_admin_destroy(:sentry_issue)) + prevent(*create_read_update_admin_destroy(:alert_management_alert)) + end + rule { can?(:metrics_dashboard) }.policy do enable :read_prometheus enable :read_deployment diff --git a/app/policies/protected_branch_access_policy.rb b/app/policies/protected_branch_access_policy.rb new file mode 100644 index 00000000000..4f33af89d2a --- /dev/null +++ b/app/policies/protected_branch_access_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProtectedBranchAccessPolicy < BasePolicy + delegate { @subject.protected_branch } +end diff --git a/app/policies/protected_branch_policy.rb b/app/policies/protected_branch_policy.rb index 8ad06653e5c..2be96ea7f24 100644 --- a/app/policies/protected_branch_policy.rb +++ b/app/policies/protected_branch_policy.rb @@ -4,6 +4,7 @@ class ProtectedBranchPolicy < BasePolicy delegate { @subject.project } rule { can?(:admin_project) }.policy do + enable :read_protected_branch enable :create_protected_branch enable :update_protected_branch enable :destroy_protected_branch diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 887980430f4..32a7d205f46 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -49,7 +49,7 @@ module Ci { merge_train: s_('Pipeline|Merge train pipeline'), merged_result: s_('Pipeline|Merged result pipeline'), - detached: s_('Pipeline|Detached merge request pipeline') + detached: s_('Pipeline|Merge request pipeline') }.freeze end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 815a4da25ab..059d6d06be2 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -26,6 +26,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated downstream_pipeline_creation_failed: 'The downstream pipeline could not be created', secrets_provider_not_found: 'The secrets provider can not be found', reached_max_descendant_pipelines_depth: 'You reached the maximum depth of child pipelines', + reached_max_pipeline_hierarchy_size: 'The downstream pipeline tree is too large', project_deleted: 'The job belongs to a deleted project', user_blocked: 'The user who created this job is blocked', ci_quota_exceeded: 'No more CI minutes available', @@ -34,11 +35,13 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated builds_disabled: 'The CI/CD is disabled for this project', environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.', deployment_rejected: 'This deployment job was rejected.', - ip_restriction_failure: "This job could not be executed because group IP address restrictions are enabled, and the runner's IP address is not in the allowed range." + ip_restriction_failure: "This job could not be executed because group IP address restrictions are enabled, and the runner's IP address is not in the allowed range.", + failed_outdated_deployment_job: 'The deployment job is older than the latest deployment, and therefore failed.' }.freeze TROUBLESHOOTING_DOC = { - environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' } + environment_creation_failure: { path: 'ci/environments/index', anchor: 'a-deployment-job-failed-with-this-job-could-not-be-executed-because-it-would-create-an-environment-with-an-invalid-parameter-error' }, + failed_outdated_deployment_job: { path: 'ci/environments/deployment_safety', anchor: 'skip-outdated-deployment-jobs' } }.freeze private_constant :CALLOUT_FAILURE_MESSAGES diff --git a/app/presenters/deployments/deployment_presenter.rb b/app/presenters/deployments/deployment_presenter.rb new file mode 100644 index 00000000000..5ef6fcff974 --- /dev/null +++ b/app/presenters/deployments/deployment_presenter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Deployments + class DeploymentPresenter < Gitlab::View::Presenter::Delegated + presents ::Deployment, as: :deployment + + delegator_override :tags + def tags + super.map do |tag| + { + name: tag, + path: "tags/#{tag}" + } + end + end + end +end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 209f016dc6b..0be13197343 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -317,6 +317,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def autodevops_anchor_data(show_auto_devops_callout: false) + return unless project.feature_available?(:builds, current_user) + if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout if auto_devops_enabled? AnchorData.new(false, diff --git a/app/serializers/access_token_entity_base.rb b/app/serializers/access_token_entity_base.rb new file mode 100644 index 00000000000..db22dbf1302 --- /dev/null +++ b/app/serializers/access_token_entity_base.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class AccessTokenEntityBase < API::Entities::PersonalAccessToken + expose :expired?, as: :expired + expose :expires_soon?, as: :expires_soon +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 6363d6276a7..22839ba3099 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -96,8 +96,7 @@ class EnvironmentSerializer < BaseSerializer scheduled_actions: [:metadata], latest_successful_builds: [] }, - project: project_associations, - deployment: [] + project: project_associations } } end diff --git a/app/serializers/group_access_token_entity.rb b/app/serializers/group_access_token_entity.rb index e832eef1188..ab1fbb8ab46 100644 --- a/app/serializers/group_access_token_entity.rb +++ b/app/serializers/group_access_token_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # rubocop: disable Gitlab/NamespacedClass -class GroupAccessTokenEntity < API::Entities::PersonalAccessToken +class GroupAccessTokenEntity < AccessTokenEntityBase include Gitlab::Routing expose :revoke_path do |token, options| @@ -14,13 +14,13 @@ class GroupAccessTokenEntity < API::Entities::PersonalAccessToken group_id: group.path) end - expose :access_level do |token, options| + expose :role do |token, options| group = options.fetch(:group) next unless group next unless token.user - group.member(token.user)&.access_level + group.member(token.user)&.human_access end end # rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/impersonation_access_token_entity.rb b/app/serializers/impersonation_access_token_entity.rb new file mode 100644 index 00000000000..b4ed62a890d --- /dev/null +++ b/app/serializers/impersonation_access_token_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class ImpersonationAccessTokenEntity < AccessTokenEntityBase + include Gitlab::Routing + + expose :revoke_path do |token, _options| + revoke_admin_user_impersonation_token_path(token.user, token) + end +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/impersonation_access_token_serializer.rb b/app/serializers/impersonation_access_token_serializer.rb new file mode 100644 index 00000000000..d3ea5ceb305 --- /dev/null +++ b/app/serializers/impersonation_access_token_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# rubocop: disable Gitlab/NamespacedClass +class ImpersonationAccessTokenSerializer < BaseSerializer + entity ImpersonationAccessTokenEntity +end +# rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/import/provider_repo_serializer.rb b/app/serializers/import/provider_repo_serializer.rb index edd1a260146..d5d29603989 100644 --- a/app/serializers/import/provider_repo_serializer.rb +++ b/app/serializers/import/provider_repo_serializer.rb @@ -23,3 +23,5 @@ class Import::ProviderRepoSerializer < BaseSerializer super(repo, opts, entity) end end + +Import::ProviderRepoSerializer.prepend_mod diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb index 6a01c5bb297..73cb9a4a798 100644 --- a/app/serializers/member_user_entity.rb +++ b/app/serializers/member_user_entity.rb @@ -16,6 +16,10 @@ class MemberUserEntity < UserEntity user.blocked? end + expose :is_bot do |user| + user.bot? + end + expose :two_factor_enabled, if: -> (user) { current_user_can_manage_members? || current_user?(user) } do |user| user.two_factor_enabled? end diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb index f8c8e3538da..29bd26c3a15 100644 --- a/app/serializers/merge_request_noteable_entity.rb +++ b/app/serializers/merge_request_noteable_entity.rb @@ -10,6 +10,15 @@ class MergeRequestNoteableEntity < IssuableEntity expose :state expose :source_branch expose :target_branch + + expose :source_branch_path, if: -> (merge_request) { merge_request.source_project } do |merge_request| + project_tree_path(merge_request.source_project, merge_request.source_branch) + end + + expose :target_branch_path, if: -> (merge_request) { merge_request.source_project } do |merge_request| + project_tree_path(merge_request.source_project, merge_request.target_branch) + end + expose :diff_head_sha expose :create_note_path do |merge_request| @@ -40,6 +49,10 @@ class MergeRequestNoteableEntity < IssuableEntity expose :can_update do |merge_request| can?(current_user, :update_merge_request, merge_request) end + + expose :can_approve do |merge_request| + merge_request.can_be_approved_by?(current_user) + end end expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request| @@ -65,3 +78,5 @@ class MergeRequestNoteableEntity < IssuableEntity @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter end end + +MergeRequestNoteableEntity.prepend_mod_with('MergeRequestNoteableEntity') diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index 2e875af6531..36825d14062 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -17,7 +17,7 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic end expose :reviewed, if: satisfies(:present?, :allows_reviewers?) do |user, options| - find_reviewer_or_assignee(user, options)&.reviewed? + options[:merge_request].find_reviewer(user)&.reviewed? end expose :approved, if: satisfies(:present?) do |user, options| @@ -25,16 +25,6 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic # makes one query per merge request, whereas #approved_by? makes one per user options[:merge_request].approvals.any? { |app| app.user_id == user.id } end - - private - - def find_reviewer_or_assignee(user, options) - if options[:type] == :reviewers - options[:merge_request].find_reviewer(user) - else - options[:merge_request].find_assignee(user) - end - end end MergeRequestUserEntity.prepend_mod_with('MergeRequestUserEntity') diff --git a/app/serializers/personal_access_token_entity.rb b/app/serializers/personal_access_token_entity.rb index acd06fecd12..49dcdf12a6f 100644 --- a/app/serializers/personal_access_token_entity.rb +++ b/app/serializers/personal_access_token_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # rubocop: disable Gitlab/NamespacedClass -class PersonalAccessTokenEntity < API::Entities::PersonalAccessToken +class PersonalAccessTokenEntity < AccessTokenEntityBase include Gitlab::Routing expose :revoke_path do |token, options| diff --git a/app/serializers/project_access_token_entity.rb b/app/serializers/project_access_token_entity.rb index b317057c952..52bb7b05d4e 100644 --- a/app/serializers/project_access_token_entity.rb +++ b/app/serializers/project_access_token_entity.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # rubocop: disable Gitlab/NamespacedClass -class ProjectAccessTokenEntity < API::Entities::PersonalAccessToken +class ProjectAccessTokenEntity < AccessTokenEntityBase include Gitlab::Routing expose :revoke_path do |token, options| @@ -15,13 +15,13 @@ class ProjectAccessTokenEntity < API::Entities::PersonalAccessToken project_id: project.path) end - expose :access_level do |token, options| + expose :role do |token, options| project = options.fetch(:project) next unless project next unless token.user - project.member(token.user)&.access_level + project.member(token.user)&.human_access end end # rubocop: enable Gitlab/NamespacedClass diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb index 1524c1291d8..04caba43cf4 100644 --- a/app/serializers/request_aware_entity.rb +++ b/app/serializers/request_aware_entity.rb @@ -10,6 +10,6 @@ module RequestAwareEntity end def request - options.fetch(:request) + options.fetch(:request, nil) end end diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index 1b377a3d367..e0594247975 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -36,10 +36,5 @@ module AlertManagement ) end end - - override :resolving_alert? - def resolving_alert? - incoming_payload.resolved? - end end end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index e806bef46fe..509c2d4d544 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -83,6 +83,7 @@ module Auth token.audience = params[:service] token.subject = current_user.try(:username) token.expire_time = self.class.token_expire_at + token[:auth_type] = params[:auth_type] token[:access] = accesses.compact end end diff --git a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb index 3a2251f15cc..dd696da0447 100644 --- a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb +++ b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb @@ -28,31 +28,33 @@ module AuthorizedProjectUpdate current.except!(*projects_with_duplicates) remove |= current.each_with_object([]) do |(project_id, row), array| + next if fresh[project_id] && fresh[project_id] == row.access_level + # rows not in the new list or with a different access level should be # removed. - if !fresh[project_id] || fresh[project_id] != row.access_level - if incorrect_auth_found_callback - incorrect_auth_found_callback.call(project_id, row.access_level) - end - array << row.project_id + if incorrect_auth_found_callback + incorrect_auth_found_callback.call(project_id, row.access_level) end + + array << row.project_id end add = fresh.each_with_object([]) do |(project_id, level), array| + next if current[project_id] && current[project_id].access_level == level + # rows not in the old list or with a different access level should be # added. - if !current[project_id] || current[project_id].access_level != level - if missing_auth_found_callback - missing_auth_found_callback.call(project_id, level) - end - - array << { - user_id: user.id, - project_id: project_id, - access_level: level - } + + if missing_auth_found_callback + missing_auth_found_callback.call(project_id, level) end + + array << { + user_id: user.id, + project_id: project_id, + access_level: level + } end [remove, add] diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb index 9d711d83fd2..c9da889c536 100644 --- a/app/services/boards/base_item_move_service.rb +++ b/app/services/boards/base_item_move_service.rb @@ -2,6 +2,8 @@ module Boards class BaseItemMoveService < Boards::BaseService + LIST_END_POSITION = -1 + def execute(issuable) issuable_modification_params = issuable_params(issuable) return if issuable_modification_params.empty? @@ -32,7 +34,13 @@ module Boards ) end - reposition_ids = move_between_ids(params) + move_params = if params[:position_in_list].present? + move_params_from_list_position(params[:position_in_list]) + else + params + end + + reposition_ids = move_between_ids(move_params) attrs.merge!(reposition_params(reposition_ids)) if reposition_ids attrs @@ -90,6 +98,18 @@ module Boards ::Label.ids_on_board(board.id) end + def move_params_from_list_position(position) + if position == LIST_END_POSITION + { move_before_id: moving_to_list_items_relation.reverse_order.pick(:id), move_after_id: nil } + else + item_at_position = moving_to_list_items_relation.offset(position).pick(:id) # rubocop: disable CodeReuse/ActiveRecord + + return move_params_from_list_position(LIST_END_POSITION) if item_at_position.nil? + + { move_before_id: nil, move_after_id: item_at_position } + end + end + def move_between_ids(move_params) ids = [move_params[:move_before_id], move_params[:move_after_id]] .map(&:to_i) diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 90226b9d4e0..4de4d7c8f69 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -54,6 +54,10 @@ module Boards def update(issue, issue_modification_params) ::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: issue_modification_params).execute(issue) end + + def moving_to_list_items_relation + Boards::Issues::ListService.new(board.resource_parent, current_user, board_id: board.id, id: moving_to_list.id).execute + end end end end diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index a2c8ba5b1cd..45f1350df92 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -10,10 +10,11 @@ # @param filename [String] Name of the file to download, if known. Use remote filename if none given. module BulkImports class FileDownloadService + include ::BulkImports::FileDownloads::FilenameFetch + include ::BulkImports::FileDownloads::Validations + ServiceError = Class.new(StandardError) - REMOTE_FILENAME_PATTERN = %r{filename="(?<filename>[^"]+)"}.freeze - FILENAME_SIZE_LIMIT = 255 # chars before the extension DEFAULT_FILE_SIZE_LIMIT = 5.gigabytes DEFAULT_ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze @@ -74,6 +75,10 @@ module BulkImports raise e end + def raise_error(message) + raise ServiceError, message + end + def http_client @http_client ||= BulkImports::Clients::HTTP.new( url: configuration.url, @@ -85,24 +90,20 @@ module BulkImports ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? end - def headers - @headers ||= http_client.head(relative_url).headers - end - - def validate_filepath - Gitlab::Utils.check_path_traversal!(filepath) + def response_headers + @response_headers ||= http_client.head(relative_url).headers end def validate_tmpdir Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir]) end - def validate_symlink - if File.lstat(filepath).symlink? - File.delete(filepath) + def filepath + @filepath ||= File.join(@tmpdir, filename) + end - raise(ServiceError, 'Invalid downloaded file') - end + def filename + @filename.presence || remote_filename end def validate_url @@ -113,61 +114,5 @@ module BulkImports schemes: %w(http https) ) end - - def validate_content_length - validate_size!(headers['content-length']) - end - - def validate_size!(size) - if size.blank? - raise ServiceError, 'Missing content-length header' - elsif size.to_i > file_size_limit - raise ServiceError, "File size %{size} exceeds limit of %{limit}" % { - size: ActiveSupport::NumberHelper.number_to_human_size(size), - limit: ActiveSupport::NumberHelper.number_to_human_size(file_size_limit) - } - end - end - - def validate_content_type - content_type = headers['content-type'] - - raise(ServiceError, 'Invalid content type') if content_type.blank? || allowed_content_types.exclude?(content_type) - end - - def filepath - @filepath ||= File.join(@tmpdir, filename) - end - - def filename - @filename.presence || remote_filename - end - - # Fetch the remote filename information from the request content-disposition header - # - Raises if the filename does not exist - # - If the filename is longer then 255 chars truncate it - # to be a total of 255 chars (with the extension) - def remote_filename - @remote_filename ||= - headers['content-disposition'].to_s - .match(REMOTE_FILENAME_PATTERN) # matches the filename pattern - .then { |match| match&.named_captures || {} } # ensures the match is a hash - .fetch('filename') # fetches the 'filename' key or raise KeyError - .then(&File.method(:basename)) # Ensures to remove path from the filename (../ for instance) - .then(&method(:ensure_filename_size)) # Ensures the filename is within the FILENAME_SIZE_LIMIT - rescue KeyError - raise ServiceError, 'Remote filename not provided in content-disposition header' - end - - def ensure_filename_size(filename) - if filename.length <= FILENAME_SIZE_LIMIT - filename - else - extname = File.extname(filename) - basename = File.basename(filename, extname)[0, FILENAME_SIZE_LIMIT] - - "#{basename}#{extname}" - end - end end end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index c43f0d8cb4f..b1efa881180 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -65,7 +65,7 @@ module BulkImports def export_service @export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation) - TreeExportService.new(portable, config.export_path, relation) + TreeExportService.new(portable, config.export_path, relation, user) elsif config.file_relation?(relation) FileExportService.new(portable, config.export_path, relation) else diff --git a/app/services/bulk_imports/tree_export_service.rb b/app/services/bulk_imports/tree_export_service.rb index 8e885e590d1..b6f094da558 100644 --- a/app/services/bulk_imports/tree_export_service.rb +++ b/app/services/bulk_imports/tree_export_service.rb @@ -2,11 +2,12 @@ module BulkImports class TreeExportService - def initialize(portable, export_path, relation) + def initialize(portable, export_path, relation, user) @portable = portable @export_path = export_path @relation = relation @config = FileTransfer.config_for(portable) + @user = user end def execute @@ -27,7 +28,7 @@ module BulkImports private - attr_reader :export_path, :portable, :relation, :config + attr_reader :export_path, :portable, :relation, :config, :user # rubocop: disable CodeReuse/Serializer def serializer @@ -35,7 +36,8 @@ module BulkImports portable, config.portable_tree, json_writer, - exportable_path: '' + exportable_path: '', + current_user: user ) end # rubocop: enable CodeReuse/Serializer diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb index 1ae4639751b..634c547a623 100644 --- a/app/services/ci/after_requeue_job_service.rb +++ b/app/services/ci/after_requeue_job_service.rb @@ -21,9 +21,16 @@ module Ci @processable.pipeline.reset_source_bridge!(current_user) end + # rubocop: disable CodeReuse/ActiveRecord def dependent_jobs + return legacy_dependent_jobs unless ::Feature.enabled?(:ci_requeue_with_dag_object_hierarchy, project) + ordered_by_dag( - stage_dependent_jobs.or(needs_dependent_jobs).ordered_by_stage + ::Ci::Processable + .from_union(needs_dependent_jobs, stage_dependent_jobs) + .skipped + .ordered_by_stage + .preload(:needs) ) end @@ -34,22 +41,37 @@ module Ci end def stage_dependent_jobs - skipped_jobs.after_stage(@processable.stage_idx) + @processable.pipeline.processables.after_stage(@processable.stage_idx) end def needs_dependent_jobs - skipped_jobs.scheduling_type_dag.with_needs([@processable.name]) + ::Gitlab::Ci::ProcessableObjectHierarchy.new( + ::Ci::Processable.where(id: @processable.id) + ).descendants end - def skipped_jobs - @skipped_jobs ||= @processable.pipeline.processables.skipped + def legacy_skipped_jobs + @legacy_skipped_jobs ||= @processable.pipeline.processables.skipped + end + + def legacy_dependent_jobs + ordered_by_dag( + legacy_stage_dependent_jobs.or(legacy_needs_dependent_jobs).ordered_by_stage.preload(:needs) + ) + end + + def legacy_stage_dependent_jobs + legacy_skipped_jobs.after_stage(@processable.stage_idx) + end + + def legacy_needs_dependent_jobs + legacy_skipped_jobs.scheduling_type_dag.with_needs([@processable.name]) end - # rubocop: disable CodeReuse/ActiveRecord def ordered_by_dag(jobs) sorted_job_names = sort_jobs(jobs).each_with_index.to_h - jobs.preload(:needs).group_by(&:stage_idx).flat_map do |_, stage_jobs| + jobs.group_by(&:stage_idx).flat_map do |_, stage_jobs| stage_jobs.sort_by { |job| sorted_job_names.fetch(job.name) } end end diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 9705a236d98..566346a4b09 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -27,7 +27,7 @@ module Ci job.trace.archive! job.remove_pending_state! - if Feature.enabled?(:datadog_integration_logs_collection, job.project) && job.job_artifacts_trace.present? + if job.job_artifacts_trace.present? job.project.execute_integrations(Gitlab::DataBuilder::ArchiveTrace.build(job), :archive_trace_hooks) end diff --git a/app/services/ci/build_erase_service.rb b/app/services/ci/build_erase_service.rb new file mode 100644 index 00000000000..8a468e094eb --- /dev/null +++ b/app/services/ci/build_erase_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Ci + class BuildEraseService + include BaseServiceUtility + + def initialize(build, current_user) + @build = build + @current_user = current_user + end + + def execute + unless build.erasable? + return ServiceResponse.error(message: _('Build cannot be erased'), http_status: :unprocessable_entity) + end + + if build.project.refreshing_build_artifacts_size? + Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh( + method: 'Ci::BuildEraseService#execute', + project_id: build.project_id + ) + end + + destroy_artifacts + erase_trace! + update_erased! + + ServiceResponse.success(payload: build) + end + + private + + attr_reader :build, :current_user + + def destroy_artifacts + # fix_expire_at is false because in this case we want to explicitly delete the job artifacts + # this flag is a workaround that will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/355833 + Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts, fix_expire_at: false).execute + end + + def erase_trace! + build.trace.erase! + end + + def update_erased! + build.update(erased_by: current_user, erased_at: Time.current, artifacts_expire_at: nil) + end + end +end diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb index f9146b3677a..20a31322919 100644 --- a/app/services/ci/build_report_result_service.rb +++ b/app/services/ci/build_report_result_service.rb @@ -22,7 +22,8 @@ module Ci private def generate_test_suite_report(build) - build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report.get_suite(build.test_suite_name) end def tests_params(test_suite) diff --git a/app/services/ci/compare_reports_base_service.rb b/app/services/ci/compare_reports_base_service.rb index 9aba3a50ec1..ee687706b53 100644 --- a/app/services/ci/compare_reports_base_service.rb +++ b/app/services/ci/compare_reports_base_service.rb @@ -8,6 +8,8 @@ module Ci # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 class CompareReportsBaseService < ::BaseService def execute(base_pipeline, head_pipeline) + return parsing_payload(base_pipeline, head_pipeline) if base_pipeline&.running? + base_report = get_report(base_pipeline) head_report = get_report(head_pipeline) comparer = build_comparer(base_report, head_report) @@ -33,6 +35,13 @@ module Ci protected + def parsing_payload(base_pipeline, head_pipeline) + { + status: :parsing, + key: key(base_pipeline, head_pipeline) + } + end + def build_comparer(base_report, head_report) comparer_class.new(base_report, head_report) end diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index b38b3b93353..25cc9045052 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -11,6 +11,7 @@ module Ci DuplicateDownstreamPipelineError = Class.new(StandardError) MAX_NESTED_CHILDREN = 2 + MAX_HIERARCHY_SIZE = 1000 def execute(bridge) @bridge = bridge @@ -86,6 +87,11 @@ module Ci return false end + if Feature.enabled?(:ci_limit_complete_hierarchy_size) && pipeline_tree_too_large? + @bridge.drop!(:reached_max_pipeline_hierarchy_size) + return false + end + unless can_create_downstream_pipeline?(target_ref) @bridge.drop!(:insufficient_bridge_permissions) return false @@ -137,10 +143,17 @@ module Ci return false unless @bridge.triggers_child_pipeline? # only applies to parent-child pipelines not multi-project - ancestors_of_new_child = @bridge.pipeline.self_and_ancestors + ancestors_of_new_child = @bridge.pipeline.self_and_project_ancestors ancestors_of_new_child.count > MAX_NESTED_CHILDREN end + def pipeline_tree_too_large? + return false unless @bridge.triggers_downstream_pipeline? + + # Applies to the entire pipeline tree across all projects + @bridge.pipeline.complete_hierarchy_count >= MAX_HIERARCHY_SIZE + end + def config_checksum(pipeline) [pipeline.project_id, pipeline.ref, pipeline.source].hash end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 02f25a82307..af175b8da1c 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -23,6 +23,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::SeedBlock, Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules, + Gitlab::Ci::Pipeline::Chain::AssignPartition, Gitlab::Ci::Pipeline::Chain::Seed, Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Limit::Deployments, diff --git a/app/services/ci/delete_objects_service.rb b/app/services/ci/delete_objects_service.rb index bac99abadc9..7a93d0e9665 100644 --- a/app/services/ci/delete_objects_service.rb +++ b/app/services/ci/delete_objects_service.rb @@ -27,9 +27,7 @@ module Ci # `find_by_sql` performs a write in this case and we need to wrap it in # a transaction to stick to the primary database. Ci::DeletedObject.transaction do - Ci::DeletedObject.find_by_sql([ - next_batch_sql, new_pick_up_at: RETRY_IN.from_now - ]) + Ci::DeletedObject.find_by_sql([next_batch_sql, new_pick_up_at: RETRY_IN.from_now]) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb index bf2355c447a..15597eb7209 100644 --- a/app/services/ci/expire_pipeline_cache_service.rb +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -86,7 +86,7 @@ module Ci etag_paths << path end - pipeline.all_pipelines_in_hierarchy.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord + pipeline.upstream_and_all_downstreams.includes(project: [:route, { namespace: :route }]).each do |relative_pipeline| # rubocop: disable CodeReuse/ActiveRecord etag_paths << project_pipeline_path(relative_pipeline.project, relative_pipeline) etag_paths << graphql_pipeline_path(relative_pipeline) etag_paths << graphql_pipeline_sha_path(relative_pipeline.sha) diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb index 81f26e84ef8..8beecb79fd9 100644 --- a/app/services/ci/generate_coverage_reports_service.rb +++ b/app/services/ci/generate_coverage_reports_service.rb @@ -43,7 +43,7 @@ module Ci end def last_update_timestamp(pipeline_hierarchy) - pipeline_hierarchy&.self_and_descendants&.maximum(:updated_at) + pipeline_hierarchy&.self_and_project_descendants&.maximum(:updated_at) end end end diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index af56eb221d5..3dc097a8603 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -80,7 +80,7 @@ module Ci Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in artifact_attributes = { - job_id: job.id, + job: job, project: project, expire_in: expire_in } diff --git a/app/services/ci/job_artifacts/delete_service.rb b/app/services/ci/job_artifacts/delete_service.rb new file mode 100644 index 00000000000..65cae03312e --- /dev/null +++ b/app/services/ci/job_artifacts/delete_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class DeleteService + include BaseServiceUtility + + def initialize(build) + @build = build + end + + def execute + if build.project.refreshing_build_artifacts_size? + Gitlab::ProjectStatsRefreshConflictsLogger.warn_artifact_deletion_during_stats_refresh( + method: 'Ci::JobArtifacts::DeleteService#execute', + project_id: build.project_id + ) + end + + # fix_expire_at is false because in this case we want to explicitly delete the job artifacts + # this flag is a workaround that will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/355833 + Ci::JobArtifacts::DestroyBatchService.new(build.job_artifacts.erasable, fix_expire_at: false).execute + + ServiceResponse.success + end + + private + + attr_reader :build + end + end +end diff --git a/app/services/ci/job_artifacts/track_artifact_report_service.rb b/app/services/ci/job_artifacts/track_artifact_report_service.rb new file mode 100644 index 00000000000..1be1d98394f --- /dev/null +++ b/app/services/ci/job_artifacts/track_artifact_report_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class TrackArtifactReportService + include Gitlab::Utils::UsageData + + REPORT_TRACKED = %i[test].freeze + + def execute(pipeline) + REPORT_TRACKED.each do |report| + if pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(report)) + track_usage_event(event_name(report), pipeline.user_id) + end + end + end + + def event_name(report) + "i_testing_#{report}_report_uploaded" + end + end + end +end diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb index c11a8f7a0fd..99877603554 100644 --- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb +++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb @@ -27,12 +27,18 @@ module Ci end def pipeline_artifact_params - { + attributes = { pipeline: pipeline, file_type: :code_coverage, file: carrierwave_file, size: carrierwave_file['tempfile'].size } + + if ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, pipeline.project) + attributes[:locked] = pipeline.locked + end + + attributes end def carrierwave_file diff --git a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb index d6865efac9f..aeb68a75f88 100644 --- a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb +++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb @@ -13,21 +13,31 @@ module Ci return if pipeline.has_codequality_mr_diff_report? return unless new_errors_introduced? + pipeline.pipeline_artifacts.create!(**artifact_attributes) + end + + private + + attr_reader :pipeline + + def artifact_attributes file = build_carrierwave_file! - pipeline.pipeline_artifacts.create!( + attributes = { project_id: pipeline.project_id, file_type: :code_quality_mr_diff, file_format: Ci::PipelineArtifact::REPORT_TYPES.fetch(:code_quality_mr_diff), size: file["tempfile"].size, file: file, expire_at: Ci::PipelineArtifact::EXPIRATION_DATE.from_now - ) - end + } - private + if ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, pipeline.project) + attributes[:locked] = pipeline.locked + end - attr_reader :pipeline + attributes + end def merge_requests strong_memoize(:merge_requests) do diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb index fc852bc3edd..dfbb37cf0dc 100644 --- a/app/services/ci/pipelines/add_job_service.rb +++ b/app/services/ci/pipelines/add_job_service.rb @@ -39,11 +39,13 @@ module Ci job.pipeline = pipeline job.project = pipeline.project job.ref = pipeline.ref + job.partition_id = pipeline.partition_id # update metadata since it might have been lazily initialised before this call # metadata is present on `Ci::Processable` if job.respond_to?(:metadata) && job.metadata job.metadata.project = pipeline.project + job.metadata.partition_id = pipeline.partition_id end end end diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb index c8bdbba5e65..cfafe66d10b 100644 --- a/app/services/ci/queue/pending_builds_strategy.rb +++ b/app/services/ci/queue/pending_builds_strategy.rb @@ -19,7 +19,11 @@ module Ci def builds_for_group_runner return new_builds.none if runner.namespace_ids.empty? - new_builds.where('ci_pending_builds.namespace_traversal_ids && ARRAY[?]::int[]', runner.namespace_ids) + new_builds_relation = new_builds.where('ci_pending_builds.namespace_traversal_ids && ARRAY[?]::int[]', runner.namespace_ids) + + return order(new_builds_relation) if ::Feature.enabled?(:order_builds_for_group_runner) + + new_builds_relation end def builds_matching_tag_ids(relation, ids) diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index b357855db12..0bd4bf8cc86 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -287,7 +287,7 @@ module Ci Gitlab::ErrorTracking.track_exception(ex, build_id: build.id, build_name: build.name, - build_stage: build.stage, + build_stage: build.stage_name, pipeline_id: build.pipeline_id, project_id: build.project_id ) diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb index dfd97498fc8..d7078200c14 100644 --- a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb +++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb @@ -9,8 +9,10 @@ module Ci free_resources = resource_group.resources.free.count - resource_group.upcoming_processables.take(free_resources).each do |processable| - processable.enqueue_waiting_for_resource + resource_group.upcoming_processables.take(free_resources).each do |upcoming| + Gitlab::OptimisticLocking.retry_lock(upcoming, name: 'enqueue_waiting_for_resource') do |processable| + processable.enqueue_waiting_for_resource + end end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/runners/set_runner_associated_projects_service.rb b/app/services/ci/runners/set_runner_associated_projects_service.rb new file mode 100644 index 00000000000..7930776749d --- /dev/null +++ b/app/services/ci/runners/set_runner_associated_projects_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Ci + module Runners + class SetRunnerAssociatedProjectsService + # @param [Ci::Runner] runner: the project runner to assign/unassign projects from + # @param [User] current_user: the user performing the operation + # @param [Array<Integer>] project_ids: the IDs of the associated projects to assign the runner to + def initialize(runner:, current_user:, project_ids:) + @runner = runner + @current_user = current_user + @project_ids = project_ids + end + + def execute + unless current_user&.can?(:assign_runner, runner) + return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden) + end + + return ServiceResponse.success if project_ids.blank? + + set_associated_projects + end + + private + + def set_associated_projects + new_project_ids = [runner.owner_project.id] + project_ids + + response = ServiceResponse.success + runner.transaction do + # rubocop:disable CodeReuse/ActiveRecord + current_project_ids = runner.projects.ids + # rubocop:enable CodeReuse/ActiveRecord + + unless associate_new_projects(new_project_ids, current_project_ids) + response = ServiceResponse.error(message: 'failed to assign projects to runner') + raise ActiveRecord::Rollback, response.errors + end + + unless disassociate_old_projects(new_project_ids, current_project_ids) + response = ServiceResponse.error(message: 'failed to destroy runner project') + raise ActiveRecord::Rollback, response.errors + end + end + + response + end + + def associate_new_projects(new_project_ids, current_project_ids) + missing_projects = Project.id_in(new_project_ids - current_project_ids) + missing_projects.all? { |project| runner.assign_to(project, current_user) } + end + + def disassociate_old_projects(new_project_ids, current_project_ids) + projects_to_be_deleted = current_project_ids - new_project_ids + return true if projects_to_be_deleted.empty? + + Ci::RunnerProject + .destroy_by(project_id: projects_to_be_deleted) + .all?(&:destroyed?) + end + + attr_reader :runner, :current_user, :project_ids + end + end +end + +Ci::Runners::SetRunnerAssociatedProjectsService.prepend_mod diff --git a/app/services/ci/runners/update_runner_service.rb b/app/services/ci/runners/update_runner_service.rb index 6cc080f81c2..bd01f52f396 100644 --- a/app/services/ci/runners/update_runner_service.rb +++ b/app/services/ci/runners/update_runner_service.rb @@ -9,11 +9,14 @@ module Ci @runner = runner end - def update(params) + def execute(params) params[:active] = !params.delete(:paused) if params.include?(:paused) - runner.update(params).tap do |updated| - runner.tick_runner_queue if updated + if runner.update(params) + runner.tick_runner_queue + ServiceResponse.success + else + ServiceResponse.error(message: runner.errors.full_messages) end end end diff --git a/app/services/ci/stuck_builds/drop_helpers.rb b/app/services/ci/stuck_builds/drop_helpers.rb index dca50963883..f56c9aaeb55 100644 --- a/app/services/ci/stuck_builds/drop_helpers.rb +++ b/app/services/ci/stuck_builds/drop_helpers.rb @@ -48,7 +48,7 @@ module Ci Gitlab::ErrorTracking.track_exception(ex, build_id: build.id, build_name: build.name, - build_stage: build.stage, + build_stage: build.stage_name, pipeline_id: build.pipeline_id, project_id: build.project_id ) diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index 2214a6a2729..5a8072b2a0d 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -80,8 +80,8 @@ module Ci end def generate_test_suite!(build) - # Returns an instance of Gitlab::Ci::Reports::TestSuite - build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report = build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) + test_report.get_suite(build.test_suite_name) end def ci_unit_test_attrs(batch) diff --git a/app/services/ci/unlock_artifacts_service.rb b/app/services/ci/unlock_artifacts_service.rb index 30da31ba8ec..1fee31da4fc 100644 --- a/app/services/ci/unlock_artifacts_service.rb +++ b/app/services/ci/unlock_artifacts_service.rb @@ -7,9 +7,12 @@ module Ci def execute(ci_ref, before_pipeline = nil) results = { unlocked_pipelines: 0, - unlocked_job_artifacts: 0 + unlocked_job_artifacts: 0, + unlocked_pipeline_artifacts: 0 } + unlock_pipeline_artifacts_enabled = ::Feature.enabled?(:ci_update_unlocked_pipeline_artifacts, ci_ref.project) + if ::Feature.enabled?(:ci_update_unlocked_job_artifacts, ci_ref.project) loop do unlocked_pipelines = [] @@ -18,6 +21,10 @@ module Ci ::Ci::Pipeline.transaction do unlocked_pipelines = unlock_pipelines(ci_ref, before_pipeline) unlocked_job_artifacts = unlock_job_artifacts(unlocked_pipelines) + + if unlock_pipeline_artifacts_enabled + results[:unlocked_pipeline_artifacts] += unlock_pipeline_artifacts(unlocked_pipelines) + end end break if unlocked_pipelines.empty? @@ -100,6 +107,14 @@ module Ci ) end + # rubocop:disable CodeReuse/ActiveRecord + def unlock_pipeline_artifacts(pipelines) + return 0 if pipelines.empty? + + ::Ci::PipelineArtifact.where(pipeline_id: pipelines.rows.flatten).update_all(locked: :unlocked) + end + # rubocop:enable CodeReuse/ActiveRecord + def unlock_pipelines(ci_ref, before_pipeline) ::Ci::Pipeline.connection.exec_query(unlock_pipelines_query(ci_ref, before_pipeline)) end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index fc18420f6e4..a498d39d34e 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -66,7 +66,7 @@ module Commits validate_on_branch! validate_branch_existence! - validate_new_branch_name! if different_branch? + validate_new_branch_name! if project.empty_repo? || different_branch? end def validate_permissions! diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb index 8c6c7b15d28..9fe82507edd 100644 --- a/app/services/concerns/alert_management/alert_processing.rb +++ b/app/services/concerns/alert_management/alert_processing.rb @@ -113,7 +113,7 @@ module AlertManagement end def resolving_alert? - incoming_payload.ends_at.present? + incoming_payload.resolved? end def notifying_alert? @@ -121,7 +121,7 @@ module AlertManagement end def alert_source - incoming_payload.monitoring_tool + incoming_payload.source end def logger diff --git a/app/services/concerns/ci/downstream_pipeline_helpers.rb b/app/services/concerns/ci/downstream_pipeline_helpers.rb index 39c0adb6e4e..26d7eb97151 100644 --- a/app/services/concerns/ci/downstream_pipeline_helpers.rb +++ b/app/services/concerns/ci/downstream_pipeline_helpers.rb @@ -5,7 +5,6 @@ module Ci def log_downstream_pipeline_creation(downstream_pipeline) return unless downstream_pipeline&.persisted? - hierarchy_size = downstream_pipeline.all_pipelines_in_hierarchy.count root_pipeline = downstream_pipeline.upstream_root ::Gitlab::AppLogger.info( @@ -14,7 +13,7 @@ module Ci root_pipeline_id: root_pipeline.id, downstream_pipeline_id: downstream_pipeline.id, downstream_pipeline_relationship: downstream_pipeline.parent_pipeline? ? :parent_child : :multi_project, - hierarchy_size: hierarchy_size, + hierarchy_size: downstream_pipeline.complete_hierarchy_count, root_pipeline_plan: root_pipeline.project.actual_plan_name, root_pipeline_namespace_path: root_pipeline.project.namespace.full_path, root_pipeline_project_path: root_pipeline.project.full_path diff --git a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb index 23053975313..427aebf397e 100644 --- a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb +++ b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb @@ -9,10 +9,6 @@ module Ci "not exist or you don't have permission to perform this action" def validate_edit!(source_project, target_project, current_user) - unless source_project.ci_job_token_scope_enabled? - raise ValidationError, "Job token scope is disabled for this project" - end - unless can?(current_user, :admin_project, source_project) raise ValidationError, "Insufficient permissions to modify the job token scope" end diff --git a/app/services/concerns/projects/container_repository/gitlab/timeoutable.rb b/app/services/concerns/projects/container_repository/gitlab/timeoutable.rb new file mode 100644 index 00000000000..095f5aa7cfa --- /dev/null +++ b/app/services/concerns/projects/container_repository/gitlab/timeoutable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + module Gitlab + module Timeoutable + extend ActiveSupport::Concern + + DISABLED_TIMEOUTS = [nil, 0].freeze + + TimeoutError = Class.new(StandardError) + + private + + def timeout?(start_time) + return false if service_timeout.in?(DISABLED_TIMEOUTS) + + (Time.zone.now - start_time) > service_timeout + end + + def service_timeout + ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout + end + end + end + end +end diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb index 34889e58127..1123b29f217 100644 --- a/app/services/container_expiration_policies/cleanup_service.rb +++ b/app/services/container_expiration_policies/cleanup_service.rb @@ -24,7 +24,7 @@ module ContainerExpirationPolicies begin service_result = Projects::ContainerRepository::CleanupTagsService - .new(repository, nil, policy_params.merge('container_expiration_policy' => true)) + .new(container_repository: repository, params: policy_params.merge('container_expiration_policy' => true)) .execute rescue StandardError repository.cleanup_unfinished! diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb index 3cacedc7d6e..90a31ae9370 100644 --- a/app/services/deployments/update_environment_service.rb +++ b/app/services/deployments/update_environment_service.rb @@ -61,6 +61,12 @@ module Deployments ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all }) end + def expanded_auto_stop_in + return unless auto_stop_in + + ExpandVariables.expand(auto_stop_in, -> { variables.sort_and_expand_all }) + end + def environment_url environment_options[:url] end @@ -69,6 +75,10 @@ module Deployments environment_options[:action] || 'start' end + def auto_stop_in + deployable&.environment_auto_stop_in + end + def renew_external_url if (url = expanded_environment_url) environment.external_url = url @@ -78,7 +88,9 @@ module Deployments def renew_auto_stop_in return unless deployable - environment.auto_stop_in = deployable.environment_auto_stop_in + if (value = expanded_auto_stop_in) + environment.auto_stop_in = value + end end def renew_deployment_tier diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb index 886077191ab..3bc30f62a81 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -143,7 +143,7 @@ module DesignManagement gitaly_actions = version.actions.map do |action| design = action.design # Map the raw Action#event enum value to a Gitaly "action" for the - # `Repository#multi_action` call. + # `Repository#commit_files` call. gitaly_action_name = @event_enum_map[action.event_before_type_cast] # `content` will be the LfsPointer file and not the design file, # and can be nil for deletions. @@ -157,7 +157,7 @@ module DesignManagement }.compact end - sha = target_repository.multi_action( + sha = target_repository.commit_files( git_user, branch_name: temporary_branch, message: commit_message(version), diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb index 9ed03a994c4..921c904d8de 100644 --- a/app/services/design_management/delete_designs_service.rb +++ b/app/services/design_management/delete_designs_service.rb @@ -16,7 +16,8 @@ module DesignManagement version = delete_designs! EventCreateService.new.destroy_designs(designs, current_user) - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user) + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user, + project: project) TodosDestroyer::DestroyedDesignsWorker.perform_async(designs.map(&:id)) success(version: version) diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb index ee6aa9286d3..267ed6bf29f 100644 --- a/app/services/design_management/runs_design_actions.rb +++ b/app/services/design_management/runs_design_actions.rb @@ -15,7 +15,7 @@ module DesignManagement def run_actions(actions, skip_system_notes: false) raise NoActions if actions.empty? - sha = repository.multi_action(current_user, + sha = repository.commit_files(current_user, branch_name: target_branch, message: commit_message, actions: actions.map(&:gitaly_action)) diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb index a1fce45434b..64537293e65 100644 --- a/app/services/design_management/save_designs_service.rb +++ b/app/services/design_management/save_designs_service.rb @@ -131,9 +131,11 @@ module DesignManagement def track_usage_metrics(action) if action == :update - ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_modified_action(author: current_user) + ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_modified_action(author: current_user, + project: project) else - ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_added_action(author: current_user) + ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_added_action(author: current_user, + project: project) end ::Gitlab::UsageDataCounters::DesignsCounter.count(action) diff --git a/app/services/environments/stop_service.rb b/app/services/environments/stop_service.rb index 75c878c9350..774e3ffe273 100644 --- a/app/services/environments/stop_service.rb +++ b/app/services/environments/stop_service.rb @@ -25,8 +25,19 @@ module Environments def execute_for_merge_request_pipeline(merge_request) return unless merge_request.actual_head_pipeline&.merge_request? - merge_request.environments_in_head_pipeline(deployment_status: :success).each do |environment| - execute(environment) + created_environments = merge_request.created_environments + + if created_environments.any? + created_environments.each { |env| execute(env) } + else + environments_in_head_pipeline = merge_request.environments_in_head_pipeline(deployment_status: :success) + environments_in_head_pipeline.each { |env| execute(env) } + + if environments_in_head_pipeline.any? + # If we don't see a message often, we'd be able to remove this path. (or likely in GitLab 16.0) + # See https://gitlab.com/gitlab-org/gitlab/-/issues/372965 + Gitlab::AppJsonLogger.info(message: 'Running legacy dynamic environment stop logic', project_id: project.id) + end end end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index 65af4dd5a28..dd09ecafb4f 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -38,7 +38,7 @@ module Files end def commit_actions!(actions) - repository.multi_action( + repository.commit_files( current_user, message: @commit_message, branch_name: @branch_name, diff --git a/app/services/google_cloud/create_cloudsql_instance_service.rb b/app/services/google_cloud/create_cloudsql_instance_service.rb index f7fca277c52..8d040c6c908 100644 --- a/app/services/google_cloud/create_cloudsql_instance_service.rb +++ b/app/services/google_cloud/create_cloudsql_instance_service.rb @@ -11,7 +11,7 @@ module GoogleCloud trigger_instance_setup_worker success rescue Google::Apis::Error => err - error(err.to_json) + error(err.message) end private diff --git a/app/services/google_cloud/enable_cloudsql_service.rb b/app/services/google_cloud/enable_cloudsql_service.rb index a466b2f3696..e4a411d0fab 100644 --- a/app/services/google_cloud/enable_cloudsql_service.rb +++ b/app/services/google_cloud/enable_cloudsql_service.rb @@ -12,6 +12,8 @@ module GoogleCloud end success({ gcp_project_ids: unique_gcp_project_ids }) + rescue Google::Apis::Error => err + error(err.message) end private diff --git a/app/services/google_cloud/fetch_google_ip_list_service.rb b/app/services/google_cloud/fetch_google_ip_list_service.rb new file mode 100644 index 00000000000..f7739971603 --- /dev/null +++ b/app/services/google_cloud/fetch_google_ip_list_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module GoogleCloud + class FetchGoogleIpListService + include BaseServiceUtility + + GOOGLE_IP_RANGES_URL = 'https://www.gstatic.com/ipranges/cloud.json' + RESPONSE_BODY_LIMIT = 1.megabyte + EXPECTED_CONTENT_TYPE = 'application/json' + + IpListNotRetrievedError = Class.new(StandardError) + + def execute + # Prevent too many workers from hitting the same HTTP endpoint + if ::Gitlab::ApplicationRateLimiter.throttled?(:fetch_google_ip_list, scope: nil) + return error("#{self.class} was rate limited") + end + + subnets = fetch_and_update_cache! + + Gitlab::AppJsonLogger.info(class: self.class.name, + message: 'Successfully retrieved Google IP list', + subnet_count: subnets.count) + + success({ subnets: subnets }) + rescue IpListNotRetrievedError => err + Gitlab::ErrorTracking.log_exception(err) + error('Google IP list not retrieved') + end + + private + + # Attempts to retrieve and parse the list of IPs from Google. Updates + # the internal cache so that the data is accessible. + # + # Returns an array of IPAddr objects consisting of subnets. + def fetch_and_update_cache! + parsed_response = fetch_google_ip_list + + parse_google_prefixes(parsed_response).tap do |subnets| + ::ObjectStorage::CDN::GoogleIpCache.update!(subnets) + end + end + + def fetch_google_ip_list + response = Gitlab::HTTP.get(GOOGLE_IP_RANGES_URL, follow_redirects: false, allow_local_requests: false) + + validate_response!(response) + + response.parsed_response + end + + def validate_response!(response) + raise IpListNotRetrievedError, "response was #{response.code}" unless response.code == 200 + raise IpListNotRetrievedError, "response was nil" unless response.body + + parsed_response = response.parsed_response + + unless response.content_type == EXPECTED_CONTENT_TYPE && parsed_response.is_a?(Hash) + raise IpListNotRetrievedError, "response was not JSON" + end + + if response.body&.bytesize.to_i > RESPONSE_BODY_LIMIT + raise IpListNotRetrievedError, "response was too large: #{response.body.bytesize}" + end + + prefixes = parsed_response['prefixes'] + + raise IpListNotRetrievedError, "JSON was type #{prefixes.class}, expected Array" unless prefixes.is_a?(Array) + raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if prefixes.empty? + + response.parsed_response + end + + def parse_google_prefixes(parsed_response) + ranges = parsed_response['prefixes'].map do |prefix| + ip_range = prefix['ipv4Prefix'] || prefix['ipv6Prefix'] + + next unless ip_range + + IPAddr.new(ip_range) + end.compact + + raise IpListNotRetrievedError, "#{GOOGLE_IP_RANGES_URL} did not return any IP ranges" if ranges.empty? + + ranges + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 35716f7742a..d508865ef32 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -39,7 +39,7 @@ module Groups if @group.save @group.add_owner(current_user) Integration.create_from_active_default_integrations(@group, :group_id) - OnboardingProgress.onboard(@group) + Onboarding::Progress.onboard(@group) end end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index ff5d5d2c4c1..53297d2412c 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -50,7 +50,7 @@ module Import end def project_name - @project_name ||= params[:new_name].presence || repo.name + @project_name ||= params[:new_name].presence || repo[:name] end def namespace_path @@ -66,13 +66,13 @@ module Import end def oversized? - repository_size_limit > 0 && repo.size > repository_size_limit + repository_size_limit > 0 && repo[:size] > repository_size_limit end def oversize_error_message _('"%{repository_name}" size (%{repository_size}) is larger than the limit of %{limit}.') % { - repository_name: repo.name, - repository_size: number_to_human_size(repo.size), + repository_name: repo[:name], + repository_size: number_to_human_size(repo[:size]), limit: number_to_human_size(repository_size_limit) } end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index acd6d45af7a..70ad97f8436 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -285,7 +285,7 @@ class IssuableBaseService < ::BaseProjectService if issuable.changed? || params.present? || widget_params.present? issuable.assign_attributes(allowed_update_params(params)) - if has_title_or_description_changed?(issuable) + if issuable.description_changed? issuable.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user) end @@ -398,10 +398,6 @@ class IssuableBaseService < ::BaseProjectService update_task(issuable) end - def has_title_or_description_changed?(issuable) - issuable.title_changed? || issuable.description_changed? - end - def change_additional_attributes(issuable) change_state(issuable) change_subscription(issuable) diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index aca98596a02..2e9775af8c2 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -41,7 +41,7 @@ module IssuableLinks set_link_type(link) if link.changed? && link.save - create_notes(referenced_issuable) + create_notes(link) end link @@ -124,9 +124,9 @@ module IssuableLinks :issue end - def create_notes(referenced_issuable) - SystemNoteService.relate_issuable(issuable, referenced_issuable, current_user) - SystemNoteService.relate_issuable(referenced_issuable, issuable, current_user) + def create_notes(issuable_link) + SystemNoteService.relate_issuable(issuable_link.source, issuable_link.target, current_user) + SystemNoteService.relate_issuable(issuable_link.target, issuable_link.source, current_user) end def linkable_issuables(objects) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 61a95e49228..d75e74f3b19 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -28,9 +28,6 @@ module Issues return if issue.relative_position.nil? return if NO_REBALANCING_NEEDED.cover?(issue.relative_position) - gates = [issue.project, issue.project.group].compact - return unless gates.any? { |gate| Feature.enabled?(:rebalance_issues, gate) } - Issues::RebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids) end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index d08e4d12a92..da888386e0a 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -24,7 +24,7 @@ module Issues return issue end - if perform_close(issue) + if issue.close(current_user) event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note @@ -40,7 +40,7 @@ module Issues if closed_via.is_a?(MergeRequest) store_first_mentioned_in_commit_at(issue, closed_via) - OnboardingProgressService.new(project.namespace).execute(action: :issue_auto_closed) + Onboarding::ProgressService.new(project.namespace).execute(action: :issue_auto_closed) end delete_milestone_closed_issue_counter_cache(issue.milestone) @@ -51,11 +51,6 @@ module Issues private - # Overridden on EE - def perform_close(issue) - issue.close(current_user) - end - def can_close?(issue, skip_authorization: false) skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue) end diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index 6209127bd86..46e4b865dc3 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -5,20 +5,20 @@ module Issues include Gitlab::Routing.url_helpers include GitlabRoutingHelper - def initialize(issuables_relation, project) - super + def initialize(issuables_relation, project, user = nil) + super(issuables_relation, project) @labels = @issuables.labels_hash.transform_values { |labels| labels.sort.join(',').presence } end - def email(user) - Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now + def email(mail_to_user) + Notify.issues_csv_email(mail_to_user, project, csv_data, csv_builder.status).deliver_now end private def associations_to_preload - %i(author assignees timelogs milestone project) + [:author, :assignees, :timelogs, :milestone, { project: { namespace: :route } }] end def header_to_value_hash diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb index 23bb409f3cd..b5c10430e83 100644 --- a/app/services/issues/relative_position_rebalancing_service.rb +++ b/app/services/issues/relative_position_rebalancing_service.rb @@ -16,8 +16,6 @@ module Issues end def execute - return unless Feature.enabled?(:rebalance_issues, root_namespace) - # Given can_start_rebalance? and track_new_running_rebalance are not atomic # it can happen that we end up with more than Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES running. # Considering the number of allowed Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES is small we should be ok, diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index e003ecacb3f..f4f81e9455a 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -5,7 +5,7 @@ module Issues def execute(issue, skip_authorization: false) return issue unless can_reopen?(issue, skip_authorization: skip_authorization) - if perform_reopen(issue) + if issue.reopen event_service.reopen_issue(issue, current_user) create_note(issue, 'reopened') notification_service.async.reopen_issue(issue, current_user) @@ -22,11 +22,6 @@ module Issues private - # Overriden on EE - def perform_reopen(issue) - issue.reopen - end - def can_reopen?(issue, skip_authorization: false) skip_authorization || can?(current_user, :reopen_issue, issue) end diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb index 67163cb8122..a79e5b00232 100644 --- a/app/services/labels/transfer_service.rb +++ b/app/services/labels/transfer_service.rb @@ -40,9 +40,9 @@ module Labels def labels_to_transfer Label .from_union([ - group_labels_applied_to_issues, - group_labels_applied_to_merge_requests - ]) + group_labels_applied_to_issues, + group_labels_applied_to_merge_requests + ]) .reorder(nil) .distinct end diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b4d1b80e5a3..8ef3e307519 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -7,6 +7,8 @@ module Members raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member) raise Gitlab::Access::AccessDeniedError if prevent_upgrade_to_owner?(member) || prevent_downgrade_from_owner?(member) + return success(member: member) if update_results_in_no_change?(member) + old_access_level = member.human_access old_expiry = member.expires_at @@ -26,6 +28,13 @@ module Members private + def update_results_in_no_change?(member) + return false if params[:expires_at]&.to_date != member.expires_at + return false if params[:access_level] != member.access_level + + true + end + def downgrading_to_guest? params[:access_level] == Gitlab::Access::GUEST end diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index 93a0d375b97..9d12eb80eb6 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -28,7 +28,7 @@ module MergeRequests merge_request.diffs(include_stats: false).write_cache merge_request.create_cross_references!(current_user) - OnboardingProgressService.new(merge_request.target_project.namespace).execute(action: :merge_request_created) + Onboarding::ProgressService.new(merge_request.target_project.namespace).execute(action: :merge_request_created) todo_service.new_merge_request(merge_request, current_user) merge_request.cache_merge_request_closes_issues!(current_user) diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index dcc4cf4bb1e..64ae33c9b15 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -17,19 +17,11 @@ module MergeRequests # utilizing the `Gitlab::EventStore`. # # Workers can subscribe to the `MergeRequests::ApprovedEvent`. - if Feature.enabled?(:async_after_approval, project) - Gitlab::EventStore.publish( - MergeRequests::ApprovedEvent.new( - data: { current_user_id: current_user.id, merge_request_id: merge_request.id } - ) + Gitlab::EventStore.publish( + MergeRequests::ApprovedEvent.new( + data: { current_user_id: current_user.id, merge_request_id: merge_request.id } ) - else - create_event(merge_request) - stream_audit_event(merge_request) - create_approval_note(merge_request) - mark_pending_todos_as_done(merge_request) - execute_approval_hooks(merge_request, current_user) - end + ) success end @@ -37,7 +29,7 @@ module MergeRequests private def can_be_approved?(merge_request) - current_user.can?(:approve_merge_request, merge_request) + merge_request.can_be_approved_by?(current_user) end def save_approval(approval) @@ -49,29 +41,6 @@ module MergeRequests def reset_approvals_cache(merge_request) merge_request.approvals.reset end - - def create_event(merge_request) - event_service.approve_mr(merge_request, current_user) - end - - def stream_audit_event(merge_request) - # Defined in EE - end - - def create_approval_note(merge_request) - SystemNoteService.approve_mr(merge_request, current_user) - end - - def mark_pending_todos_as_done(merge_request) - todo_service.resolve_todos_for_target(merge_request, current_user) - end - - def execute_approval_hooks(merge_request, current_user) - # Only one approval is required for a merge request to be approved - notification_service.async.approve_mr(merge_request, current_user) - - execute_hooks(merge_request, 'approved') - end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index bda8dc64ac0..6cefd9169f5 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -43,8 +43,6 @@ module MergeRequests end def handle_assignees_change(merge_request, old_assignees) - bulk_update_assignees_state(merge_request, merge_request.assignees - old_assignees) - MergeRequests::HandleAssigneesChangeService .new(project: project, current_user: current_user) .async_execute(merge_request, old_assignees) @@ -60,7 +58,6 @@ module MergeRequests new_reviewers = merge_request.reviewers - old_reviewers merge_request_activity_counter.track_users_review_requested(users: new_reviewers) merge_request_activity_counter.track_reviewers_changed_action(user: current_user) - bulk_update_reviewers_state(merge_request, new_reviewers) end def cleanup_environments(merge_request) @@ -247,46 +244,6 @@ module MergeRequests Milestones::MergeRequestsCountService.new(milestone).delete_cache end - - def bulk_update_assignees_state(merge_request, new_assignees) - return unless current_user.mr_attention_requests_enabled? - return if new_assignees.empty? - - assignees_map = merge_request.merge_request_assignees_with(new_assignees).to_h do |assignee| - state = if assignee.user_id == current_user&.id - :unreviewed - else - merge_request.find_reviewer(assignee.assignee)&.state || :attention_requested - end - - [ - assignee, - { state: MergeRequestAssignee.states[state], updated_state_by_user_id: current_user.id } - ] - end - - ::Gitlab::Database::BulkUpdate.execute(%i[state updated_state_by_user_id], assignees_map) - end - - def bulk_update_reviewers_state(merge_request, new_reviewers) - return unless current_user.mr_attention_requests_enabled? - return if new_reviewers.empty? - - reviewers_map = merge_request.merge_request_reviewers_with(new_reviewers).to_h do |reviewer| - state = if reviewer.user_id == current_user&.id - :unreviewed - else - merge_request.find_assignee(reviewer.reviewer)&.state || :attention_requested - end - - [ - reviewer, - { state: MergeRequestReviewer.states[state], updated_state_by_user_id: current_user.id } - ] - end - - ::Gitlab::Database::BulkUpdate.execute(%i[state updated_state_by_user_id], reviewers_map) - end end end diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index c5640047899..6e1d1b6ad23 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -8,26 +8,22 @@ module MergeRequests # Executed when you do fast-forward merge via GitLab UI # class FfMergeService < MergeRequests::MergeService - private + extend ::Gitlab::Utils::Override - def commit - ff_merge = repository.ff_merge(current_user, - source, - merge_request.target_branch, - merge_request: merge_request) + private - if merge_request.squash_on_merge? - merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha) - end + override :execute_git_merge + def execute_git_merge + repository.ff_merge(current_user, + source, + merge_request.target_branch, + merge_request: merge_request) + end - ff_merge - rescue Gitlab::Git::PreReceiveError => e - Gitlab::ErrorTracking.track_exception(e, pre_receive_message: e.raw_message, merge_request_id: merge_request&.id) - raise MergeError, e.message - rescue StandardError => e - raise MergeError, "Something went wrong during merge: #{e.message}" - ensure - merge_request.update_and_mark_in_progress_merge_commit_sha(nil) + override :merge_success_data + def merge_success_data(commit_id) + # There is no merge commit to update, so this is just blank. + {} end end end diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 87cd6544406..51be4690af4 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -21,7 +21,7 @@ module MergeRequests merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees) merge_request_activity_counter.track_assignees_changed_action(user: current_user) - execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks] + execute_assignees_hooks(merge_request, old_assignees) if options['execute_hooks'] end private diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index f51923b7035..6d31a29f5a7 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -92,15 +92,26 @@ module MergeRequests raise_error(GENERIC_ERROR_MESSAGE) end - merge_request.update!(merge_commit_sha: commit_id) + update_merge_sha_metadata(commit_id) + + commit_id ensure merge_request.update_and_mark_in_progress_merge_commit_sha(nil) end + def update_merge_sha_metadata(commit_id) + data_to_update = merge_success_data(commit_id) + data_to_update[:squash_commit_sha] = source if merge_request.squash_on_merge? + + merge_request.update!(**data_to_update) if data_to_update.present? + end + + def merge_success_data(commit_id) + { merge_commit_sha: commit_id } + end + def try_merge - repository.merge(current_user, source, merge_request, commit_message).tap do - merge_request.update_column(:squash_commit_sha, source) if merge_request.squash_on_merge? - end + execute_git_merge rescue Gitlab::Git::PreReceiveError => e raise MergeError, "Something went wrong during merge pre-receive hook. #{e.message}".strip @@ -109,6 +120,10 @@ module MergeRequests raise_error(GENERIC_ERROR_MESSAGE) end + def execute_git_merge + repository.merge(current_user, source, merge_request, commit_message) + end + def after_merge log_info("Post merge started on JID #{merge_jid} with state #{state}") MergeRequests::PostMergeService.new(project: project, current_user: current_user).execute(merge_request) diff --git a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb new file mode 100644 index 00000000000..d25234183fd --- /dev/null +++ b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module MergeRequests + module Mergeability + class DetailedMergeStatusService + include ::Gitlab::Utils::StrongMemoize + + def initialize(merge_request:) + @merge_request = merge_request + end + + def execute + return :checking if checking? + return :unchecked if unchecked? + + if check_results.success? + + # If everything else is mergeable, but CI is not, the frontend expects two potential states to be returned + # See discussion: gitlab.com/gitlab-org/gitlab/-/merge_requests/96778#note_1093063523 + if check_ci_results.success? + :mergeable + else + ci_check_failure_reason + end + else + check_results.failure_reason + end + end + + private + + attr_reader :merge_request, :checks, :ci_check + + def checking? + merge_request.cannot_be_merged_rechecking? || merge_request.preparing? || merge_request.checking? + end + + def unchecked? + merge_request.unchecked? + end + + def check_results + strong_memoize(:check_results) do + merge_request.execute_merge_checks(params: { skip_ci_check: true }) + end + end + + def check_ci_results + strong_memoize(:check_ci_results) do + ::MergeRequests::Mergeability::CheckCiStatusService.new(merge_request: merge_request, params: {}).execute + end + end + + def ci_check_failure_reason + if merge_request.actual_head_pipeline&.running? + :ci_still_running + else + check_ci_results.payload.fetch(:reason) + end + end + end + end +end diff --git a/app/services/merge_requests/mergeability/logger.rb b/app/services/merge_requests/mergeability/logger.rb new file mode 100644 index 00000000000..8b45d231e03 --- /dev/null +++ b/app/services/merge_requests/mergeability/logger.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module MergeRequests + module Mergeability + class Logger + include Gitlab::Utils::StrongMemoize + + def initialize(merge_request:, destination: Gitlab::AppJsonLogger) + @destination = destination + @merge_request = merge_request + end + + def commit + return unless enabled? + + commit_logs + end + + def instrument(mergeability_name:) + raise ArgumentError, 'block not given' unless block_given? + + return yield unless enabled? + + op_start_db_counters = current_db_counter_payload + op_started_at = current_monotonic_time + + result = yield + + observe("mergeability.#{mergeability_name}.duration_s", current_monotonic_time - op_started_at) + + observe_sql_counters(mergeability_name, op_start_db_counters, current_db_counter_payload) + + result + end + + private + + attr_reader :destination, :merge_request + + def observe(name, value) + return unless enabled? + + observations[name.to_s].push(value) + end + + def commit_logs + attributes = Gitlab::ApplicationContext.current.merge({ + mergeability_project_id: merge_request.project.id + }) + + attributes[:mergeability_merge_request_id] = merge_request.id + attributes.merge!(observations_hash) + attributes.compact! + attributes.stringify_keys! + + destination.info(attributes) + end + + def observations_hash + transformed = observations.transform_values do |values| + next if values.empty? + + { + 'values' => values + } + end.compact + + transformed.each_with_object({}) do |key, hash| + key[1].each { |k, v| hash["#{key[0]}.#{k}"] = v } + end + end + + def observations + strong_memoize(:observations) do + Hash.new { |hash, key| hash[key] = [] } + end + end + + def observe_sql_counters(name, start_db_counters, end_db_counters) + end_db_counters.each do |key, value| + result = value - start_db_counters.fetch(key, 0) + next if result == 0 + + observe("mergeability.#{name}.#{key}", result) + end + end + + def current_db_counter_payload + ::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_payload + end + + def enabled? + strong_memoize(:enabled) do + ::Feature.enabled?(:mergeability_checks_logger, merge_request.project) + end + end + + def current_monotonic_time + ::Gitlab::Metrics::System.monotonic_time + end + end + end +end diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb index 68f842b3322..7f205c8dd6c 100644 --- a/app/services/merge_requests/mergeability/run_checks_service.rb +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -15,12 +15,17 @@ module MergeRequests next if check.skip? - check_result = run_check(check) + check_result = logger.instrument(mergeability_name: check_class.to_s.demodulize.underscore) do + run_check(check) + end + result_hash << check_result break result_hash if check_result.failed? end + logger.commit + self end @@ -57,6 +62,12 @@ module MergeRequests Gitlab::MergeRequests::Mergeability::ResultsStore.new(merge_request: merge_request) end end + + def logger + strong_memoize(:logger) do + MergeRequests::Mergeability::Logger.new(merge_request: merge_request) + end + end end end end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 5205d34baae..533d0052fb8 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -234,6 +234,7 @@ module MergeRequests end # Add comment about pushing new commits to merge requests and send nofitication emails + # def notify_about_push(merge_request) return unless @commits.present? diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index a6b0235c525..a13db52e34b 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -20,8 +20,6 @@ module MergeRequests attrs = update_attrs.merge(assignee_ids: new_ids) merge_request.update!(**attrs) - bulk_update_assignees_state(merge_request, merge_request.assignees - old_assignees) - # Defer the more expensive operations (handle_assignee_changes) to the background MergeRequests::HandleAssigneesChangeService .new(project: project, current_user: current_user) diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 0902b5195a1..6d518edc88f 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -179,18 +179,16 @@ module MergeRequests old_title_draft = MergeRequest.draft?(old_title) new_title_draft = MergeRequest.draft?(new_title) + # notify the draft status changed. Added/removed message is handled in the + # email template itself, see `change_in_merge_request_draft_status_email` template. + notify_draft_status_changed(merge_request) if old_title_draft || new_title_draft + if !old_title_draft && new_title_draft # Marked as Draft - # - merge_request_activity_counter - .track_marked_as_draft_action(user: current_user) + merge_request_activity_counter.track_marked_as_draft_action(user: current_user) elsif old_title_draft && !new_title_draft # Unmarked as Draft - # - notify_draft_status_changed(merge_request) - - merge_request_activity_counter - .track_unmarked_as_draft_action(user: current_user) + merge_request_activity_counter.track_unmarked_as_draft_action(user: current_user) end end diff --git a/app/services/milestones/transfer_service.rb b/app/services/milestones/transfer_service.rb index b9bd259ca8b..bbf6920f83b 100644 --- a/app/services/milestones/transfer_service.rb +++ b/app/services/milestones/transfer_service.rb @@ -35,10 +35,7 @@ module Milestones # rubocop: disable CodeReuse/ActiveRecord def milestones_to_transfer - Milestone.from_union([ - group_milestones_applied_to_issues, - group_milestones_applied_to_merge_requests - ]) + Milestone.from_union([group_milestones_applied_to_issues, group_milestones_applied_to_merge_requests]) .reorder(nil) .distinct end diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index c139b2e11dd..1ce7e4cae16 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -89,7 +89,7 @@ module Namespaces end def groups_for_track - onboarding_progress_scope = OnboardingProgress + onboarding_progress_scope = Onboarding::Progress .completed_actions_with_latest_in_range(completed_actions, range) .incomplete_actions(incomplete_actions) diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb index 0a7f25f1af3..3fabec29c0d 100644 --- a/app/services/notification_recipients/builder/base.rb +++ b/app/services/notification_recipients/builder/base.rb @@ -183,58 +183,6 @@ module NotificationRecipients add_recipients(target.subscribers(project), :subscription, NotificationReason::SUBSCRIBED) end - # rubocop: disable CodeReuse/ActiveRecord - def user_ids_notifiable_on(resource, notification_level = nil) - return [] unless resource - - scope = resource.notification_settings - - if notification_level - scope = scope.where(level: NotificationSetting.levels[notification_level]) - end - - scope.pluck(:user_id) - end - # rubocop: enable CodeReuse/ActiveRecord - - # Build a list of user_ids based on project notification settings - def select_project_members_ids(global_setting, user_ids_global_level_watch) - user_ids = user_ids_notifiable_on(project, :watch) - - # If project setting is global, add to watch list if global setting is watch - user_ids + (global_setting & user_ids_global_level_watch) - end - - # Build a list of user_ids based on group notification settings - def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch) - uids = user_ids_notifiable_on(group, :watch) - - # Group setting is global, add to user_ids list if global setting is watch - uids + (global_setting & user_ids_global_level_watch) - project_members - end - - # rubocop: disable CodeReuse/ActiveRecord - def user_ids_with_global_level_watch(ids) - settings_with_global_level_of(:watch, ids).pluck(:user_id) - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def user_ids_with_global_level_custom(ids, action) - settings_with_global_level_of(:custom, ids).pluck(:user_id) - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def settings_with_global_level_of(level, ids) - NotificationSetting.where( - user_id: ids, - source_type: nil, - level: NotificationSetting.levels[level] - ) - end - # rubocop: enable CodeReuse/ActiveRecord - def add_labels_subscribers(labels: nil) return unless target.respond_to? :labels diff --git a/app/services/onboarding/progress_service.rb b/app/services/onboarding/progress_service.rb new file mode 100644 index 00000000000..66f7f2bc33d --- /dev/null +++ b/app/services/onboarding/progress_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Onboarding + class ProgressService + class Async + attr_reader :namespace_id + + def initialize(namespace_id) + @namespace_id = namespace_id + end + + def execute(action:) + return unless Onboarding::Progress.not_completed?(namespace_id, action) + + Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action) + end + end + + def self.async(namespace_id) + Async.new(namespace_id) + end + + def initialize(namespace) + @namespace = namespace&.root_ancestor + end + + def execute(action:) + return unless @namespace + + Onboarding::Progress.register(@namespace, action) + end + end +end diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb deleted file mode 100644 index 6d44c0a61ea..00000000000 --- a/app/services/onboarding_progress_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class OnboardingProgressService - class Async - attr_reader :namespace_id - - def initialize(namespace_id) - @namespace_id = namespace_id - end - - def execute(action:) - return unless OnboardingProgress.not_completed?(namespace_id, action) - - Namespaces::OnboardingProgressWorker.perform_async(namespace_id, action) - end - end - - def self.async(namespace_id) - Async.new(namespace_id) - end - - def initialize(namespace) - @namespace = namespace&.root_ancestor - end - - def execute(action:) - return unless @namespace - - OnboardingProgress.register(@namespace, action) - end -end diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb index 31ee9bea084..df22a895c00 100644 --- a/app/services/packages/conan/search_service.rb +++ b/app/services/packages/conan/search_service.rb @@ -44,7 +44,7 @@ module Packages name, version, username, _ = query.split(%r{[@/]}) full_path = Packages::Conan::Metadatum.full_path_from(package_username: username) project = Project.find_by_full_path(full_path) - return unless Ability.allowed?(current_user, :read_package, project) + return unless Ability.allowed?(current_user, :read_package, project&.packages_policy_subject) result = project.packages.with_name(name).with_version(version).order_created.last [result&.conan_recipe].compact diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index 7db27f9234d..9b313202400 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -220,6 +220,7 @@ module Packages valid_until_field, rfc822_field('NotAutomatic', !@distribution.automatic, !@distribution.automatic), rfc822_field('ButAutomaticUpgrades', @distribution.automatic_upgrades, !@distribution.automatic && @distribution.automatic_upgrades), + rfc822_field('Acquire-By-Hash', 'yes'), rfc822_field('Architectures', @distribution.architectures.map { |architecture| architecture.name }.sort.join(' ')), rfc822_field('Components', @distribution.components.map { |component| component.name }.sort.join(' ')), rfc822_field('Description', @distribution.description) diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb index b6e81012656..a29cbd3f65f 100644 --- a/app/services/packages/debian/process_changes_service.rb +++ b/app/services/packages/debian/process_changes_service.rb @@ -42,22 +42,30 @@ module Packages def update_files_metadata files.each do |filename, entry| - entry.package_file.package = package - file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute + ::Packages::UpdatePackageFileService.new(entry.package_file, package_id: package.id) + .execute + + # Force reload from database, as package has changed + entry.package_file.reload_package + entry.package_file.debian_file_metadatum.update!( file_type: file_metadata[:file_type], component: files[filename].component, architecture: file_metadata[:architecture], fields: file_metadata[:fields] ) - entry.package_file.save! end end def update_changes_metadata - package_file.update!(package: package) + ::Packages::UpdatePackageFileService.new(package_file, package_id: package.id) + .execute + + # Force reload from database, as package has changed + package_file.reload_package + package_file.debian_file_metadatum.update!( file_type: metadata[:file_type], fields: metadata[:fields] diff --git a/app/services/packages/rpm/repository_metadata/base_builder.rb b/app/services/packages/rpm/repository_metadata/base_builder.rb new file mode 100644 index 00000000000..9d76336d764 --- /dev/null +++ b/app/services/packages/rpm/repository_metadata/base_builder.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +module Packages + module Rpm + module RepositoryMetadata + class BaseBuilder + def execute + build_empty_structure + end + + private + + def build_empty_structure + Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml.public_send(self.class::ROOT_TAG, self.class::ROOT_ATTRIBUTES) # rubocop:disable GitlabSecurity/PublicSend + end.to_xml + end + end + end + end +end diff --git a/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb b/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb new file mode 100644 index 00000000000..01fb36f4b91 --- /dev/null +++ b/app/services/packages/rpm/repository_metadata/build_filelist_xml.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module Packages + module Rpm + module RepositoryMetadata + class BuildFilelistXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder + ROOT_TAG = 'filelists' + ROOT_ATTRIBUTES = { + xmlns: 'http://linux.duke.edu/metadata/filelists', + packages: '0' + }.freeze + end + end + end +end diff --git a/app/services/packages/rpm/repository_metadata/build_other_xml.rb b/app/services/packages/rpm/repository_metadata/build_other_xml.rb new file mode 100644 index 00000000000..4bf61c901a3 --- /dev/null +++ b/app/services/packages/rpm/repository_metadata/build_other_xml.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module Packages + module Rpm + module RepositoryMetadata + class BuildOtherXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder + ROOT_TAG = 'otherdata' + ROOT_ATTRIBUTES = { + xmlns: 'http://linux.duke.edu/metadata/other', + packages: '0' + }.freeze + end + end + end +end diff --git a/app/services/packages/rpm/repository_metadata/build_primary_xml.rb b/app/services/packages/rpm/repository_metadata/build_primary_xml.rb new file mode 100644 index 00000000000..affb41677c2 --- /dev/null +++ b/app/services/packages/rpm/repository_metadata/build_primary_xml.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module Packages + module Rpm + module RepositoryMetadata + class BuildPrimaryXml < ::Packages::Rpm::RepositoryMetadata::BaseBuilder + ROOT_TAG = 'metadata' + ROOT_ATTRIBUTES = { + xmlns: 'http://linux.duke.edu/metadata/common', + 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm', + packages: '0' + }.freeze + end + end + end +end diff --git a/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb b/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb new file mode 100644 index 00000000000..c6cfd77815d --- /dev/null +++ b/app/services/packages/rpm/repository_metadata/build_repomd_xml.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +module Packages + module Rpm + module RepositoryMetadata + class BuildRepomdXml + attr_reader :data + + ROOT_ATTRIBUTES = { + xmlns: 'http://linux.duke.edu/metadata/repo', + 'xmlns:rpm': 'http://linux.duke.edu/metadata/rpm' + }.freeze + + # Expected `data` structure + # + # data = { + # filelists: { + # checksum: { type: "sha256", value: "123" }, + # location: { href: "repodata/123-filelists.xml.gz" }, + # ... + # }, + # ... + # } + def initialize(data) + @data = data + end + + def execute + build_repomd + end + + private + + def build_repomd + Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml.repomd(ROOT_ATTRIBUTES) do + xml.revision Time.now.to_i + build_data_info(xml) + end + end.to_xml + end + + def build_data_info(xml) + data.each do |filename, info| + xml.data(type: filename) do + build_file_info(info, xml) + end + end + end + + def build_file_info(info, xml) + info.each do |key, attributes| + value = attributes.delete(:value) + xml.public_send(key, value, attributes) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + end + end +end diff --git a/app/services/packages/rubygems/dependency_resolver_service.rb b/app/services/packages/rubygems/dependency_resolver_service.rb index c44b26e2b92..839a7683632 100644 --- a/app/services/packages/rubygems/dependency_resolver_service.rb +++ b/app/services/packages/rubygems/dependency_resolver_service.rb @@ -8,7 +8,10 @@ module Packages DEFAULT_PLATFORM = 'ruby' def execute - return ServiceResponse.error(message: "forbidden", http_status: :forbidden) unless Ability.allowed?(current_user, :read_package, project) + unless Ability.allowed?(current_user, :read_package, project&.packages_policy_subject) + return ServiceResponse.error(message: "forbidden", http_status: :forbidden) + end + return ServiceResponse.error(message: "#{gem_name} not found", http_status: :not_found) if packages.empty? payload = packages.map do |package| diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 15c978e6763..c376b4036f8 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -101,7 +101,7 @@ class PostReceiveService def record_onboarding_progress return unless project - OnboardingProgressService.new(project.namespace).execute(action: :git_write) + Onboarding::ProgressService.new(project.namespace).execute(action: :git_write) end end diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index c21a61bcb52..9403c7bcfed 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -2,14 +2,13 @@ module Projects module Alerting - class NotifyService + class NotifyService < ::BaseProjectService extend ::Gitlab::Utils::Override include ::AlertManagement::AlertProcessing include ::AlertManagement::Responses - def initialize(project, payload) - @project = project - @payload = payload + def initialize(project, params) + super(project: project, params: params.to_h) end def execute(token, integration = nil) @@ -29,15 +28,11 @@ module Projects private - attr_reader :project, :payload, :integration + attr_reader :integration + alias_method :payload, :params def valid_payload_size? - Gitlab::Utils::DeepSize.new(payload.to_h).valid? - end - - override :alert_source - def alert_source - super || integration&.name || 'Generic Alert Endpoint' + Gitlab::Utils::DeepSize.new(params).valid? end def active_integration? diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb index b324ea27360..57b913b04e6 100644 --- a/app/services/projects/blame_service.rb +++ b/app/services/projects/blame_service.rb @@ -10,6 +10,7 @@ module Projects @blob = blob @commit = commit @page = extract_page(params) + @pagination_enabled = pagination_state(params) end attr_reader :page @@ -19,7 +20,7 @@ module Projects end def pagination - return unless pagination_enabled? + return unless pagination_enabled Kaminari.paginate_array([], total_count: blob_lines_count, limit: per_page) .tap { |pagination| pagination.max_paginates_per(per_page) } @@ -28,10 +29,10 @@ module Projects private - attr_reader :blob, :commit + attr_reader :blob, :commit, :pagination_enabled def blame_range - return unless pagination_enabled? + return unless pagination_enabled first_line = (page - 1) * per_page + 1 last_line = (first_line + per_page).to_i - 1 @@ -51,6 +52,12 @@ module Projects PER_PAGE end + def pagination_state(params) + return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false) + + Feature.enabled?(:blame_page_pagination, commit.project) + end + def overlimit?(page) page * per_page >= blob_lines_count + per_page end @@ -58,9 +65,5 @@ module Projects def blob_lines_count @blob_lines_count ||= blob.data.lines.count end - - def pagination_enabled? - Feature.enabled?(:blame_page_pagination, commit.project) - end end end diff --git a/app/services/projects/container_repository/base_container_repository_service.rb b/app/services/projects/container_repository/base_container_repository_service.rb new file mode 100644 index 00000000000..d7539737e78 --- /dev/null +++ b/app/services/projects/container_repository/base_container_repository_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class BaseContainerRepositoryService < ::BaseContainerService + include ::Gitlab::Utils::StrongMemoize + + alias_method :container_repository, :container + + def initialize(container_repository:, current_user: nil, params: {}) + super(container: container_repository, current_user: current_user, params: params) + end + + delegate :project, to: :container_repository + end + end +end diff --git a/app/services/projects/container_repository/cleanup_tags_base_service.rb b/app/services/projects/container_repository/cleanup_tags_base_service.rb new file mode 100644 index 00000000000..8ea4ae4830a --- /dev/null +++ b/app/services/projects/container_repository/cleanup_tags_base_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class CleanupTagsBaseService < BaseContainerRepositoryService + private + + def filter_out_latest!(tags) + tags.reject!(&:latest?) + end + + def filter_by_name!(tags) + regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_delete || name_regex}\\z") + regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_keep}\\z") + + tags.select! do |tag| + # regex_retain will override any overlapping matches by regex_delete + regex_delete.match?(tag.name) && !regex_retain.match?(tag.name) + end + end + + # Should return [tags_to_delete, tags_to_keep] + def partition_by_keep_n(tags) + return [tags, []] unless keep_n + + tags = order_by_date_desc(tags) + + tags.partition.with_index { |_, index| index >= keep_n_as_integer } + end + + # Should return [tags_to_delete, tags_to_keep] + def partition_by_older_than(tags) + return [tags, []] unless older_than + + older_than_timestamp = older_than_in_seconds.ago + + tags.partition do |tag| + timestamp = pushed_at(tag) + + timestamp && timestamp < older_than_timestamp + end + end + + def order_by_date_desc(tags) + now = DateTime.current + tags.sort_by! { |tag| pushed_at(tag) || now } + .reverse! + end + + def delete_tags(tags) + return success(deleted: []) unless tags.any? + + service = Projects::ContainerRepository::DeleteTagsService.new( + project, + current_user, + tags: tags.map(&:name), + container_expiration_policy: container_expiration_policy + ) + + service.execute(container_repository) + end + + def can_destroy? + return true if container_expiration_policy + + can?(current_user, :destroy_container_image, project) + end + + def valid_regex? + %w[name_regex_delete name_regex name_regex_keep].each do |param_name| + regex = params[param_name] + ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank? + end + true + rescue RegexpError => e + ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id) + false + end + + def older_than + params['older_than'] + end + + def name_regex_delete + params['name_regex_delete'] + end + + def name_regex + params['name_regex'] + end + + def name_regex_keep + params['name_regex_keep'] + end + + def container_expiration_policy + params['container_expiration_policy'] + end + + def keep_n + params['keep_n'] + end + + def project + container_repository.project + end + + def keep_n_as_integer + keep_n.to_i + end + + def older_than_in_seconds + strong_memoize(:older_than_in_seconds) do + ChronicDuration.parse(older_than).seconds + end + end + end + end +end diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index 0a8e8e72766..285c3e252ef 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -2,39 +2,33 @@ module Projects module ContainerRepository - class CleanupTagsService - include BaseServiceUtility - include ::Gitlab::Utils::StrongMemoize + class CleanupTagsService < CleanupTagsBaseService + def initialize(container_repository:, current_user: nil, params: {}) + super - def initialize(container_repository, user = nil, params = {}) - @container_repository = container_repository - @current_user = user @params = params.dup - - @project = container_repository.project - @tags = container_repository.tags - tags_size = @tags.size - @counts = { - original_size: tags_size, - cached_tags_count: 0 - } + @counts = { cached_tags_count: 0 } end def execute return error('access denied') unless can_destroy? return error('invalid regex') unless valid_regex? - filter_out_latest - filter_by_name + tags = container_repository.tags + @counts[:original_size] = tags.size + + filter_out_latest!(tags) + filter_by_name!(tags) + + tags = truncate(tags) + populate_from_cache(tags) - truncate - populate_from_cache + tags = filter_keep_n(tags) + tags = filter_by_older_than(tags) - filter_keep_n - filter_by_older_than + @counts[:before_delete_size] = tags.size - delete_tags.merge(@counts).tap do |result| - result[:before_delete_size] = @tags.size + delete_tags(tags).merge(@counts).tap do |result| result[:deleted_size] = result[:deleted]&.size result[:status] = :error if @counts[:before_truncate_size] != @counts[:after_truncate_size] @@ -43,94 +37,45 @@ module Projects private - def delete_tags - return success(deleted: []) unless @tags.any? - - service = Projects::ContainerRepository::DeleteTagsService.new( - @project, - @current_user, - tags: @tags.map(&:name), - container_expiration_policy: container_expiration_policy - ) - - service.execute(@container_repository) - end - - def filter_out_latest - @tags.reject!(&:latest?) - end - - def order_by_date - now = DateTime.current - @tags.sort_by! { |tag| tag.created_at || now } - .reverse! - end + def filter_keep_n(tags) + tags, tags_to_keep = partition_by_keep_n(tags) - def filter_by_name - regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_delete || name_regex}\\z") - regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{name_regex_keep}\\z") - - @tags.select! do |tag| - # regex_retain will override any overlapping matches by regex_delete - regex_delete.match?(tag.name) && !regex_retain.match?(tag.name) - end - end - - def filter_keep_n - return unless keep_n + cache_tags(tags_to_keep) - order_by_date - cache_tags(@tags.first(keep_n_as_integer)) - @tags = @tags.drop(keep_n_as_integer) + tags end - def filter_by_older_than - return unless older_than - - older_than_timestamp = older_than_in_seconds.ago - - @tags, tags_to_keep = @tags.partition do |tag| - tag.created_at && tag.created_at < older_than_timestamp - end + def filter_by_older_than(tags) + tags, tags_to_keep = partition_by_older_than(tags) cache_tags(tags_to_keep) - end - def can_destroy? - return true if container_expiration_policy - - can?(@current_user, :destroy_container_image, @project) + tags end - def valid_regex? - %w(name_regex_delete name_regex name_regex_keep).each do |param_name| - regex = @params[param_name] - ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank? - end - true - rescue RegexpError => e - ::Gitlab::ErrorTracking.log_exception(e, project_id: @project.id) - false + def pushed_at(tag) + tag.created_at end - def truncate - @counts[:before_truncate_size] = @tags.size - @counts[:after_truncate_size] = @tags.size + def truncate(tags) + @counts[:before_truncate_size] = tags.size + @counts[:after_truncate_size] = tags.size - return if max_list_size == 0 + return tags if max_list_size == 0 # truncate the list to make sure that after the #filter_keep_n # execution, the resulting list will be max_list_size truncated_size = max_list_size + keep_n_as_integer - return if @tags.size <= truncated_size + return tags if tags.size <= truncated_size - @tags = @tags.sample(truncated_size) - @counts[:after_truncate_size] = @tags.size + tags = tags.sample(truncated_size) + @counts[:after_truncate_size] = tags.size + tags end - def populate_from_cache - @counts[:cached_tags_count] = cache.populate(@tags) if caching_enabled? + def populate_from_cache(tags) + @counts[:cached_tags_count] = cache.populate(tags) if caching_enabled? end def cache_tags(tags) @@ -139,7 +84,7 @@ module Projects def cache strong_memoize(:cache) do - ::Gitlab::ContainerRepository::Tags::Cache.new(@container_repository) + ::Gitlab::ContainerRepository::Tags::Cache.new(container_repository) end end @@ -153,40 +98,6 @@ module Projects def max_list_size ::Gitlab::CurrentSettings.current_application_settings.container_registry_cleanup_tags_service_max_list_size.to_i end - - def keep_n - @params['keep_n'] - end - - def keep_n_as_integer - keep_n.to_i - end - - def older_than_in_seconds - strong_memoize(:older_than_in_seconds) do - ChronicDuration.parse(older_than).seconds - end - end - - def older_than - @params['older_than'] - end - - def name_regex_delete - @params['name_regex_delete'] - end - - def name_regex - @params['name_regex'] - end - - def name_regex_keep - @params['name_regex_keep'] - end - - def container_expiration_policy - @params['container_expiration_policy'] - end end end end diff --git a/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb new file mode 100644 index 00000000000..81bb94c867a --- /dev/null +++ b/app/services/projects/container_repository/gitlab/cleanup_tags_service.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + module Gitlab + class CleanupTagsService < CleanupTagsBaseService + include ::Projects::ContainerRepository::Gitlab::Timeoutable + + TAGS_PAGE_SIZE = 1000 + + def initialize(container_repository:, current_user: nil, params: {}) + super + @params = params.dup + end + + def execute + return error('access denied') unless can_destroy? + return error('invalid regex') unless valid_regex? + + with_timeout do |start_time, result| + container_repository.each_tags_page(page_size: TAGS_PAGE_SIZE) do |tags| + execute_for_tags(tags, result) + + raise TimeoutError if timeout?(start_time) + end + end + end + + private + + def execute_for_tags(tags, overall_result) + original_size = tags.size + + filter_out_latest!(tags) + filter_by_name!(tags) + + tags = filter_by_keep_n(tags) + tags = filter_by_older_than(tags) + + overall_result[:before_delete_size] += tags.size + overall_result[:original_size] += original_size + + result = delete_tags(tags) + + overall_result[:deleted_size] += result[:deleted]&.size + overall_result[:deleted] += result[:deleted] + overall_result[:status] = result[:status] unless overall_result[:status] == :error + end + + def with_timeout + result = { + original_size: 0, + before_delete_size: 0, + deleted_size: 0, + deleted: [] + } + + yield Time.zone.now, result + + result + rescue TimeoutError + result[:status] = :error + + result + end + + def filter_by_keep_n(tags) + partition_by_keep_n(tags).first + end + + def filter_by_older_than(tags) + partition_by_older_than(tags).first + end + + def pushed_at(tag) + tag.updated_at || tag.created_at + end + end + end + end +end diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb index 81cef554dec..530cf87c338 100644 --- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb @@ -6,10 +6,7 @@ module Projects class DeleteTagsService include BaseServiceUtility include ::Gitlab::Utils::StrongMemoize - - DISABLED_TIMEOUTS = [nil, 0].freeze - - TimeoutError = Class.new(StandardError) + include ::Projects::ContainerRepository::Gitlab::Timeoutable def initialize(container_repository, tag_names) @container_repository = container_repository @@ -44,16 +41,6 @@ module Projects @deleted_tags.any? ? success(deleted: @deleted_tags) : error('could not delete tags') end - - def timeout?(start_time) - return false if service_timeout.in?(DISABLED_TIMEOUTS) - - (Time.zone.now - start_time) > service_timeout - end - - def service_timeout - ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout - end end end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 6381ee67ce7..c72f9b4b602 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -96,7 +96,7 @@ module Projects log_info("#{current_user.name} created a new project \"#{@project.full_name}\"") if @project.import? - experiment(:combined_registration, user: current_user).track(:import_project) + Gitlab::Tracking.event(self.class.name, 'import_project', user: current_user) else # Skip writing the config for project imports/forks because it # will always fail since the Git directory doesn't exist until @@ -158,14 +158,25 @@ module Projects priority: UserProjectAccessChangedService::LOW_PRIORITY ) else - @project.add_owner(@project.namespace.owner, current_user: current_user) + owner_user = @project.namespace.owner + owner_member = @project.add_owner(owner_user, current_user: current_user) + + # There is a possibility that the sidekiq job to refresh the authorizations of the owner_user in this project + # isn't picked up (or finished) by the time the user is redirected to the newly created project's page. + # If that happens, the user will hit a 404. To avoid that scenario, we manually create a `project_authorizations` record for the user here. + if owner_member.persisted? + owner_user.project_authorizations.safe_find_or_create_by( + project: @project, + access_level: ProjectMember::OWNER + ) + end # During the process of adding a project owner, a check on permissions is made on the user which caches # the max member access for that user on this project. # Since that is `0` before the member is created - and we are still inside the request # cycle when we need to do other operations that might check those permissions (e.g. write a commit) # we need to purge that cache so that the updated permissions is fetched instead of using the outdated cached value of 0 # from before member creation - @project.team.purge_member_access_cache_for_user_id(@project.namespace.owner.id) + @project.team.purge_member_access_cache_for_user_id(owner_user.id) end end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 06a44b07f9f..f1525ed9763 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -67,9 +67,9 @@ module Projects end def remove_snippets - # We're setting the hard_delete param because we dont need to perform the access checks within the service since + # We're setting the skip_authorization param because we dont need to perform the access checks within the service since # the user has enough access rights to remove the project and its resources. - response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute(hard_delete: true) + response = ::Snippets::BulkDestroyService.new(current_user, project.snippets).execute(skip_authorization: true) if response.error? log_error("Snippet deletion failed on #{project.full_path} with the following message: #{response.message}") @@ -134,6 +134,8 @@ module Projects destroy_ci_records! destroy_mr_diff_relations! + destroy_merge_request_diffs! if ::Feature.enabled?(:extract_mr_diff_deletions) + # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 # This ensures we delete records in batches. @@ -158,10 +160,9 @@ module Projects # # rubocop: disable CodeReuse/ActiveRecord def destroy_mr_diff_relations! - mr_batch_size = 100 delete_batch_size = 1000 - project.merge_requests.each_batch(column: :iid, of: mr_batch_size) do |relation_ids| + project.merge_requests.each_batch(column: :iid, of: BATCH_SIZE) do |relation_ids| [MergeRequestDiffCommit, MergeRequestDiffFile].each do |model| loop do inner_query = model @@ -180,6 +181,23 @@ module Projects end # rubocop: enable CodeReuse/ActiveRecord + # rubocop: disable CodeReuse/ActiveRecord + def destroy_merge_request_diffs! + delete_batch_size = 1000 + + project.merge_requests.each_batch(column: :iid, of: BATCH_SIZE) do |relation| + loop do + deleted_rows = MergeRequestDiff + .where(merge_request: relation) + .limit(delete_batch_size) + .delete_all + + break if deleted_rows == 0 + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + def destroy_ci_records! # Make sure to destroy this first just in case the project is undergoing stats refresh. # This is to avoid logging the artifact deletion in Ci::JobArtifacts::DestroyBatchService. diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 6265a74fad2..9f260345937 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -3,9 +3,8 @@ module Projects module Prometheus module Alerts - class NotifyService + class NotifyService < ::BaseProjectService include Gitlab::Utils::StrongMemoize - include ::IncidentManagement::Settings include ::AlertManagement::Responses # This set of keys identifies a payload as a valid Prometheus @@ -26,14 +25,13 @@ module Projects # https://gitlab.com/gitlab-com/gl-infra/production/-/issues/6086 PROCESS_MAX_ALERTS = 100 - def initialize(project, payload) - @project = project - @payload = payload + def initialize(project, params) + super(project: project, params: params.to_h) end def execute(token, integration = nil) return bad_request unless valid_payload_size? - return unprocessable_entity unless self.class.processable?(payload) + return unprocessable_entity unless self.class.processable?(params) return unauthorized unless valid_alert_manager_token?(token, integration) truncate_alerts! if max_alerts_exceeded? @@ -53,10 +51,8 @@ module Projects private - attr_reader :project, :payload - def valid_payload_size? - Gitlab::Utils::DeepSize.new(payload.to_h).valid? + Gitlab::Utils::DeepSize.new(params).valid? end def max_alerts_exceeded? @@ -75,11 +71,11 @@ module Projects } ) - payload['alerts'] = alerts.first(PROCESS_MAX_ALERTS) + params['alerts'] = alerts.first(PROCESS_MAX_ALERTS) end def alerts - payload['alerts'] + params['alerts'] end def valid_alert_manager_token?(token, integration) @@ -152,7 +148,7 @@ module Projects def process_prometheus_alerts alerts.map do |alert| AlertManagement::ProcessPrometheusAlertService - .new(project, alert.to_h) + .new(project, alert) .execute end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index dd1c2b94e18..bf90783fcbe 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -65,11 +65,20 @@ module Projects def build_commit_status GenericCommitStatus.new( user: build.user, - stage: 'deploy', + ci_stage: stage, name: 'pages:deploy' ) end + # rubocop: disable Performance/ActiveRecordSubtransactionMethods + def stage + build.pipeline.stages.safe_find_or_create_by(name: 'deploy', pipeline_id: build.pipeline.id) do |stage| + stage.position = GenericCommitStatus::EXTERNAL_STAGE_IDX + stage.project = build.project + end + end + # rubocop: enable Performance/ActiveRecordSubtransactionMethods + def create_pages_deployment(artifacts_path, build) sha256 = build.job_artifacts_archive.file_sha256 File.open(artifacts_path) do |file| diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index 2588d2187a5..b7df201824a 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -4,6 +4,7 @@ module Releases class CreateService < Releases::BaseService def execute return error('Access Denied', 403) unless allowed? + return error('You are not allowed to create this tag as it is protected.', 403) unless can_create_tag? return error('Release already exists', 409) if release return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any? @@ -38,7 +39,7 @@ module Releases end def allowed? - Ability.allowed?(current_user, :create_release, project) && can_create_tag? + Ability.allowed?(current_user, :create_release, project) end def can_create_tag? diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 04f917ec8ef..7e176f95db0 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -29,7 +29,10 @@ module ResourceEvents resource.expire_note_etag_cache - Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue) + return unless resource.is_a?(Issue) + + Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user, + project: resource.project) end private diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb index 89cb14e6fff..7fd0fb10b4b 100644 --- a/app/services/service_ping/submit_service.rb +++ b/app/services/service_ping/submit_service.rb @@ -18,41 +18,20 @@ module ServicePing def execute return unless ServicePing::ServicePingSettings.product_intelligence_enabled? - start = Time.current - begin - usage_data = payload || ServicePing::BuildPayload.new.execute - response = submit_usage_data_payload(usage_data) - rescue StandardError => e - return unless Gitlab::CurrentSettings.usage_ping_enabled? - - error_payload = { - time: Time.current, - uuid: Gitlab::CurrentSettings.uuid, - hostname: Gitlab.config.gitlab.host, - version: Gitlab.version_info.to_s, - message: "#{e.message.presence || e.class} at #{e.backtrace[0]}", - elapsed: (Time.current - start).round(1) - } - submit_payload({ error: error_payload }, path: ERROR_PATH) + start_time = Time.current - usage_data = payload || Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values) - response = submit_usage_data_payload(usage_data) - end + begin + response = submit_usage_data_payload - version_usage_data_id = - response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id') + raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success? - unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0 - raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}" - end + handle_response(response) + submit_metadata_payload + rescue StandardError => e + submit_error_payload(e, start_time) - unless skip_db_write - raw_usage_data = save_raw_usage_data(usage_data) - raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id) - ServicePing::DevopsReport.new(response).execute + raise end - - submit_payload(metadata(usage_data), path: METADATA_PATH) end private @@ -90,14 +69,43 @@ module ServicePing ) end - def submit_usage_data_payload(usage_data) - raise SubmissionError, 'Usage data is blank' if usage_data.blank? + def submit_usage_data_payload + raise SubmissionError, 'Usage data payload is blank' if payload.blank? + + submit_payload(payload) + end + + def handle_response(response) + version_usage_data_id = + response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id') - response = submit_payload(usage_data) + unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0 + raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}" + end - raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success? + return if skip_db_write + + raw_usage_data = save_raw_usage_data(payload) + raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id) + ServicePing::DevopsReport.new(response).execute + end + + def submit_error_payload(error, start_time) + current_time = Time.current + error_payload = { + time: current_time, + uuid: Gitlab::CurrentSettings.uuid, + hostname: Gitlab.config.gitlab.host, + version: Gitlab.version_info.to_s, + message: "#{error.message.presence || error.class} at #{error.backtrace[0]}", + elapsed: (current_time - start_time).round(1) + } + + submit_payload({ error: error_payload }, path: ERROR_PATH) + end - response + def submit_metadata_payload + submit_payload(metadata(payload), path: METADATA_PATH) end def save_raw_usage_data(usage_data) diff --git a/app/services/service_response.rb b/app/services/service_response.rb index c7ab75a4426..848f90e7f25 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -2,20 +2,28 @@ class ServiceResponse def self.success(message: nil, payload: {}, http_status: :ok) - new(status: :success, message: message, payload: payload, http_status: http_status) + new(status: :success, + message: message, + payload: payload, + http_status: http_status) end - def self.error(message:, payload: {}, http_status: nil) - new(status: :error, message: message, payload: payload, http_status: http_status) + def self.error(message:, payload: {}, http_status: nil, reason: nil) + new(status: :error, + message: message, + payload: payload, + http_status: http_status, + reason: reason) end - attr_reader :status, :message, :http_status, :payload + attr_reader :status, :message, :http_status, :payload, :reason - def initialize(status:, message: nil, payload: {}, http_status: nil) + def initialize(status:, message: nil, payload: {}, http_status: nil, reason: nil) self.status = status self.message = message self.payload = payload self.http_status = http_status + self.reason = reason end def track_exception(as: StandardError, **extra_data) @@ -41,7 +49,11 @@ class ServiceResponse end def to_h - (payload || {}).merge(status: status, message: message, http_status: http_status) + (payload || {}).merge( + status: status, + message: message, + http_status: http_status, + reason: reason) end def success? @@ -60,5 +72,5 @@ class ServiceResponse private - attr_writer :status, :message, :http_status, :payload + attr_writer :status, :message, :http_status, :payload, :reason end diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb index 1a04c4fcedd..42e62d65ee4 100644 --- a/app/services/snippets/base_service.rb +++ b/app/services/snippets/base_service.rb @@ -73,6 +73,15 @@ module Snippets message end + def file_paths_to_commit + paths = [] + snippet_actions.to_commit_actions.each do |action| + paths << { path: action[:file_path] } + end + + paths + end + def files_to_commit(snippet) snippet_actions.to_commit_actions.presence || build_actions_from_params(snippet) end diff --git a/app/services/snippets/bulk_destroy_service.rb b/app/services/snippets/bulk_destroy_service.rb index 6eab9fb320e..9c6e1c14051 100644 --- a/app/services/snippets/bulk_destroy_service.rb +++ b/app/services/snippets/bulk_destroy_service.rb @@ -14,10 +14,10 @@ module Snippets @snippets = snippets end - def execute(options = {}) + def execute(skip_authorization: false) return ServiceResponse.success(message: 'No snippets found.') if snippets.empty? - user_can_delete_snippets! unless options[:hard_delete] + user_can_delete_snippets! unless skip_authorization attempt_delete_repositories! snippets.destroy_all # rubocop: disable Cop/DestroyAll diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 6d3b63de9fd..e0bab4cd6ad 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -24,7 +24,8 @@ module Snippets spammable: @snippet, spam_params: spam_params, user: current_user, - action: :create + action: :create, + extra_features: { files: file_paths_to_commit } ).execute if save_and_commit diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 76d5063c337..067680f2abc 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -23,11 +23,14 @@ module Snippets update_snippet_attributes(snippet) + files = snippet.all_files.map { |f| { path: f } } + file_paths_to_commit + Spam::SpamActionService.new( spammable: snippet, spam_params: spam_params, user: current_user, - action: :update + action: :update, + extra_features: { files: files } ).execute if save_and_commit(snippet) diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 4fa9c0e4993..9c52e9f0cd3 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -4,11 +4,12 @@ module Spam class SpamActionService include SpamConstants - def initialize(spammable:, spam_params:, user:, action:) + def initialize(spammable:, spam_params:, user:, action:, extra_features: {}) @target = spammable @spam_params = spam_params @user = user @action = action + @extra_features = extra_features end # rubocop:disable Metrics/AbcSize @@ -40,7 +41,7 @@ module Spam private - attr_reader :user, :action, :target, :spam_params, :spam_log + attr_reader :user, :action, :target, :spam_params, :spam_log, :extra_features ## # In order to be proceed to the spam check process, the target must be @@ -124,7 +125,9 @@ module Spam SpamVerdictService.new(target: target, user: user, options: options, - context: context) + context: context, + extra_features: extra_features + ) end def noteable_type diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb index d300525710c..9ac3bcf8a1d 100644 --- a/app/services/spam/spam_constants.rb +++ b/app/services/spam/spam_constants.rb @@ -2,6 +2,7 @@ module Spam module SpamConstants + ERROR_TYPE = 'spamcheck' BLOCK_USER = 'block' DISALLOW = 'disallow' CONDITIONAL_ALLOW = 'conditional_allow' diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index e73b2666c02..08634ec840c 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -5,11 +5,12 @@ module Spam include AkismetMethods include SpamConstants - def initialize(user:, target:, options:, context: {}) + def initialize(user:, target:, options:, context: {}, extra_features: {}) @target = target @user = user @options = options @context = context + @extra_features = extra_features end def execute @@ -61,7 +62,7 @@ module Spam private - attr_reader :user, :target, :options, :context + attr_reader :user, :target, :options, :context, :extra_features def akismet_verdict if akismet.spam? @@ -75,7 +76,8 @@ module Spam return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled begin - result, attribs, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context) + result, attribs, _error = spamcheck_client.spam?(spammable: target, user: user, context: context, + extra_features: extra_features) # @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545 return [nil, attribs] unless result @@ -83,7 +85,7 @@ module Spam [result, attribs] rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e) + Gitlab::ErrorTracking.log_exception(e, error: ERROR_TYPE) # Default to ALLOW if any errors occur [ALLOW, attribs, true] diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 75903fde39e..7275a05d2ce 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -14,6 +14,13 @@ module SystemNotes # See also the discussion in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60700#note_612724683 USE_COMMIT_DATE_FOR_CROSS_REFERENCE_NOTE = false + def self.issuable_events + { + review_requested: s_('IssuableEvents|requested review from'), + review_request_removed: s_('IssuableEvents|removed review request for') + }.freeze + end + # # noteable_ref - Referenced noteable object # @@ -26,7 +33,7 @@ module SystemNotes issuable_type = noteable.to_ability_name.humanize(capitalize: false) body = "marked this #{issuable_type} as related to #{noteable_ref.to_reference(noteable.resource_parent)}" - issue_activity_counter.track_issue_related_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_related_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'relate')) end @@ -42,7 +49,7 @@ module SystemNotes def unrelate_issuable(noteable_ref) body = "removed the relation with #{noteable_ref.to_reference(noteable.resource_parent)}" - issue_activity_counter.track_issue_unrelated_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_unrelated_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'unrelate')) end @@ -61,7 +68,7 @@ module SystemNotes def change_assignee(assignee) body = assignee.nil? ? 'removed assignee' : "assigned to #{assignee.to_reference}" - issue_activity_counter.track_issue_assignee_changed_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_assignee_changed_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end @@ -93,7 +100,7 @@ module SystemNotes body = text_parts.join(' and ') - issue_activity_counter.track_issue_assignee_changed_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_assignee_changed_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end @@ -115,8 +122,8 @@ module SystemNotes text_parts = [] Gitlab::I18n.with_default_locale do - text_parts << "requested review from #{added_users.map(&:to_reference).to_sentence}" if added_users.any? - text_parts << "removed review request for #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + text_parts << "#{self.class.issuable_events[:review_requested]} #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "#{self.class.issuable_events[:review_request_removed]} #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? end body = text_parts.join(' and ') @@ -172,7 +179,7 @@ module SystemNotes body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" - issue_activity_counter.track_issue_title_changed_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_title_changed_action) work_item_activity_counter.track_work_item_title_changed_action(author: author) if noteable.is_a?(WorkItem) create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) @@ -210,7 +217,7 @@ module SystemNotes def change_description body = 'changed the description' - issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_description_changed_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) end @@ -246,6 +253,7 @@ module SystemNotes ) else track_cross_reference_action + created_at = mentioner.created_at if USE_COMMIT_DATE_FOR_CROSS_REFERENCE_NOTE && mentioner.is_a?(Commit) create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference', created_at: created_at)) end @@ -280,7 +288,7 @@ module SystemNotes status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE body = "marked the checklist item **#{new_task.source}** as #{status_label}" - issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_description_changed_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'task')) end @@ -303,7 +311,7 @@ module SystemNotes cross_reference = noteable_ref.to_reference(project) body = "moved #{direction} #{cross_reference}" - issue_activity_counter.track_issue_moved_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_moved_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'moved')) end @@ -327,9 +335,7 @@ module SystemNotes cross_reference = noteable_ref.to_reference(project) body = "cloned #{direction} #{cross_reference}" - if noteable.is_a?(Issue) && direction == :to - issue_activity_counter.track_issue_cloned_action(author: author, project: project) - end + track_issue_event(:track_issue_cloned_action) if direction == :to create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned', created_at: created_at)) end @@ -346,12 +352,12 @@ module SystemNotes body = 'made the issue confidential' action = 'confidential' - issue_activity_counter.track_issue_made_confidential_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_made_confidential_action) else body = 'made the issue visible to everyone' action = 'visible' - issue_activity_counter.track_issue_made_visible_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_made_visible_action) end create_note(NoteSummary.new(noteable, project, author, body, action: action)) @@ -418,7 +424,7 @@ module SystemNotes def mark_duplicate_issue(canonical_issue) body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}" - issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_marked_as_duplicate_action) create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) end @@ -431,12 +437,10 @@ module SystemNotes action = noteable.discussion_locked? ? 'locked' : 'unlocked' body = "#{action} this #{noteable.class.to_s.titleize.downcase}" - if noteable.is_a?(Issue) - if action == 'locked' - issue_activity_counter.track_issue_locked_action(author: author) - else - issue_activity_counter.track_issue_unlocked_action(author: author) - end + if action == 'locked' + track_issue_event(:track_issue_locked_action) + else + track_issue_event(:track_issue_unlocked_action) end create_note(NoteSummary.new(noteable, project, author, body, action: action)) @@ -495,7 +499,7 @@ module SystemNotes end def track_cross_reference_action - issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue) + track_issue_event(:track_issue_cross_referenced_action) end def hierarchy_note_params(action, parent, child) @@ -520,6 +524,12 @@ module SystemNotes } end end + + def track_issue_event(event_name) + return unless noteable.is_a?(Issue) + + issue_activity_counter.public_send(event_name, author: author, project: project || noteable.project) # rubocop: disable GitlabSecurity/PublicSend + end end end diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb index 68df52a03c7..c5bdbc6799e 100644 --- a/app/services/system_notes/time_tracking_service.rb +++ b/app/services/system_notes/time_tracking_service.rb @@ -21,7 +21,7 @@ module SystemNotes # Using instance_of because WorkItem < Issue. We don't want to track work item updates as issue updates if noteable.instance_of?(Issue) && changed_dates.key?('due_date') - issue_activity_counter.track_issue_due_date_changed_action(author: author) + issue_activity_counter.track_issue_due_date_changed_action(author: author, project: project) end work_item_activity_counter.track_work_item_date_changed_action(author: author) if noteable.is_a?(WorkItem) @@ -50,7 +50,9 @@ module SystemNotes "changed time estimate to #{parsed_time}" end - issue_activity_counter.track_issue_time_estimate_changed_action(author: author) if noteable.is_a?(Issue) + if noteable.is_a?(Issue) + issue_activity_counter.track_issue_time_estimate_changed_action(author: author, project: project) + end create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) end @@ -81,7 +83,9 @@ module SystemNotes body = text_parts.join(' ') end - issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue) + if noteable.is_a?(Issue) + issue_activity_counter.track_issue_time_spent_changed_action(author: author, project: project) + end create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) end @@ -107,7 +111,9 @@ module SystemNotes text_parts << "at #{spent_at}" if spent_at && spent_at != DateTime.current.to_date body = text_parts.join(' ') - issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue) + if noteable.is_a?(Issue) + issue_activity_counter.track_issue_time_spent_changed_action(author: author, project: project) + end create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) end diff --git a/app/services/topics/merge_service.rb b/app/services/topics/merge_service.rb index 0d256579fe0..58f3d5305b4 100644 --- a/app/services/topics/merge_service.rb +++ b/app/services/topics/merge_service.rb @@ -17,14 +17,21 @@ module Topics refresh_target_topic_counters delete_source_topic end + + ServiceResponse.success + rescue ArgumentError => e + ServiceResponse.error(message: e.message) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, source_topic_id: source_topic.id, target_topic_id: target_topic.id) + ServiceResponse.error(message: _('Topics could not be merged!')) end private def validate_parameters! - raise ArgumentError, 'The source topic is not a topic.' unless source_topic.is_a?(Projects::Topic) - raise ArgumentError, 'The target topic is not a topic.' unless target_topic.is_a?(Projects::Topic) - raise ArgumentError, 'The source topic and the target topic are identical.' if source_topic == target_topic + raise ArgumentError, _('The source topic is not a topic.') unless source_topic.is_a?(Projects::Topic) + raise ArgumentError, _('The target topic is not a topic.') unless target_topic.is_a?(Projects::Topic) + raise ArgumentError, _('The source topic and the target topic are identical.') if source_topic == target_topic end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/services/users/authorized_build_service.rb b/app/services/users/authorized_build_service.rb index eb2386198d3..5029105b087 100644 --- a/app/services/users/authorized_build_service.rb +++ b/app/services/users/authorized_build_service.rb @@ -16,3 +16,5 @@ module Users end end end + +Users::AuthorizedBuildService.prepend_mod_with('Users::AuthorizedBuildService') diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index dfa9316889e..a378cb09854 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -23,6 +23,11 @@ module Users # `hard_delete: true` implies `delete_solo_owned_groups: true`. To perform # a hard deletion without destroying solo-owned groups, pass # `delete_solo_owned_groups: false, hard_delete: true` in +options+. + # + # To make the service asynchronous, a new behaviour is being introduced + # behind the user_destroy_with_limited_execution_time_worker feature flag. + # Migrating the associated user records, and post-migration cleanup is + # handled by the Users::MigrateRecordsToGhostUserWorker cron worker. def execute(user, options = {}) delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete]) @@ -35,12 +40,14 @@ module Users return user end - # Calling all before/after_destroy hooks for the user because - # there is no dependent: destroy in the relationship. And the removal - # is done by a foreign_key. Otherwise they won't be called - user.members.find_each { |member| member.run_callbacks(:destroy) } + user.block + + # Load the records. Groups are unavailable after membership is destroyed. + solo_owned_groups = user.solo_owned_groups.load + + user.members.each_batch { |batch| batch.destroy_all } # rubocop:disable Style/SymbolProc, Cop/DestroyAll - user.solo_owned_groups.each do |group| + solo_owned_groups.each do |group| Groups::DestroyService.new(group, current_user).execute end @@ -54,22 +61,32 @@ module Users yield(user) if block_given? - MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete]) + hard_delete = options.fetch(:hard_delete, false) - response = Snippets::BulkDestroyService.new(current_user, user.snippets).execute(options) - raise DestroyError, response.message if response.error? + if Feature.enabled?(:user_destroy_with_limited_execution_time_worker) + Users::GhostUserMigration.create!(user: user, + initiator_user: current_user, + hard_delete: hard_delete) - # Rails attempts to load all related records into memory before - # destroying: https://github.com/rails/rails/issues/22510 - # This ensures we delete records in batches. - user.destroy_dependent_associations_in_batches(exclude: [:snippets]) - user.nullify_dependent_associations_in_batches + else + MigrateToGhostUserService.new(user).execute(hard_delete: options[:hard_delete]) - # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing - user_data = user.destroy - namespace.destroy + response = Snippets::BulkDestroyService.new(current_user, user.snippets) + .execute(skip_authorization: hard_delete) + raise DestroyError, response.message if response.error? - user_data + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches(exclude: [:snippets]) + user.nullify_dependent_associations_in_batches + + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + user_data = user.destroy + namespace.destroy + + user_data + end end end end diff --git a/app/services/users/email_verification/base_service.rb b/app/services/users/email_verification/base_service.rb new file mode 100644 index 00000000000..3337beec195 --- /dev/null +++ b/app/services/users/email_verification/base_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class BaseService + VALID_ATTRS = %i[unlock_token confirmation_token].freeze + + def initialize(attr:) + @attr = attr + + validate_attr! + end + + protected + + attr_reader :attr, :token + + def validate_attr! + raise ArgumentError, 'Invalid attribute' unless attr.in?(VALID_ATTRS) + end + + def digest + Devise.token_generator.digest(User, attr, token) + end + end + end +end diff --git a/app/services/users/email_verification/generate_token_service.rb b/app/services/users/email_verification/generate_token_service.rb new file mode 100644 index 00000000000..6f0237ce244 --- /dev/null +++ b/app/services/users/email_verification/generate_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class GenerateTokenService < EmailVerification::BaseService + TOKEN_LENGTH = 6 + + def execute + @token = generate_token + + [token, digest] + end + + private + + def generate_token + SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0') + end + end + end +end diff --git a/app/services/users/email_verification/validate_token_service.rb b/app/services/users/email_verification/validate_token_service.rb new file mode 100644 index 00000000000..b1b34e94f49 --- /dev/null +++ b/app/services/users/email_verification/validate_token_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Users + module EmailVerification + class ValidateTokenService < EmailVerification::BaseService + include ActionView::Helpers::DateHelper + + TOKEN_VALID_FOR_MINUTES = 60 + + def initialize(attr:, user:, token:) + super(attr: attr) + + @user = user + @token = token + end + + def execute + return failure(:rate_limited) if verification_rate_limited? + return failure(:invalid) unless valid? + return failure(:expired) if expired_token? + + success + end + + private + + attr_reader :user + + def verification_rate_limited? + Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user[attr]) + end + + def valid? + return false unless token.present? + + Devise.secure_compare(user[attr], digest) + end + + def expired_token? + generated_at = case attr + when :unlock_token then user.locked_at + when :confirmation_token then user.confirmation_sent_at + end + + generated_at < TOKEN_VALID_FOR_MINUTES.minutes.ago + end + + def success + { status: :success } + end + + def failure(reason) + { + status: :failure, + reason: reason, + message: failure_message(reason) + } + end + + def failure_message(reason) + case reason + when :rate_limited + format(s_("IdentityVerification|You've reached the maximum amount of tries. "\ + 'Wait %{interval} or send a new code and try again.'), interval: email_verification_interval) + when :expired + s_('IdentityVerification|The code has expired. Send a new code and try again.') + when :invalid + s_('IdentityVerification|The code is incorrect. Enter it again, or send a new code.') + end + end + + def email_verification_interval + interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval] + distance_of_time_in_words(interval_in_seconds) + end + end + end +end diff --git a/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb new file mode 100644 index 00000000000..7c4a5698ea9 --- /dev/null +++ b/app/services/users/migrate_records_to_ghost_user_in_batches_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Users + class MigrateRecordsToGhostUserInBatchesService + def initialize + @execution_tracker = Gitlab::Utils::ExecutionTracker.new + end + + def execute + Users::GhostUserMigration.find_each do |user_to_migrate| + break if execution_tracker.over_limit? + + service = Users::MigrateRecordsToGhostUserService.new(user_to_migrate.user, + user_to_migrate.initiator_user, + execution_tracker) + service.execute(hard_delete: user_to_migrate.hard_delete) + end + rescue Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError + # no-op + end + + private + + attr_reader :execution_tracker + end +end diff --git a/app/services/users/migrate_records_to_ghost_user_service.rb b/app/services/users/migrate_records_to_ghost_user_service.rb new file mode 100644 index 00000000000..2d92aaed7da --- /dev/null +++ b/app/services/users/migrate_records_to_ghost_user_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# When a user is destroyed, some of their associated records are +# moved to a "Ghost User", to prevent these associated records from +# being destroyed. +# +# For example, all the issues/MRs a user has created are _not_ destroyed +# when the user is destroyed. +module Users + class MigrateRecordsToGhostUserService + extend ActiveSupport::Concern + + DestroyError = Class.new(StandardError) + + attr_reader :ghost_user, :user, :initiator_user, :hard_delete + + def initialize(user, initiator_user, execution_tracker) + @user = user + @initiator_user = initiator_user + @execution_tracker = execution_tracker + @ghost_user = User.ghost + end + + def execute(hard_delete: false) + @hard_delete = hard_delete + + migrate_records + post_migrate_records + end + + private + + attr_reader :execution_tracker + + def migrate_records + return if hard_delete + + migrate_issues + migrate_merge_requests + migrate_notes + migrate_abuse_reports + migrate_award_emoji + migrate_snippets + migrate_reviews + end + + def post_migrate_records + delete_snippets + + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches(exclude: [:snippets]) + user.nullify_dependent_associations_in_batches + + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + user_data = user.destroy + user.namespace.destroy + + user_data + end + + def delete_snippets + response = Snippets::BulkDestroyService.new(initiator_user, user.snippets).execute(skip_authorization: true) + raise DestroyError, response.message if response.error? + end + + def migrate_issues + batched_migrate(Issue, :author_id) + batched_migrate(Issue, :last_edited_by_id) + end + + def migrate_merge_requests + batched_migrate(MergeRequest, :author_id) + batched_migrate(MergeRequest, :merge_user_id) + end + + def migrate_notes + batched_migrate(Note, :author_id) + end + + def migrate_abuse_reports + user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) + end + + def migrate_award_emoji + user.award_emoji.update_all(user_id: ghost_user.id) + end + + def migrate_snippets + snippets = user.snippets.only_project_snippets + snippets.update_all(author_id: ghost_user.id) + end + + def migrate_reviews + batched_migrate(Review, :author_id) + end + + # rubocop:disable CodeReuse/ActiveRecord + def batched_migrate(base_scope, column, batch_size: 50) + loop do + update_count = base_scope.where(column => user.id).limit(batch_size).update_all(column => ghost_user.id) + break if update_count == 0 + raise Gitlab::Utils::ExecutionTracker::ExecutionTimeOutError if execution_tracker.over_limit? + end + end + # rubocop:enable CodeReuse/ActiveRecord + end +end + +Users::MigrateRecordsToGhostUserService.prepend_mod_with('Users::MigrateRecordsToGhostUserService') diff --git a/app/uploaders/object_storage/cdn.rb b/app/uploaders/object_storage/cdn.rb new file mode 100644 index 00000000000..0711ab0bd28 --- /dev/null +++ b/app/uploaders/object_storage/cdn.rb @@ -0,0 +1,46 @@ +# rubocop:disable Naming/FileName +# frozen_string_literal: true + +require_relative 'cdn/google_cdn' + +module ObjectStorage + module CDN + module Concern + extend ActiveSupport::Concern + + include Gitlab::Utils::StrongMemoize + + def use_cdn?(request_ip) + return false unless cdn_options.is_a?(Hash) && cdn_options['provider'] + return false unless cdn_provider + + cdn_provider.use_cdn?(request_ip) + end + + def cdn_signed_url + cdn_provider&.signed_url(path) + end + + private + + def cdn_provider + strong_memoize(:cdn_provider) do + provider = cdn_options['provider']&.downcase + + next unless provider + next GoogleCDN.new(cdn_options) if provider == 'google' + + raise "Unknown CDN provider: #{provider}" + end + end + + def cdn_options + return {} unless options.object_store.key?('cdn') + + options.object_store.cdn + end + end + end +end + +# rubocop:enable Naming/FileName diff --git a/app/uploaders/object_storage/cdn/google_cdn.rb b/app/uploaders/object_storage/cdn/google_cdn.rb new file mode 100644 index 00000000000..ea7683f131c --- /dev/null +++ b/app/uploaders/object_storage/cdn/google_cdn.rb @@ -0,0 +1,71 @@ +# rubocop:disable Naming/FileName +# frozen_string_literal: true + +module ObjectStorage + module CDN + class GoogleCDN + include Gitlab::Utils::StrongMemoize + + attr_reader :options + + def initialize(options) + @options = HashWithIndifferentAccess.new(options.to_h) + + GoogleIpCache.async_refresh unless GoogleIpCache.ready? + end + + def use_cdn?(request_ip) + return false unless config_valid? + + ip = IPAddr.new(request_ip) + + return false if ip.private? + + !GoogleIpCache.google_ip?(request_ip) + end + + def signed_url(path, expiry: 10.minutes) + expiration = (Time.current + expiry).utc.to_i + + uri = Addressable::URI.parse(cdn_url) + uri.path = path + uri.query = "Expires=#{expiration}&KeyName=#{key_name}" + + signature = OpenSSL::HMAC.digest('SHA1', decoded_key, uri.to_s) + encoded_signature = Base64.urlsafe_encode64(signature) + + uri.query += "&Signature=#{encoded_signature}" + uri.to_s + end + + private + + def config_valid? + [key_name, decoded_key, cdn_url].all?(&:present?) + end + + def key_name + strong_memoize(:key_name) do + options['key_name'] + end + end + + def decoded_key + strong_memoize(:decoded_key) do + Base64.urlsafe_decode64(options['key']) if options['key'] + rescue ArgumentError + Gitlab::ErrorTracking.log_exception(ArgumentError.new("Google CDN key is not base64-encoded")) + nil + end + end + + def cdn_url + strong_memoize(:cdn_url) do + options['url'] + end + end + end + end +end + +# rubocop:enable Naming/FileName diff --git a/app/uploaders/object_storage/cdn/google_ip_cache.rb b/app/uploaders/object_storage/cdn/google_ip_cache.rb new file mode 100644 index 00000000000..35ec7ce0c6e --- /dev/null +++ b/app/uploaders/object_storage/cdn/google_ip_cache.rb @@ -0,0 +1,60 @@ +# rubocop:disable Naming/FileName +# frozen_string_literal: true + +module ObjectStorage + module CDN + class GoogleIpCache + GOOGLE_CDN_LIST_KEY = 'google_cdn_ip_list' + CACHE_EXPIRATION_TIME = 1.day + + class << self + def update!(subnets) + caches.each { |cache| cache.write(GOOGLE_CDN_LIST_KEY, subnets) } + end + + def ready? + caches.any? { |cache| cache.exist?(GOOGLE_CDN_LIST_KEY) } + end + + def google_ip?(request_ip) + google_ip_ranges = cached_value(GOOGLE_CDN_LIST_KEY) + + return false unless google_ip_ranges + + google_ip_ranges.any? { |range| range.include?(request_ip) } + end + + def async_refresh + ::GoogleCloud::FetchGoogleIpListWorker.perform_async + end + + private + + def caches + [l1_cache, l2_cache] + end + + def l1_cache + Gitlab::ProcessMemoryCache.cache_backend + end + + def l2_cache + Rails.cache + end + + def cached_value(key) + l1_cache.fetch(key) do + result = l2_cache.fetch(key) + + # Don't populate the L1 cache if we can't find the entry + break unless result + + result + end + end + end + end + end +end + +# rubocop:enable Naming/FileName diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb index 4b6dbe5b358..9c0a88c9bf8 100644 --- a/app/uploaders/packages/package_file_uploader.rb +++ b/app/uploaders/packages/package_file_uploader.rb @@ -22,8 +22,6 @@ class Packages::PackageFileUploader < GitlabUploader def dynamic_segment raise ObjectNotReadyError, "Package model not ready" unless model.id - package_segment = model.package.debian? ? 'debian' : model.package.id - - Gitlab::HashedPath.new('packages', package_segment, 'files', model.id, root_hash: model.package.project_id) + Gitlab::HashedPath.new('packages', model.package_id, 'files', model.id, root_hash: model.package.project_id) end end diff --git a/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json new file mode 100644 index 00000000000..70112d7e414 --- /dev/null +++ b/app/validators/json_schemas/merge_request_predictions_suggested_reviewers.json @@ -0,0 +1,10 @@ +{ + "description": "Merge request predictions suggested reviewers", + "type": "object", + "properties": { + "top_n": { "type": "number" }, + "version": { "type": "string" }, + "changes": { "type": "array" } + }, + "additionalProperties": true +} diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml index 258fdb4ad9a..aaa85e81bd4 100644 --- a/app/views/abuse_reports/new.html.haml +++ b/app/views/abuse_reports/new.html.haml @@ -6,8 +6,8 @@ %p = _("A member of the abuse team will review your report as soon as possible.") %hr -= form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f| - = form_errors(@abuse_report, pajamas_alert: true) += gitlab_ui_form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f| + = form_errors(@abuse_report) = f.hidden_field :user_id .form-group.row @@ -25,4 +25,4 @@ = _("Explain the problem. If appropriate, provide a link to the relevant issue or comment.") .form-actions - = f.submit _("Send report"), class: "gl-button btn btn-confirm" + = f.submit _("Send report"), pajamas_button: true diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index fbadd26d0c0..1878db419f7 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -1,9 +1,9 @@ -= form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-abuse-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) += gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-abuse-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) %fieldset .form-group = f.label :abuse_notification_email, _('Abuse reports notification email'), class: 'label-bold' = f.text_field :abuse_notification_email, class: 'form-control gl-form-input' - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index e7204f635e6..c0e42f22119 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form', id: 'account-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group @@ -46,13 +46,19 @@ = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-form-input gl-mt-2' .help-block = _('Specify an email address regex pattern to identify default internal users.') - = link_to _('Learn more'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank', rel: 'noopener noreferrer' - unless Gitlab.com? .form-group = f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold' - dormant_users_help_link = help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users') - dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link } - = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after 90 days of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe } + = f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after a period of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more.%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe } + .form-group + = f.label :deactivate_dormant_users_period, _('Period of inactivity (days)'), class: 'label-light' + = f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '1' + .form-text.text-muted + = _('Period of inactivity before deactivation.') + .form-group = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light' = f.text_field :personal_access_token_prefix, placeholder: _('Maximum 20 characters'), class: 'form-control gl-form-input' @@ -60,6 +66,7 @@ = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold' = f.gitlab_ui_checkbox_component :user_show_add_ssh_key_message, _("Inform users without uploaded SSH keys that they can't push over SSH until one is added") + = render 'admin/application_settings/invitation_flow_enforcement', form: f = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f - = f.submit _('Save changes'), class: 'gl-button btn btn-confirm qa-save-changes-button' + = f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 77170761448..05aea2b343d 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -1,6 +1,6 @@ .settings-content = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true ) + = form_errors(@application_setting ) %fieldset .form-group @@ -53,8 +53,10 @@ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.') + - if Feature.enabled?(:enforce_runner_token_expires_at) + #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } - = f.submit _('Save changes'), class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), pajamas_button: true .settings-content %h4 @@ -71,8 +73,8 @@ .tab-content.gl-tab-content - @plans.each_with_index do |plan, index| .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' } - = form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f| - = form_errors(plan, pajamas_alert: true) + = gitlab_ui_form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-ci-cd-settings'), html: { class: 'fieldset-form' }, method: :post do |f| + = form_errors(plan) %fieldset = f.hidden_field(:plan_id, value: plan.id) .form-group @@ -99,4 +101,4 @@ .form-group = f.label :ci_registered_project_runners, s_('AdminSettings|Maximum number of runners registered per project') = f.number_field :ci_registered_project_runners, class: 'form-control gl-form-input' - = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-confirm' + = f.submit s_('AdminSettings|Save %{name} limits').html_safe % { name: plan.name.capitalize }, pajamas_button: true diff --git a/app/views/admin/application_settings/_default_branch.html.haml b/app/views/admin/application_settings/_default_branch.html.haml index f9b1aa22b7a..7be4bac02fd 100644 --- a/app/views/admin/application_settings/_default_branch.html.haml +++ b/app/views/admin/application_settings/_default_branch.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value}</code>" diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 30165139711..2e8eb25b1d5 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index 68eb33d6552..0bb9be497d9 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -10,7 +10,7 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form', id: 'eks-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 774c5665edd..fd65d4029f5 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-email-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml index 2dcd9d0d2c0..5a8aba5784e 100644 --- a/app/views/admin/application_settings/_error_tracking.html.haml +++ b/app/views/admin/application_settings/_error_tracking.html.haml @@ -25,7 +25,7 @@ data: { confirm: _('Are you sure you want to reset the error tracking access token?') } = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-error-tracking-settings'), html: { class: 'fieldset-form', id: 'error-tracking-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) if expanded + = form_errors(@application_setting) if expanded %fieldset .sub-section diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index f287dba9866..7919fde631f 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -10,7 +10,7 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml index d63eb2bd09d..e56ba635890 100644 --- a/app/views/admin/application_settings/_floc.html.haml +++ b/app/views/admin/application_settings/_floc.html.haml @@ -3,19 +3,20 @@ %section.settings.no-animate#js-floc-settings{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = s_('FloC|Federated Learning of Cohorts') + = s_('FloC|Federated Learning of Cohorts (FLoC)') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p - = s_('FloC|Configure whether you want to participate in FloC.').html_safe - = link_to sprite_icon('question-o'), 'https://github.com/WICG/floc', target: '_blank', rel: 'noopener noreferrer', class: 'has-tooltip', title: _('More information') + - floc_link_url = help_page_path('user/admin_area/settings/floc.md') + - floc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: floc_link_url } + = html_escape(s_('FloC|Configure whether you want to participate in FLoC. %{floc_link_start}What is FLoC?%{floc_link_end}')) % { floc_link_start: floc_link_start, floc_link_end: '</a>'.html_safe } .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-floc-settings'), html: { class: 'fieldset-form', id: 'floc-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group = f.gitlab_ui_checkbox_component :floc_enabled, - s_('FloC|Enable FloC (Federated Learning of Cohorts)') + s_('FloC|Participate in FLoC') = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_git_lfs_limits.html.haml b/app/views/admin/application_settings/_git_lfs_limits.html.haml index 7d47ca9a139..b8970a5bcf1 100644 --- a/app/views/admin/application_settings/_git_lfs_limits.html.haml +++ b/app/views/admin/application_settings/_git_lfs_limits.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-git-lfs-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset %h5 diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index cc2c6dbcb03..ade6dac606a 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-gitaly-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index cc1e3f968cb..df534f18bde 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -13,7 +13,7 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml index f17f63c7df7..7f305b9ad9c 100644 --- a/app/views/admin/application_settings/_grafana.html.haml +++ b/app/views/admin/application_settings/_grafana.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index 08a4ebe5c71..21eb4caf579 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-help-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset = render_if_exists 'admin/application_settings/help_text_setting', form: f diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml index 4e774dd0a1e..bc4a1577f90 100644 --- a/app/views/admin/application_settings/_import_export_limits.html.haml +++ b/app/views/admin/application_settings/_import_export_limits.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-import-export-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset = html_escape(_("Set any rate limit to %{code_open}0%{code_close} to disable the limit.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } diff --git a/app/views/admin/application_settings/_invitation_flow_enforcement.html.haml b/app/views/admin/application_settings/_invitation_flow_enforcement.html.haml new file mode 100644 index 00000000000..895662b38fd --- /dev/null +++ b/app/views/admin/application_settings/_invitation_flow_enforcement.html.haml @@ -0,0 +1,8 @@ +- return unless ::Feature.enabled?(:invitation_flow_enforcement_setting) + +- form = local_assigns.fetch(:form) + +%fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_("AdminSettings|Enforce invitation flow for groups and projects") + = form.gitlab_ui_checkbox_component :invitation_flow_enforcement, s_("AdminSettings|Users and groups must accept the invitation before they're added to a group or project.") diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index 9a9038ef48e..4362ae9cb9b 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-ip-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset = _("Rate limits can help reduce request volume (like from crawlers or abusive bots).") diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml index 64aca50cbe9..431e2a64c46 100644 --- a/app/views/admin/application_settings/_issue_limits.html.haml +++ b/app/views/admin/application_settings/_issue_limits.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-issue-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_jira_connect_application_key.html.haml b/app/views/admin/application_settings/_jira_connect_application_key.html.haml index 68a82288573..e3df408cd4c 100644 --- a/app/views/admin/application_settings/_jira_connect_application_key.html.haml +++ b/app/views/admin/application_settings/_jira_connect_application_key.html.haml @@ -12,7 +12,7 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-jira-connect-application-id-settings'), html: { class: 'fieldset-form', id: 'jira-connect-application-id-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml index c0ac924407f..4f5a313d7b7 100644 --- a/app/views/admin/application_settings/_kroki.html.haml +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -10,7 +10,7 @@ = link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) if expanded + = form_errors(@application_setting) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index 0477f114bdf..a6ed48ef4fe 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml index cbe7e1c5bb6..1604419869c 100644 --- a/app/views/admin/application_settings/_mailgun.html.haml +++ b/app/views/admin/application_settings/_mailgun.html.haml @@ -9,7 +9,7 @@ = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank', rel: 'noopener noreferrer') } .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) if expanded + = form_errors(@application_setting) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_network_rate_limits.html.haml b/app/views/admin/application_settings/_network_rate_limits.html.haml index 173e830c7da..f1857a9749a 100644 --- a/app/views/admin/application_settings/_network_rate_limits.html.haml +++ b/app/views/admin/application_settings/_network_rate_limits.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: anchor), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset = _("Rate limits can help reduce request volume (like from crawlers or abusive bots).") diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml index b783345b9df..40760b3c45e 100644 --- a/app/views/admin/application_settings/_note_limits.html.haml +++ b/app/views/admin/application_settings/_note_limits.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-note-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index 2d91b777a0b..bacfe056683 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-outbound-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group @@ -15,7 +15,7 @@ = f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1, xn--itlab-j1a.com", class: 'form-control gl-form-input', rows: 8 %span.form-text.text-muted = s_('OutboundRequests|Requests to these domains and IP addresses are accessible to both system hooks and web hooks even when local requests are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 and 127.0.0.0/28 are supported. Domain wildcards are not supported. To separate entries use commas, semicolons, or newlines. The allowlist can hold a maximum of 1000 entries. Domains must be IDNA encoded.') - = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'allowlist-for-local-requests'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'create-an-allowlist-for-local-requests'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :dns_rebinding_protection_enabled, diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index b31576b5c48..4bdfa5bfe83 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -26,7 +26,7 @@ - @plans.each_with_index do |plan, index| .tab-pane{ :id => "plan#{index}", class: index == 0 ? 'active': '' } = form_for plan.actual_limits, url: admin_plan_limits_path(anchor: 'js-package-settings'), html: { class: 'fieldset-form' }, method: :post do |f| - = form_errors(plan, pajamas_alert: true) + = form_errors(plan) %fieldset = f.hidden_field(:plan_id, value: plan.id) .form-group diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index 23b0d2d2092..cf43d3ddeca 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-pages-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group @@ -22,6 +22,13 @@ - pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-size-of-gitlab-pages-site-in-a-project') - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url } = s_('AdminSettings|Set the maximum size of GitLab Pages per project (0 for unlimited). %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe } + .form-group + = f.label :max_pages_custom_domains_per_project, s_('AdminSettings|Maximum number of custom domains per project'), class: 'label-bold' + = f.number_field :max_pages_custom_domains_per_project, class: 'form-control gl-form-input' + .form-text.text-muted + - pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-number-of-gitlab-pages-custom-domains-for-a-project') + - pages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: pages_link_url } + = s_('AdminSettings|Set the maximum number of GitLab Pages custom domains per project (0 for unlimited). %{link_start}Learn more.%{link_end}').html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe } %h5 = s_("AdminSettings|Configure Let's Encrypt") %p diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index c87d166f8d9..e0ba8d93fbd 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-performance-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index a7f73edcf69..4e37c4c3c98 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-performance-bar-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_pipeline_limits.html.haml b/app/views/admin/application_settings/_pipeline_limits.html.haml index 3b33c41a924..e93823172db 100644 --- a/app/views/admin/application_settings/_pipeline_limits.html.haml +++ b/app/views/admin/application_settings/_pipeline_limits.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-pipeline-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 8be37ff1dda..5c86ce8dbfb 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -10,7 +10,7 @@ = link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) if expanded + = form_errors(@application_setting) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index d8dffd6bc16..982531e9a2f 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -1,13 +1,13 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-prometheus-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group - prometheus_help_link_url = help_page_path('administration/monitoring/prometheus/gitlab_metrics') - prometheus_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: prometheus_help_link_url } = f.gitlab_ui_checkbox_component :prometheus_metrics_enabled, - _('Enable health and performance metrics endpoint'), - help_text: s_('AdminSettings|Enable a Prometheus endpoint that exposes health and performance statistics. The Health Check menu item appears in the Monitoring section of the Admin Area. Restart required. %{link_start}Learn more.%{link_end}').html_safe % { link_start: prometheus_help_link_start, link_end: '</a>'.html_safe } + _('Enable GitLab Prometheus metrics endpoint'), + help_text: s_('AdminSettings|Enable collection of application metrics. Restart required. %{link_start}Learn how to export metrics to Prometheus%{link_end}.').html_safe % { link_start: prometheus_help_link_start, link_end: '</a>'.html_safe } .form-text.gl-text-gray-500.gl-pl-6 - unless Gitlab::Metrics.metrics_folder_present? - icon_link = link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml index 00da0f59be4..1f3f67c71c7 100644 --- a/app/views/admin/application_settings/_protected_paths.html.haml +++ b/app/views/admin/application_settings/_protected_paths.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-protected-paths-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml index 66003f31104..6a7ec05d206 100644 --- a/app/views/admin/application_settings/_realtime.html.haml +++ b/app/views/admin/application_settings/_realtime.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-realtime-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index db4d1cb323c..856db32e088 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index 40d847f4949..ef8d3ccc8ab 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-check-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .sub-section diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 156a6bbcfa6..dad8d5f3fae 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-mirror-settings') do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml index a8e109ce377..d962d050ebc 100644 --- a/app/views/admin/application_settings/_repository_static_objects.html.haml +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index ff10e4a8f77..9e7f2812d64 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-storage-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .sub-section diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml index 7781db29bab..1d6051a06ea 100644 --- a/app/views/admin/application_settings/_runner_registrars_form.html.haml +++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-runner-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .gl-form-group diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml index 93637b59d60..945c9397f0d 100644 --- a/app/views/admin/application_settings/_search_limits.html.haml +++ b/app/views/admin/application_settings/_search_limits.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-search-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_sentry.html.haml b/app/views/admin/application_settings/_sentry.html.haml index ece8f50151a..cfd34f6ca15 100644 --- a/app/views/admin/application_settings/_sentry.html.haml +++ b/app/views/admin/application_settings/_sentry.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-sentry-settings'), html: { class: 'fieldset-form', id: 'sentry-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %span.text-muted = _('Changing any setting here requires an application restart') diff --git a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml index a28e6e62e7f..eaf4bbf4702 100644 --- a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml +++ b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-sidekiq-job-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 870bfbf4184..48f0b9b2c31 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-signin-settings'), html: { class: 'fieldset-form', id: 'signin-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index 2365daa2c70..fccf039533b 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -1,3 +1,3 @@ -= form_errors(@application_setting, pajamas_alert: true) += form_errors(@application_setting) #js-signup-form{ data: signup_form_data } diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index d500194b742..8684b909853 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -10,7 +10,7 @@ = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank', rel: 'noopener noreferrer').html_safe, link_start: link_start, link_end: '</a>'.html_safe } .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) if expanded + = form_errors(@application_setting) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index 43ff2bc02f5..9e99b496ad0 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -17,7 +17,7 @@ .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form', id: 'sourcegraph-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index 7f3125d91ba..bb512940be2 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-spam-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset %h5 diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index 5703fbb463e..c53f63e124b 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index c5387db59ef..a4b6e061c43 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form', id: 'terms-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index 397b47eefaa..20a60ac870a 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -9,7 +9,7 @@ = _('Control whether to display customer experience improvement content and third-party offers in GitLab.') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) if expanded + = form_errors(@application_setting) if expanded %fieldset .form-group diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 7326a63f8c2..046b59dbd18 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -3,7 +3,7 @@ - link_end = '</a>'.html_safe = gitlab_ui_form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_users_api_limits.html.haml b/app/views/admin/application_settings/_users_api_limits.html.haml index f2edb81141d..3918c76b12c 100644 --- a/app/views/admin/application_settings/_users_api_limits.html.haml +++ b/app/views/admin/application_settings/_users_api_limits.html.haml @@ -1,5 +1,5 @@ = form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-users-api-limits-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index d35fba7d3b2..b69b2f74d0d 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset = render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection') diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml index d82bb1c94e4..3248969ca16 100644 --- a/app/views/admin/application_settings/_whats_new.html.haml +++ b/app/views/admin/application_settings/_whats_new.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-whats-new-settings'), html: { class: 'fieldset-form whats-new-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) - whats_new_variants.each_key do |variant| .gl-mb-4 diff --git a/app/views/admin/application_settings/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 349e1dfde5d..a3bd8b52148 100644 --- a/app/views/admin/application_settings/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -1,7 +1,7 @@ - parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe = gitlab_ui_form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f| - = form_errors(@appearance, pajamas_alert: true) + = form_errors(@appearance) .row diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index d7559fcd48b..cd63873a893 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -13,7 +13,7 @@ .settings-content = render 'visibility_and_access' -%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content' } } +%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content', testid: 'account-limit' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Account and limit') @@ -94,7 +94,7 @@ = _('Manage Web IDE features.') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: "js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f| - = form_errors(@application_setting, pajamas_alert: true) + = form_errors(@application_setting) %fieldset .form-group diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index d4476bf838a..b79b189e9cf 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -11,7 +11,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Monitor the health and performance of GitLab with Prometheus.') + = _('Monitor GitLab with Prometheus.') .settings-content = render 'prometheus' diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index e0926221bcc..fd73d4c5671 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for [:admin, @application], url: @url, html: {role: 'form'} do |f| - = form_errors(application, pajamas_alert: true) + = form_errors(application) = content_tag :div, class: 'form-group row' do .col-sm-2.col-form-label diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml index c8b195219ec..0f76fdce416 100644 --- a/app/views/admin/background_migrations/index.html.haml +++ b/app/views/admin/background_migrations/index.html.haml @@ -5,7 +5,7 @@ .gl-flex-grow-1 %h3= s_('BackgroundMigrations|Background Migrations') %p.light.gl-mb-0 - - learnmore_link = help_page_path('development/database/batched_background_migrations') + - learnmore_link = help_page_path('user/admin_area/monitoring/background_migrations') - learnmore_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: learnmore_link } = html_escape(s_('BackgroundMigrations|Background migrations are used to perform data migrations whenever a migration exceeds the time limits in our guidelines. %{linkStart}Learn more%{linkEnd}')) % { linkStart: learnmore_link_start, linkEnd: '</a>'.html_safe } diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 865b60a74b8..dfd3b87c674 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -2,7 +2,7 @@ = render 'preview' = gitlab_ui_form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| - = form_errors(@broadcast_message, pajamas_alert: true) + = form_errors(@broadcast_message) .form-group.row.mt-4 .col-sm-2.col-form-label diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index a254690de72..69e9e4260b4 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -1,42 +1,34 @@ = gitlab_ui_form_for [:admin, @group] do |f| - = form_errors(@group, pajamas_alert: true) - .gl-border-b.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 - = _('Naming, visibility') - %p - = _('Update your group name, description, avatar, and visibility.') - = link_to _('Learn more about groups.'), help_page_path('user/group/index') - .col-lg-8 - = render 'shared/groups/group_name_and_path_fields', f: f - = render 'shared/group_form_description', f: f - .form-group.gl-form-group{ role: 'group' } - = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label' - = render 'shared/choose_avatar_button', f: f - = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false + = form_errors(@group) + = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-mb-6' }) do |c| + = c.title { _('Naming, visibility') } + = c.description do + = _('Update your group name, description, avatar, and visibility.') + = link_to _('Learn more about groups.'), help_page_path('user/group/index') + = c.body do + = render 'shared/groups/group_name_and_path_fields', f: f + = render 'shared/group_form_description', f: f + .form-group.gl-form-group{ role: 'group' } + = f.label :avatar, _("Group avatar"), class: 'gl-display-block col-form-label' + = render 'shared/choose_avatar_button', f: f + = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 - = _('Permissions and group features') - %p - = _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.') - .col-lg-8 - = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group - = render_if_exists 'admin/namespace_plan', f: f - .form-group.gl-form-group{ role: 'group' } - = render 'shared/allow_request_access', form: f - = render 'groups/group_admin_settings', f: f - = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f - .gl-mb-3 - .row - .col-lg-4 - %h4.gl-mt-0 - = _('Admin notes') - .col-lg-8 - = render 'shared/admin/admin_note_form', f: f + = render ::Layouts::HorizontalSectionComponent.new(options: { class: 'gl-pb-3 gl-mb-6' }) do |c| + = c.title { _('Permissions and group features') } + = c.description do + = _('Configure advanced permissions, Large File Storage, two-factor authentication, and CI/CD settings.') + = c.body do + = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group + = render_if_exists 'admin/namespace_plan', f: f + .form-group.gl-form-group{ role: 'group' } + = render 'shared/allow_request_access', form: f + = render 'groups/group_admin_settings', f: f + = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f + + = render ::Layouts::HorizontalSectionComponent.new(border: false, options: { class: 'gl-pb-3' }) do |c| + = c.title { _('Admin notes') } + = c.body do + = render 'shared/admin/admin_note_form', f: f - if @group.new_record? = render Pajamas::AlertComponent.new(dismissible: false) do |c| diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index cf3b6e6e0e0..a309e874317 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -1,4 +1,4 @@ -= form_errors(hook, pajamas_alert: true) += form_errors(hook) .form-group = form.label :url, _('URL'), class: 'label-bold' diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 40c4d292e9d..ba7687db9c7 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -1,5 +1,5 @@ = form_for [:admin, @user, @identity], html: { class: 'fieldset-form' } do |f| - = form_errors(@identity, pajamas_alert: true) + = form_errors(@identity) .form-group.row .col-sm-2.col-form-label diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 6921c051361..eabb7e51227 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -23,119 +23,120 @@ = last_check_message.html_safe .row .col-md-6 - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c| + - c.header do = _('Project info:') - %ul.content-list - %li - %span.light - = _('Name:') - %strong - = link_to @project.name, project_path(@project) - %li - %span.light - = _('Namespace:') - %strong - - if @project.namespace - = link_to @project.namespace.human_name, [:admin, @project.personal? ? @project.namespace.owner : @project.group] - - else - = s_('ProjectSettings|Global') - %li - %span.light - = _('Owned by:') - %strong - - if @project.owners.any? - = safe_join(@project.owners.map { |owner| link_to(owner.name, [:admin, owner]) }, ", ".html_safe) - - else - = _('(deleted)') - - %li - %span.light - = _('Created by:') - %strong - = @project.creator.try(:name) || _('(deleted)') - - %li - %span.light - = _('Created on:') - %strong - = @project.created_at.to_s(:medium) - - %li - %span.light - = _('ID:') - %strong - = @project.id - - %li - %span.light - = _('http:') - %strong - = link_to @project.http_url_to_repo, project_path(@project) - %li - %span.light - = _('ssh:') - %strong - = link_to @project.ssh_url_to_repo, project_path(@project) - - if @project.repository.exists? - %li + - c.body do + %ul.content-list + %li{ class: 'gl-px-5!' } %span.light - = _('Gitaly storage name:') + = _('Name:') %strong - = @project.repository.storage - %li + = link_to @project.name, project_path(@project) + %li{ class: 'gl-px-5!' } %span.light - = _('Gitaly relative path:') + = _('Namespace:') %strong - = @project.repository.relative_path - - %li - = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics - - %li + - if @project.namespace + = link_to @project.namespace.human_name, [:admin, @project.personal? ? @project.namespace.owner : @project.group] + - else + = s_('ProjectSettings|Global') + %li{ class: 'gl-px-5!' } %span.light - = _('last commit:') + = _('Owned by:') %strong - = last_commit(@project) + - if @project.owners.any? + = safe_join(@project.owners.map { |owner| link_to(owner.name, [:admin, owner]) }, ", ".html_safe) + - else + = _('(deleted)') - %li + %li{ class: 'gl-px-5!' } %span.light - = _('Git LFS status:') + = _('Created by:') %strong - = project_lfs_status(@project) - = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index') - - else - %li - %span.light - = _('repository:') - %strong.cred - = _('does not exist') + = @project.creator.try(:name) || _('(deleted)') - - if @project.archived? - %li + %li{ class: 'gl-px-5!' } %span.light - = _('archived:') + = _('Created on:') %strong - = _('project is read-only') + = @project.created_at.to_s(:medium) - = render_if_exists "shared_runner_status", project: @project + %li{ class: 'gl-px-5!' } + %span.light + = _('ID:') + %strong + = @project.id - %li - %span.light - = _('access:') - %strong - %span{ class: visibility_level_color(@project.visibility_level) } - = visibility_level_icon(@project.visibility_level) - = visibility_level_label(@project.visibility_level) + %li{ class: 'gl-px-5!' } + %span.light + = _('http:') + %strong + = link_to @project.http_url_to_repo, project_path(@project) + %li{ class: 'gl-px-5!' } + %span.light + = _('ssh:') + %strong + = link_to @project.ssh_url_to_repo, project_path(@project) + - if @project.repository.exists? + %li{ class: 'gl-px-5!' } + %span.light + = _('Gitaly storage name:') + %strong + = @project.repository.storage + %li{ class: 'gl-px-5!' } + %span.light + = _('Gitaly relative path:') + %strong + = @project.repository.relative_path + + %li{ class: 'gl-px-5!' } + = render 'shared/storage_counter_statistics', storage_size: @project.statistics&.storage_size, storage_details: @project.statistics + + %li{ class: 'gl-px-5!' } + %span.light + = _('last commit:') + %strong + = last_commit(@project) + + %li{ class: 'gl-px-5!' } + %span.light + = _('Git LFS status:') + %strong + = project_lfs_status(@project) + = link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index') + - else + %li{ class: 'gl-px-5!' } + %span.light + = _('repository:') + %strong.cred + = _('does not exist') + + - if @project.archived? + %li{ class: 'gl-px-5!' } + %span.light + = _('archived:') + %strong + = _('project is read-only') + + = render_if_exists "admin/projects/shared_runner_status", project: @project + + %li{ class: 'gl-px-5!' } + %span.light + = _('access:') + %strong + %span{ class: visibility_level_color(@project.visibility_level) } + = visibility_level_icon(@project.visibility_level) + = visibility_level_label(@project.visibility_level) = render 'shared/custom_attributes', custom_attributes: @project.custom_attributes = render_if_exists 'admin/projects/geo_status_widget', locals: { project: @project } - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c| + - c.header do = s_('ProjectSettings|Transfer project') - .card-body + - c.body do = form_for @project, url: transfer_admin_project_path(@project), method: :put do |f| .form-group.row .col-sm-3.col-form-label @@ -150,10 +151,10 @@ .offset-sm-3.col-sm-9 = f.submit _('Transfer'), class: 'gl-button btn btn-confirm' - .card.repository-check - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5 repository-check' }) do |c| + - c.header do = _("Repository check") - .card-body + - c.body do = form_for @project, url: repository_check_admin_project_path(@project), method: :post do |f| .form-group - if @project.last_repository_check_at.nil? @@ -172,34 +173,36 @@ .col-md-6 - if @group - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }, footer_options: { class: 'gl-p-4' }) do |c| + - c.header do %strong= @group.name = _('group members') = gl_badge_tag @group_members.size = render 'shared/members/manage_access_button', path: group_group_members_path(@group) - %ul.content-list.members-list - = render partial: 'shared/members/member', - collection: @group_members, as: :member, - locals: { membership_source: @project, - group: @group, - current_user_is_group_owner: current_user_is_group_owner } - .card-footer + - c.body do + %ul.content-list.members-list + = render partial: 'shared/members/member', + collection: @group_members, as: :member, + locals: { membership_source: @project, + group: @group, + current_user_is_group_owner: current_user_is_group_owner } + - c.footer do = paginate @group_members, param_name: 'group_members_page', theme: 'gitlab' = render 'shared/members/requests', membership_source: @project, group: @group, requesters: @requesters - .card - .card-header + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }, footer_options: { class: 'gl-p-4' }) do |c| + - c.header do %strong= @project.name = _('project members') = gl_badge_tag @project.users.size = render 'shared/members/manage_access_button', path: project_project_members_path(@project) - %ul.content-list.project_members.members-list - = render partial: 'shared/members/member', - collection: @project_members, as: :member, - locals: { membership_source: @project, - group: @group, - current_user_is_group_owner: current_user_is_group_owner } - .card-footer + - c.body do + %ul.content-list.project_members.members-list + = render partial: 'shared/members/member', + collection: @project_members, as: :member, + locals: { membership_source: @project, + group: @group, + current_user_is_group_owner: current_user_is_group_owner } + - c.footer do = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 65eb1358b40..b755b4a442c 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -1,7 +1,7 @@ = form_tag(admin_session_path, method: :post, class: 'new_user gl-show-field-errors', 'aria-live': 'assertive') do .form-group = label_tag :user_password, _('Password'), class: 'label-bold' - = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } + = password_field_tag 'user[password]', nil, class: 'form-control', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field', testid: 'password-field' } .submit-container.move-submit-down = submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml index 9372bae14c3..c7382266480 100644 --- a/app/views/admin/sessions/_signin_box.html.haml +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -4,8 +4,6 @@ .login-body = render 'devise/sessions/new_crowd' - = render_if_exists 'devise/sessions/new_kerberos_tab' - - ldap_servers.each_with_index do |server, i| .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) } .login-body diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index cd6df5f30f3..2d0ea585735 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -26,11 +26,13 @@ = link_to _('Remove user'), admin_spam_log_path(spam_log, remove_user: true), data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') }, method: :delete, class: "gl-button btn btn-sm btn-danger" %td - - if spam_log.submitted_as_ham? - .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3 - = _("Submitted as ham") - - else - = link_to _('Submit as ham'), mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-default btn-sm gl-mb-3' + -# TODO: Remove conditonal once spamcheck supports this https://gitlab.com/gitlab-com/gl-security/engineering-and-research/automation-team/spam/spamcheck/-/issues/190 + - if akismet_enabled? + - if spam_log.submitted_as_ham? + .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3 + = _("Submitted as ham") + - else + = link_to _('Submit as ham'), mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-default btn-sm gl-mb-3' - if user && !user.blocked? = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-sm gl-mb-3" - else diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index 1c1bc61aef2..9b9d97950cc 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @topic, url: url, html: { multipart: true, class: 'js-project-topic-form gl-show-field-errors common-note-form js-quick-submit js-requires-input' }, authenticity_token: true do |f| - = form_errors(@topic, pajamas_alert: true) + = form_errors(@topic) .form-group = f.label :name do diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml index 6485b8aa411..77823ed7058 100644 --- a/app/views/admin/topics/index.html.haml +++ b/app/views/admin/topics/index.html.haml @@ -1,16 +1,16 @@ - page_title _("Topics") -= form_tag admin_topics_path, method: :get do |f| - .gl-py-3.gl-display-flex.gl-flex-direction-column-reverse.gl-md-flex-direction-row.gl-border-b-solid.gl-border-gray-100.gl-border-b-1 - .gl-flex-grow-1.gl-mt-3.gl-md-mt-0 - .inline.gl-w-full.gl-md-w-auto - - search = params.fetch(:search, nil) - .search-field-holder - = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' } - = sprite_icon('search', css_class: 'search-icon') - .nav-controls - = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do - = _('New topic') +.top-area + .nav-controls.gl-w-full.gl-mt-3.gl-mb-3 + = form_tag admin_topics_path, method: :get do |f| + - search = params.fetch(:search, nil) + .search-field-holder + = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' } + = sprite_icon('search', css_class: 'search-icon') + .gl-flex-grow-1 + .js-merge-topics{ data: { path: merge_admin_topics_path } } + = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do + = _('New topic') %ul.content-list = render partial: 'topic', collection: @topics diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 5ac15694922..47a761e608f 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -1,6 +1,6 @@ .user_new = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| - = form_errors(@user, pajamas_alert: true) + = form_errors(@user) .gl-border-b.gl-pb-3.gl-mb-6 .row diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 6cf414dc648..6ed46847482 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -1,7 +1,7 @@ - api_awards_path = local_assigns.fetch(:api_awards_path, nil) - if api_awards_path - .gl-display-flex.gl-flex-wrap + .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between #js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } } = yield - else diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml index e0f5a984529..b6d6dcdd7a9 100644 --- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml +++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml @@ -1,3 +1,3 @@ = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-details-form' } do |field| - = form_errors(@cluster, pajamas_alert: true) + = form_errors(@cluster) #js-cluster-details-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) } diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index ed6cecdcc3d..4edb0f324dc 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -2,7 +2,7 @@ = render 'shared/event_filter' .controls = link_to dashboard_projects_path(rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip', title: 'Subscribe' do - = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') + = sprite_icon('rss', css_class: 'gl-icon') .content_list .loading diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index 39fbd9bc097..bc8e3e6ab69 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -9,7 +9,7 @@ - if current_user .page-title-controls = render 'shared/new_project_item_select', - path: '-/milestones/new', label: 'New milestone', + path: '-/milestones/new', label: _('Milestone'), include_groups: true, type: :milestones - if @milestone_states.any? { |name, count| count > 0 } @@ -23,7 +23,7 @@ - if current_user .page-title-controls = render 'shared/new_project_item_select', - path: '-/milestones/new', label: 'New milestone', + path: '-/milestones/new', label: _('Milestone'), include_groups: true, type: :milestones - else .milestones @@ -36,5 +36,5 @@ - if current_user .page-title-controls = render 'shared/new_project_item_select', - path: '-/milestones/new', label: 'New milestone', + path: '-/milestones/new', label: _('Milestone'), include_groups: true, type: :milestones diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index 0658d548eab..a9a34af3f96 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -11,14 +11,9 @@ %p = _('Projects are where you store your code, access issues, wiki and other features of GitLab.') - else - .blank-state.gl-display-flex.gl-align-items-center.gl-border-1.gl-border-solid.gl-border-gray-100.gl-rounded-base.gl-mb-5 - .blank-state-icon - = custom_icon("add_new_project", size: 50) - .blank-state-body.gl-sm-pl-0.gl-pl-6 - %h3.gl-font-size-h2.gl-mt-0 - = _('Create a project') - %p - = _('If you are added to a project, it will be displayed here.') + = render Pajamas::AlertComponent.new(variant: :info, alert_options: { class: 'gl-mb-5 gl-w-full' }) do |c| + = c.body do + = _("You see projects here when you're added to a group or project.").html_safe - if current_user.can_create_group? = link_to new_group_path, class: link_classes do diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 8d82116bf10..b4668b1e52a 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,4 +1,4 @@ -%li.todo{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } } +%li.todo.gl-hover-border-blue-200.gl-hover-bg-blue-50.gl-hover-cursor-pointer{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } } .gl-display-flex.gl-flex-direction-row .todo-avatar.gl-display-none.gl-sm-display-inline-block = author_avatar(todo, size: 40) @@ -49,13 +49,13 @@ .todo-actions.gl-ml-3 - if todo.pending? - = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do + = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button gl-bg-gray-50 btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do = gl_loading_icon(inline: true) = _('Done') - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do + = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button gl-bg-gray-50 btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do = gl_loading_icon(inline: true) = _('Undo') - else - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do + = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button gl-bg-gray-50 btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do = gl_loading_icon(inline: true) = _('Add a to do') diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 6bfe18fd3b2..deb1ac9e360 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -93,7 +93,7 @@ .text-content.gl-text-center - if todos_filter_empty? %h4 - = Gitlab.config.gitlab.no_todos_messages.sample + = no_todos_messages.sample %p = (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '<strong>', strongEnd: '</strong>', openIssuesLinkStart: "<a href=\"#{issues_dashboard_path}\">", openIssuesLinkEnd: '</a>', mergeRequestLinkStart: "<a href=\"#{merge_requests_dashboard_path}\">", mergeRequestLinkEnd: '</a>' }).html_safe - else diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 5a322a8f89b..3aeb89979bb 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,10 +1,10 @@ -= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-sign-in-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| += gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f| .form-group.gl-px-5.gl-pt-5 = render_if_exists 'devise/sessions/new_base_user_login_label', form: f = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' } .form-group.gl-px-5 = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}" - = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } + = f.password_field :password, class: 'form-control gl-form-input bottom', autocomplete: 'current-password', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field', testid: 'password-field' } - if devise_mapping.rememberable? .gl-px-5 .gl-display-inline-block diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index f4db9ea5637..e0e0b82b596 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -17,10 +17,15 @@ %div = _('No authentication methods configured.') + - if Feature.enabled?(:restyle_login_page, @project) + %p.gl-px-5 + = html_escape(s_("SignUp|By signing in you accept the %{link_start}Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}.")) % { link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, + link_end: '</a>'.html_safe } + - if allow_signup? %p{ class: "gl-mt-3 #{'gl-text-center' if Feature.enabled?(:restyle_login_page, @project)}" } = _("Don't have an account yet?") - = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }, class: "#{'gl-font-weight-bold' if Feature.enabled?(:restyle_login_page, @project)} " + = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' } - if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled? .clearfix = render 'devise/shared/omniauth_box' diff --git a/app/views/devise/sessions/successful_verification.haml b/app/views/devise/sessions/successful_verification.haml index 8af80fbdceb..59280cc13ca 100644 --- a/app/views/devise/sessions/successful_verification.haml +++ b/app/views/devise/sessions/successful_verification.haml @@ -8,4 +8,4 @@ %p.gl-pt-2 - redirect_url_start = '<a href="%{url}"">'.html_safe % { url: @redirect_url } - redirect_url_end = '</a>'.html_safe - = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment or %{redirect_url_start}click here%{redirect_url_end} to refresh.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end } + = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment. You can also %{redirect_url_start}refresh the page%{redirect_url_end}.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end } diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index d67669352a6..d4f34a1cb3f 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -1,19 +1,20 @@ - hide_remember_me = local_assigns.fetch(:hide_remember_me, false) -%div{ class: Feature.enabled?(:restyle_login_page, @project) ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' } - %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : 'gl-font-weight-bold' } +- restyle_login_page_enabled = Feature.enabled?(:restyle_login_page, @project) +%div{ class: restyle_login_page_enabled ? 'omniauth-container gl-mt-5 gl-p-5 gl-text-center gl-w-90p gl-ml-auto gl-mr-auto' : 'omniauth-container gl-mt-5 gl-p-5' } + %label{ class: restyle_login_page_enabled ? 'gl-font-weight-normal' : 'gl-font-weight-bold' } = _('Sign in with') - providers = enabled_button_based_providers - .gl-display-flex.gl-justify-content-between.gl-flex-wrap + .gl-display-flex.gl-flex-wrap{ class: restyle_login_page_enabled ? 'gl-justify-content-center' : 'gl-justify-content-between' } - providers.each do |provider| - has_icon = provider_has_icon?(provider) - = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' if Feature.disabled?(:restyle_login_page, @project)}", form: { class: 'gl-w-full gl-mb-3' } do + = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)} #{'gl-w-full' unless restyle_login_page_enabled}", form: { class: restyle_login_page_enabled ? 'gl-mb-3' : 'gl-w-full gl-mb-3' } do - if has_icon = provider_image_tag(provider) %span.gl-button-text = label_for_provider(provider) - unless hide_remember_me %fieldset - %label{ class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-font-weight-normal' : '' } + %label{ class: restyle_login_page_enabled ? 'gl-font-weight-normal' : '' } = check_box_tag :remember_me, nil, false %span = _('Remember me') diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index ff93449194a..60f1ff02e76 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -4,8 +4,6 @@ .login-body = render 'devise/sessions/new_crowd' - = render_if_exists 'devise/sessions/new_kerberos_tab' - - ldap_servers.each_with_index do |server, i| .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) } .login-body diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 991af1eea0c..b9c9c99bf1a 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -6,7 +6,7 @@ - if show_omniauth_providers && omniauth_providers_placement == :top = render 'devise/shared/signup_omniauth_providers_top' - = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| + = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f| .devise-errors = render 'devise/shared/error_messages', resource: resource - if Gitlab::CurrentSettings.invisible_captcha_enabled @@ -66,8 +66,12 @@ = render_if_exists 'shared/password_requirements_list' = render_if_exists 'devise/shared/phone_verification', form: f %div - - if show_recaptcha_sign_up? + + - if Feature.enabled?(:arkose_labs_signup_challenge) + = render_if_exists 'devise/registrations/arkose_labs' + - elsif show_recaptcha_sign_up? = recaptcha_tags nonce: content_security_policy_nonce + .submit-container.gl-mt-5 = f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' } - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project) diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index 8dc22674243..5c085555872 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -2,7 +2,7 @@ - if Feature.enabled?(:restyle_login_page, @project) .gl-text-center.gl-pt-5 %label.gl-font-weight-normal - = _("Create an account using:") + = _("Register with:") .gl-text-center.gl-w-90p.gl-ml-auto.gl-mr-auto - providers.each do |provider| = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-ml-2 gl-mr-2 gl-mb-2 js-oauth-login #{qa_class_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do diff --git a/app/views/devise/shared/_tab_single.html.haml b/app/views/devise/shared/_tab_single.html.haml index 336954d00b0..b7ba8870df5 100644 --- a/app/views/devise/shared/_tab_single.html.haml +++ b/app/views/devise/shared/_tab_single.html.haml @@ -1,2 +1,2 @@ = gl_tabs_nav({ class: 'new-session-tabs gl-border-0' }) do - = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1', data: { qa_selector: 'sign_in_tab' } } + = gl_tab_link_to tab_title, '#', { item_active: true, class: 'gl-cursor-default!', tab_class: 'gl-bg-transparent!', tabindex: '-1', data: { qa_selector: 'sign_in_tab', testid: 'sign-in-tab' } } diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index 0ef4a30d820..e81a5928983 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,14 +1,14 @@ - show_password_form = local_assigns.fetch(:show_password_form, password_authentication_enabled_for_web?) - render_signup_link = local_assigns.fetch(:render_signup_link, true) -%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) } +%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: "#{"custom-provider-tabs" if any_form_based_providers_enabled?} #{"nav-links-unboxed" if Feature.enabled?(:restyle_login_page, @project)}" } - if crowd_enabled? %li.nav-item = link_to _("Crowd"), "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab', role: 'tab' = render_if_exists "devise/shared/kerberos_tab" - ldap_servers.each_with_index do |server, i| %li.nav-item - = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' }, role: 'tab' + = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab', testid: 'ldap-tab' }, role: 'tab' = render_if_exists 'devise/shared/tab_smartcard' diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml index 1c6dc1f2d5d..c19d64e789d 100644 --- a/app/views/devise/shared/_terms_of_service_notice.html.haml +++ b/app/views/devise/shared/_terms_of_service_notice.html.haml @@ -1,9 +1,17 @@ - return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms? %p.gl-text-gray-500.gl-mt-5.gl-mb-0 - - if Gitlab.com? - = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, - link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } + - if Feature.enabled?(:restyle_login_page, @project) + - if Gitlab.com? + = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the GitLab%{link_start} Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}")) % { button_text: button_text, + link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } + - else + = html_escape(s_("SignUp|By clicking %{button_text} or registering through a third party you accept the%{link_start} Terms of Use and acknowledge the Privacy Policy and Cookie Policy%{link_end}")) % { button_text: button_text, + link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } - else - = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, - link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } + - if Gitlab.com? + = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, + link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } + - else + = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, + link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 757c0a836f3..0d6c3e74ce8 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -2,7 +2,7 @@ = render 'shared/event_filter', show_group_events: @group.supports_events? .controls = link_to group_path(@group, rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' , title: _('Subscribe') do - = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') + = sprite_icon('rss', css_class: 'gl-icon') .content_list .loading diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 2911e9991f2..a82a2e41508 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -3,17 +3,17 @@ - emails_disabled = @group.emails_disabled? .group-home-panel - .row.mb-3 + .row.my-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' } = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' } + %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2{ itemprop: 'name' } = @group.name - %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } + %span.visibility-icon.gl-text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) - .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' } + .home-panel-metadata.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'group_id_content' }, itemprop: 'identifier' } - if can?(current_user, :read_group, @group) %span.gl-display-inline-block.gl-vertical-align-middle = s_("GroupPage|Group ID: %{group_id}") % { group_id: @group.id } diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index 632884051f0..94b0b018084 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -1,18 +1,18 @@ - parent = @group.parent - submit_label = parent ? s_('GroupsNew|Create subgroup') : s_('GroupsNew|Create group') -= form_errors(@group, pajamas_alert: true) += form_errors(@group) = render 'shared/groups/group_name_and_path_fields', f: f, autofocus: true, new_subgroup: !!parent -- unless parent - .row - .form-group.gl-form-group.col-sm-12 - %label.label-bold - = _('Visibility level') - %p - = _('Who will be able to see this group?') - = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer' - = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false +.row + .form-group.gl-form-group.col-sm-12 + %label.label-bold + = _('Visibility level') + %p + = _('Who will be able to see this group?') + = link_to _('View the documentation'), help_page_path("user/public_access"), target: '_blank', rel: 'noopener noreferrer' + = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false +- unless parent - if Gitlab.config.mattermost.enabled .row = render 'create_chat_team', f: f diff --git a/app/views/groups/crm/organizations/index.html.haml b/app/views/groups/crm/organizations/index.html.haml index ff1ba678de0..f7702889eef 100644 --- a/app/views/groups/crm/organizations/index.html.haml +++ b/app/views/groups/crm/organizations/index.html.haml @@ -5,4 +5,4 @@ = content_for :after_content do #js-crm-form-portal -#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } } +#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group), text_query: params[:search] } } diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml index a8a52b2aba7..59ad29ccabd 100644 --- a/app/views/groups/harbor/repositories/index.html.haml +++ b/app/views/groups/harbor/repositories/index.html.haml @@ -4,8 +4,9 @@ #js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - "registry_host_url_with_port" => 'demo.harbor.com', + "repository_url" => @group.harbor_integration.hostname, + "harbor_integration_project_name" => @group.harbor_integration.project_name, + full_path: @group.full_path, connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, is_group_page: true.to_s } } diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 9f13ad301bb..3864b30eb1e 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| - = form_errors(@milestone, pajamas_alert: true) + = form_errors(@milestone) .form-group.row .col-form-label.col-sm-2 = f.label :title, _("Title") diff --git a/app/views/groups/observability/index.html.haml b/app/views/groups/observability/index.html.haml new file mode 100644 index 00000000000..582651c329b --- /dev/null +++ b/app/views/groups/observability/index.html.haml @@ -0,0 +1,2 @@ +- page_title _("Observability") +%iframe{ id: 'observability-ui-iframe', src: @observability_iframe_src, frameborder: 0, width: "100%", height: "100%" } diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml index c5999317597..4bd550eaa47 100644 --- a/app/views/groups/runners/edit.html.haml +++ b/app/views/groups/runners/edit.html.haml @@ -1,14 +1,7 @@ +- runner_name = "##{@runner.id} (#{@runner.short_sha})" - breadcrumb_title _('Edit') -- page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})" - +- page_title _('Edit'), runner_name - add_to_breadcrumbs _('Runners'), group_runners_path(@group) -- add_to_breadcrumbs "#{@runner.short_sha}", group_runner_path(@group, @runner) - - -%h1.page-title.gl-font-size-h-display - = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) - = render 'shared/runners/runner_type_badge', runner: @runner - -= render 'shared/runners/runner_type_alert', runner: @runner +- add_to_breadcrumbs runner_name, group_runner_path(@group, @runner) -= render 'shared/runners/form', runner: @runner, runner_form_url: group_runner_path(@group, @runner) +#js-group-runner-edit{ data: {runner_id: @runner.id, runner_path: group_runner_path(@group, @runner) } } diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml index a67a4f28c93..1146063969b 100644 --- a/app/views/groups/runners/index.html.haml +++ b/app/views/groups/runners/index.html.haml @@ -1,3 +1,3 @@ - page_title s_('Runners|Runners') -#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count } ) } +#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count, registration_token: @group_runner_registration_token } ) } diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 8fa8eeea3cd..21b1986bd34 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -4,7 +4,7 @@ .sub-section %h4.warning-title= s_('GroupSettings|Change group URL') = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| - = form_errors(@group, pajamas_alert: true) + = form_errors(@group) .form-group %p = s_("GroupSettings|Changing a group's URL can have unintended side effects.") diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 527791dfc04..be9d2c45885 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -1,6 +1,6 @@ = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' } - = form_errors(@group, pajamas_alert: true) + = form_errors(@group) %fieldset .row diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index a60ab43f566..e35c0341ec0 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -1,6 +1,6 @@ = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-permissions-form' }, authenticity_token: true do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-permissions-settings' } - = form_errors(@group, pajamas_alert: true) + = form_errors(@group) %fieldset %h5= _('Permissions') diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml index 3691c470ea7..a55ccd94974 100644 --- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f| - = form_errors(group, pajamas_alert: true) + = form_errors(group) %fieldset .form-group .card.gl-mb-3 diff --git a/app/views/groups/settings/packages_and_registries/show.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml index 888419e463a..2861e696e31 100644 --- a/app/views/groups/settings/packages_and_registries/show.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _('Packages & Registries') -- page_title _('Packages & Registries') +- breadcrumb_title _('Package and registry settings') +- page_title _('Package and registry settings') - @content_class = 'limit-container-width' unless fluid_layout %section#js-packages-and-registries-settings{ data: { group_path: @group.full_path, diff --git a/app/views/groups/settings/repository/_default_branch.html.haml b/app/views/groups/settings/repository/_default_branch.html.haml index cae33820a05..844a5f890a4 100644 --- a/app/views/groups/settings/repository/_default_branch.html.haml +++ b/app/views/groups/settings/repository/_default_branch.html.haml @@ -8,7 +8,7 @@ = s_('GroupSettings|Set the initial name and protections for the default branch of new repositories created in the group.') .settings-content = gitlab_ui_form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| - = form_errors(@group, pajamas_alert: true) + = form_errors(@group) - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>" %fieldset diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml index d3b9117c05b..a15652b3179 100644 --- a/app/views/groups/settings/repository/show.html.haml +++ b/app/views/groups/settings/repository/show.html.haml @@ -2,7 +2,11 @@ - page_title _('Repository') - @content_class = "limit-container-width" unless fluid_layout -- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.') +- if can?(current_user, :admin_group, @group) + - deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.') -= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description -= render "default_branch", group: @group + = render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description + = render "default_branch", group: @group + +- if can?(current_user, :change_push_rules, @group) + = render "push_rules" diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index d8da77dc5cc..f474f8fbd3b 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -33,33 +33,36 @@ = render_if_exists 'groups/group_activity_analytics', group: @group -.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } - .top-area.group-nav-container.justify-content-between - .scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) - -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js` - -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466 - = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do - = gl_tab_link_to group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do - = _("Subgroups and projects") - = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do - = _("Shared projects") - = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do - = _("Archived projects") +- if Feature.enabled?(:group_overview_tabs_vue, @group) + #js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) } +- else + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } + .top-area.group-nav-container.justify-content-between + .scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js` + -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466 + = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do + = gl_tab_link_to group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do + = _("Subgroups and projects") + = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do + = _("Shared projects") + = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do + = _("Archived projects") - .nav-controls.d-block.d-md-flex - .group-search - = render "shared/groups/search_form" + .nav-controls.d-block.d-md-flex + .group-search + = render "shared/groups/search_form" - = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash + = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash - .tab-content - #subgroups_and_projects.tab-pane - = render "subgroups_and_projects", group: @group + .tab-content + #subgroups_and_projects.tab-pane + = render "subgroups_and_projects", group: @group - #shared.tab-pane - = render "shared_projects", group: @group + #shared.tab-pane + = render "shared_projects", group: @group - #archived.tab-pane - = render "archived_projects", group: @group + #archived.tab-pane + = render "archived_projects", group: @group diff --git a/app/views/help/drawers.html.haml b/app/views/help/drawers.html.haml new file mode 100644 index 00000000000..7c173eb7b07 --- /dev/null +++ b/app/views/help/drawers.html.haml @@ -0,0 +1,2 @@ += cache(@clean_path, expires_in: 1.day) do + = markdown get_markdown_without_frontmatter(@path) diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index d4ced15b869..f66aa0840aa 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -1,4 +1,4 @@ -.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) } +.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions, @current_jira_installation) } = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? = webpack_bundle_tag 'jira_connect_app' diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml index f5c823465be..97e118aba93 100644 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ b/app/views/layouts/_google_tag_manager_head.html.haml @@ -6,19 +6,20 @@ function gtag(){dataLayer.push(arguments);} gtag('consent', 'default', { - 'analytics_storage': 'denied', - 'ad_storage': 'denied', - 'functionality_storage': 'denied', - 'region': ['EU', 'UK', 'PE', 'RU'], - 'wait_for_update': 500 - }); - gtag('consent', 'default', { 'analytics_storage': 'granted', 'ad_storage': 'granted', 'functionality_storage': 'granted', 'wait_for_update': 500 }); + gtag('consent', 'default', { + 'analytics_storage': 'denied', + 'ad_storage': 'denied', + 'functionality_storage': 'denied', + 'region': ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'PE', 'RU'], + 'wait_for_update': 500 + }); + - if Feature.enabled?(:gtm_nonce, type: :ops) = javascript_tag nonce: content_security_policy_nonce do :plain diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 59d4c81358d..014e26c7613 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -27,6 +27,7 @@ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } %main.content{ id: "content-body", **page_itemtype } = render "layouts/flash", extra_flash_class: 'limit-container-width' + = yield :after_flash_content = yield :before_content = yield = yield :after_content diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 87a8b6dd870..6650e07be2a 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -18,10 +18,10 @@ = current_appearance&.title.presence || _('GitLab') - if current_appearance&.description? = brand_text + = render_if_exists 'layouts/devise_help_text' .mb-3 .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar = yield - = render_if_exists 'layouts/devise_help_text' = render 'devise/shared/footer', footer_message: footer_message diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index 2a865aeda40..61a57240ed5 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -6,12 +6,16 @@ = header_message = render partial: "layouts/header/default", locals: { project: @project, group: @group } .mobile-overlay - .alert-wrapper.hide-when-top-nav-responsive-open - = render 'shared/outdated_browser' - = render "layouts/broadcast" - = yield :flash_message - = render "layouts/flash" - .content-wrapper.hide-when-top-nav-responsive-open{ id: "content-body", class: "d-flex flex-column align-items-stretch" } - = yield + .hide-when-top-nav-responsive-open.gl--flex-full.gl-h-full{ class: nav ? ["layout-page", page_with_sidebar_class, "gl-mt-0!"]: '' } + - if defined?(nav) && nav + = render "layouts/nav/sidebar/#{nav}" + .gl--flex-full.gl-flex-direction-column.gl-w-full + .alert-wrapper + = render 'shared/outdated_browser' + = render "layouts/broadcast" + = yield :flash_message + = render "layouts/flash" + .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" } + = yield = render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto" = footer_message diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 67809cbc608..97c2b8bb7e3 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -4,9 +4,10 @@ - nav "group" - display_subscription_banner! - @left_sidebar = true +- base_layout = local_assigns[:base_layout] - content_for :flash_message do - = render "layouts/header/storage_enforcement_banner", context: @group + = dispensable_render_if_exists "groups/storage_enforcement_alert", context: @group = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @group - content_for :page_specific_javascripts do @@ -15,4 +16,4 @@ :plain window.uploads_path = "#{group_uploads_path(@group)}"; -= render template: "layouts/application" += render template: base_layout || "layouts/application" diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 353f07c07c5..00e7a0567da 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -37,7 +37,7 @@ %li.d-md-none = render 'shared/help_dropdown_forum_link' %li.d-md-none - = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" + = link_to _("Submit feedback"), Gitlab::Utils.append_path(promo_url, "submit-feedback") - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) %li.d-md-none = render 'shared/user_dropdown_contributing_link' diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 783733bb313..a00c5c186cc 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -5,15 +5,15 @@ %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid .header-content.js-header-content - .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0 + .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 .title %span.gl-sr-only GitLab = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do = brand_header_logo + .gl-display-flex.gl-align-items-center - if Gitlab.com_and_canary? - = link_to Gitlab::Saas.canary_toggle_com_url, class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer' do - = gl_badge_tag({ variant: :success, size: :sm }) do - = _('Next') + = gl_badge_tag({ variant: :success, size: :sm }, { href: Gitlab::Saas.canary_toggle_com_url, data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: 'noopener noreferrer', class: 'canary-badge' }) do + = _('Next') - if current_user .gl-display-none.gl-sm-display-block @@ -28,11 +28,25 @@ .gl-display-none.gl-sm-display-block = render "layouts/nav/top_nav" - .navbar-collapse.gl-transition-medium.collapse + - if top_nav_show_search && Feature.enabled?(:new_navbar_layout) + .navbar-collapse.gl-transition-medium.collapse.gl-mr-auto.global-search-container.hide-when-top-nav-responsive-open + - search_menu_item = top_nav_search_menu_item_attrs + %ul.nav.navbar-nav.gl-w-full.gl-align-items-center + %li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full + - unless current_controller?(:search) + - if Feature.enabled?(:new_header_search) + = render 'layouts/header_search' + - else + = render 'layouts/search' + %li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' } + = link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = sprite_icon(search_menu_item.fetch(:icon)) + + .navbar-collapse.gl-transition-medium.collapse{ class: ('global-search-container' unless Feature.enabled?(:new_navbar_layout)) } %ul.nav.navbar-nav.gl-w-full.gl-align-items-center.gl-justify-content-end - if current_user = render 'layouts/header/new_dropdown', class: 'gl-display-none gl-sm-display-block gl-white-space-nowrap gl-text-right' - - if top_nav_show_search + - if top_nav_show_search && Feature.disabled?(:new_navbar_layout) - search_menu_item = top_nav_search_menu_item_attrs %li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full - unless current_controller?(:search) @@ -116,7 +130,7 @@ = render "layouts/nav/top_nav" - e.control {} - if header_link?(:user_dropdown) - %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) } + %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) } = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name = render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group diff --git a/app/views/layouts/header/_storage_enforcement_banner.html.haml b/app/views/layouts/header/_storage_enforcement_banner.html.haml deleted file mode 100644 index 1f7060f8235..00000000000 --- a/app/views/layouts/header/_storage_enforcement_banner.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -- return unless current_user -- context = local_assigns.fetch(:context) -- banner_info = storage_enforcement_banner_info(context) -- return unless banner_info.present? - -= render Pajamas::AlertComponent.new(variant: :warning, - alert_options: { class: 'js-storage-enforcement-banner', - data: { feature_id: banner_info[:callouts_feature_name], - dismiss_endpoint: banner_info[:callouts_path], - group_id: banner_info[:namespace_id], - defer_links: "true" }}) do |c| - = c.body do - %p= banner_info[:text_paragraph_1] - %p= banner_info[:text_paragraph_2] - %p= banner_info[:text_paragraph_3] diff --git a/app/views/layouts/nav/_top_nav.html.haml b/app/views/layouts/nav/_top_nav.html.haml index 42119ddb291..aa1c462d2bf 100644 --- a/app/views/layouts/nav/_top_nav.html.haml +++ b/app/views/layouts/nav/_top_nav.html.haml @@ -1,9 +1,10 @@ - view_model = top_nav_view_model(project: @project, group: @group) -%ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } } +%ul.list-unstyled.nav.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } } %li %a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } } - = sprite_icon('hamburger', css_class: "dropdown-icon") - = view_model[:activeTitle] + = sprite_icon('hamburger') + - if view_model[:menuTitle] + .gl-ml-3= view_model[:menuTitle] .hidden - view_model[:shortcuts].each do |shortcut| diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index f3f79750643..56f333664df 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -175,7 +175,7 @@ %strong.fly-out-top-item-name = _('Kubernetes') - - if akismet_enabled? + - if anti_spam_service_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path do .nav-icon-container diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml index 1ec839ef642..1b6e78b7b3d 100644 --- a/app/views/layouts/profile.html.haml +++ b/app/views/layouts/profile.html.haml @@ -7,7 +7,7 @@ - enable_search_settings locals: { container_class: 'gl-my-5' } - content_for :flash_message do - = render "layouts/header/storage_enforcement_banner", context: current_user.namespace + = dispensable_render_if_exists "profiles/storage_enforcement_alert", context: current_user.namespace = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: current_user.namespace = render template: "layouts/application" diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 9503e874fd0..75d5e40011c 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -8,7 +8,7 @@ - @content_class = [@content_class, project_classes(@project)].compact.join(" ") - content_for :flash_message do - = render "layouts/header/storage_enforcement_banner", context: @project + = dispensable_render_if_exists "projects/storage_enforcement_alert", context: @project = dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project - content_for :project_javascripts do @@ -18,4 +18,6 @@ :plain window.uploads_path = "#{project_uploads_path(project)}"; += dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert" + = render template: "layouts/application" diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml index fc4a063f5a9..bb35bfffe46 100644 --- a/app/views/notify/_failed_builds.html.haml +++ b/app/views/notify/_failed_builds.html.haml @@ -18,6 +18,6 @@ %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #d22f57; font-weight: 500; font-size: 16px; vertical-align: middle; padding-right: 8px; line-height: 10px" } %img{ alt: "✖", height: "10", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red.gif'), style: "display: block;", width: "10" }/ %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; color: #8c8c8c; font-weight: 500; font-size: 14px; vertical-align: middle;" } - = build.stage + = build.stage_name %td{ align: "right", style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 16px 0; color: #8c8c8c; font-weight: 500; font-size: 14px;" } = render "notify/links/#{build.to_partial_path}", pipeline: pipeline, build: build diff --git a/app/views/notify/_successful_pipeline.html.haml b/app/views/notify/_successful_pipeline.html.haml index e77db14a9c5..88e0bbf6125 100644 --- a/app/views/notify/_successful_pipeline.html.haml +++ b/app/views/notify/_successful_pipeline.html.haml @@ -16,7 +16,8 @@ %table.table-info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } + = _('Project') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" } - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) @@ -26,7 +27,8 @@ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } = @project.name %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = _('Branch') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody @@ -37,7 +39,8 @@ %a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" } = @pipeline.source_ref %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = _('Commit') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody @@ -55,7 +58,8 @@ = @pipeline.git_commit_message.truncate(50) - commit = @pipeline.commit %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = s_('Notify|Commit Author') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody @@ -71,7 +75,8 @@ = commit.author_name - if commit.different_committer? %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Committed by + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = s_('Notify|Committed by') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml index 28da1182d49..0b20d4f3d3a 100644 --- a/app/views/notify/approved_merge_request_email.html.haml +++ b/app/views/notify/approved_merge_request_email.html.haml @@ -78,10 +78,11 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } %img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } - - if @merge_request.respond_to? :approvals_required - %span Merge request was approved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required}) - - else - %span Merge request was approved + %span + - if @merge_request.respond_to? :approvals_required + = s_('Notify|Merge request was approved (%{approvals}/%{required_approvals})') % { approvals: @merge_request.approvals.count, required_approvals: @merge_request.approvals_required } + - else + = s_('Notify|Merge request was approved') %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } @@ -92,12 +93,7 @@ %tr{ style: 'width:100%;' } %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" } %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } - %span{ style: "font-weight: 600;color:#333333;" } Merge request - %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference - %span was approved by - %img.avatar{ height: "24", src: avatar_icon_for_user(@approved_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/ - %a.muted{ href: user_url(@approved_by), style: "color:#333333;text-decoration:none;" } - = @approved_by.name + = s_('Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{reviewer_highlight}was approved by%{highlight_end} %{reviewer_avatar} %{reviewer_link}').html_safe % merge_request_hash_param(@merge_request, @approved_by) %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } @@ -106,7 +102,7 @@ %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }= _("Project") %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) @@ -116,7 +112,7 @@ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } = @project.name %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _("Branch") %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody @@ -127,7 +123,7 @@ %span.muted{ style: "color:#333333;text-decoration:none;" } = @merge_request.source_branch %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _("Author") %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb index c75857e96d7..da91ac67ff7 100644 --- a/app/views/notify/autodevops_disabled_email.text.erb +++ b/app/views/notify/autodevops_disabled_email.text.erb @@ -12,6 +12,6 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. <% failed.each do |build| -%> <%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %> - Stage: <%= build.stage %> + Stage: <%= build.stage_name %> Name: <%= build.name %> <% end -%> diff --git a/app/views/notify/change_in_merge_request_draft_status_email.html.haml b/app/views/notify/change_in_merge_request_draft_status_email.html.haml index 64ceb77e85c..21ea756cf06 100644 --- a/app/views/notify/change_in_merge_request_draft_status_email.html.haml +++ b/app/views/notify/change_in_merge_request_draft_status_email.html.haml @@ -1,2 +1,6 @@ -%p= html_escape(_('%{username} changed the draft status of merge request %{mr_link}')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)), - mr_link: merge_request_reference_link(@merge_request) } +- if @merge_request.draft? + %p= html_escape(_('%{username} marked merge request %{mr_link} as draft')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)), + mr_link: merge_request_reference_link(@merge_request) } +- else + %p= html_escape(_('%{username} marked merge request %{mr_link} as ready')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)), + mr_link: merge_request_reference_link(@merge_request) } diff --git a/app/views/notify/change_in_merge_request_draft_status_email.text.erb b/app/views/notify/change_in_merge_request_draft_status_email.text.erb index 4e2df2dff1d..8fe622f1e6b 100644 --- a/app/views/notify/change_in_merge_request_draft_status_email.text.erb +++ b/app/views/notify/change_in_merge_request_draft_status_email.text.erb @@ -1 +1,5 @@ -<%= "#{sanitize_name(@updated_by_user.name)} changed the draft status of merge request #{@merge_request.to_reference}" %> +<% if @merge_request.draft? %> +<%= _("#{sanitize_name(@updated_by_user.name)} marked merge request #{@merge_request.to_reference} as draft") %> +<% else %> +<%= _("#{sanitize_name(@updated_by_user.name)} marked merge request #{@merge_request.to_reference} as ready") %> +<% end %> diff --git a/app/views/notify/import_issues_csv_email.html.haml b/app/views/notify/import_issues_csv_email.html.haml index f30d2b5f078..0008085025b 100644 --- a/app/views/notify/import_issues_csv_email.html.haml +++ b/app/views/notify/import_issues_csv_email.html.haml @@ -1,18 +1,18 @@ - text_style = 'font-size:16px; text-align:center; line-height:30px;' %p{ style: text_style } - Your CSV import for project - %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none;" } - = @project.full_name - has been completed. + - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none;") + = s_('Notify|Your CSV import for project %{project_link} has been completed.').html_safe % { project_link: project_link } %p{ style: text_style } - #{pluralize(@results[:success], 'issue')} imported. + - issues = n_('%d issue', '%d issues', @results[:success]) % @results[:success] + = s_('Notify|%{issues} imported.') % { issues: issues } - if @results[:error_lines].present? %p{ style: text_style } - Errors found on line #{'number'.pluralize(@results[:error_lines].size)}: #{@results[:error_lines].join(', ')}. Please check if these lines have an issue title. + = s_('Notify|Errors found on %{singular_or_plural_line}: %{error_lines}. Please check if these lines have an issue title.') % { singular_or_plural_line: n_('line', 'lines', @results[:error_lines].size), + error_lines: @results[:error_lines].join(', ') } - if @results[:parse_error] %p{ style: text_style } - Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values. + = s_('Notify|Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.') diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml index b857705e01f..fca0dbd168a 100644 --- a/app/views/notify/new_gpg_key_email.html.haml +++ b/app/views/notify/new_gpg_key_email.html.haml @@ -1,10 +1,9 @@ %p - Hi #{sanitize_name(@user.name)}! + = s_("Notify|Hi %{user}!") % { user: sanitize_name(@user.name) } %p - A new GPG key was added to your account: + = s_("Notify|A new GPG key was added to your account:") %p - Fingerprint: - %code= @gpg_key.fingerprint + = s_("Notify|Fingerprint: %{fingerprint}").html_safe % { fingerprint: content_tag(:code, @gpg_key.fingerprint) } %p - If this key was added in error, you can remove it under - = link_to "GPG Keys", profile_gpg_keys_url + - removal_link = link_to _("GPG Keys"), profile_gpg_keys_url + = s_("Notify|If this key was added in error, you can remove it under %{removal_link}").html_safe % { removal_link: removal_link } diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml index 6b45ac265f7..3b2e36d118b 100644 --- a/app/views/notify/new_mention_in_issue_email.html.haml +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -1,4 +1,4 @@ %p - You have been mentioned in an issue. + = s_('Notify|You have been mentioned in an issue.') = render template: 'notify/new_issue_email' diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml index a28d944529f..e4588716d5c 100644 --- a/app/views/notify/new_mention_in_merge_request_email.html.haml +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -1,4 +1,4 @@ %p - You have been mentioned in merge request #{merge_request_reference_link(@merge_request)} + = (s_("Notify|You have been mentioned in merge request %{mr_link}") % { mr_link: merge_request_reference_link(@merge_request) }).html_safe = render template: 'notify/new_merge_request_email' diff --git a/app/views/notify/new_ssh_key_email.html.haml b/app/views/notify/new_ssh_key_email.html.haml index d031842be95..38c6dfae411 100644 --- a/app/views/notify/new_ssh_key_email.html.haml +++ b/app/views/notify/new_ssh_key_email.html.haml @@ -1,10 +1,4 @@ -%p - Hi #{sanitize_name(@user.name)}! -%p - A new public key was added to your account: -%p - title: - %code= @key.title -%p - If this key was added in error, you can remove it under - = link_to "SSH Keys", profile_keys_url +- name = sanitize_name(@user.name) +- key_title = html_escape(@key.title) += (s_("Notify|%{paragraph_start}Hi %{name}!%{paragraph_end} %{paragraph_start}A new public key was added to your account:%{paragraph_end} %{paragraph_start}title: %{key_title}%{paragraph_end} %{paragraph_start}If this key was added in error, you can remove it under %{removal_link}%{paragraph_end}") % { paragraph_start: '<p>'.html_safe, + paragraph_end: '</p>'.html_safe, name: name, key_title: content_tag(:code, key_title), removal_link: link_to(_("SSH Keys"), profile_keys_url) }).html_safe diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml index ec135ae994f..11660126dc2 100644 --- a/app/views/notify/new_user_email.html.haml +++ b/app/views/notify/new_user_email.html.haml @@ -1,17 +1,19 @@ %p - Hi #{sanitize_name(@user['name'])}! + = s_('Notify|Hi %{username}!') % {username: sanitize_name(@user['name'])} %p - if Gitlab::CurrentSettings.allow_signup? - Your account has been created successfully. + = s_('Notify|Your account has been created successfully.') - else - The Administrator created an account for you. Now you are a member of the company GitLab application. + = s_('Notify|The Administrator created an account for you. Now you are a member of the company GitLab application.') %p - login.......................................... + = s_('Notify|login..........................................') %code= @user['email'] - if @user.created_by_id %p - = link_to "Click here to set your password", edit_password_url(@user, reset_password_token: @token) + = link_to s_('Notify|Click here to set your password'), edit_password_url(@user, reset_password_token: @token) %p - This link is valid for #{password_reset_token_valid_time}. - After it expires, you can #{link_to("request a new one", new_user_password_url(user_email: @user.email))}. + = s_('Notify|This link is valid for %{password_reset_token_valid_time}.') % {password_reset_token_valid_time: password_reset_token_valid_time} + - a_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % {url: new_user_password_url(user_email: @user.email)} + - a_end = '</a>'.html_safe + = html_escape(s_('Notify|After it expires, you can %{a_start} request a new one %{a_end}.')) % {a_start: a_start, a_end: a_end} diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 6ab74bcfb1a..c82b7a8dd2a 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -32,6 +32,6 @@ had <%= failed.size %> failed <%= 'job'.pluralize(failed.size) %>. <% failed.each do |build| -%> <%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %> -Stage: <%= build.stage %> +Stage: <%= build.stage_name %> Name: <%= build.name %> <% end -%> diff --git a/app/views/notify/pipeline_fixed_email.html.haml b/app/views/notify/pipeline_fixed_email.html.haml index f2dbb3b20b7..33b83b104b1 100644 --- a/app/views/notify/pipeline_fixed_email.html.haml +++ b/app/views/notify/pipeline_fixed_email.html.haml @@ -1 +1 @@ -= render 'notify/successful_pipeline', title: "Pipeline has been fixed and ##{@pipeline.id} has passed!" += render 'notify/successful_pipeline', title: s_('Notify|Pipeline has been fixed and #%{pipeline_id} has passed!') % {pipeline_id: @pipeline.id} diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml index 5197a1bdd08..16612cd43c5 100644 --- a/app/views/notify/push_to_merge_request_email.html.haml +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -1,7 +1,7 @@ %h3 - = sanitize_name(@updated_by_user.name) - pushed new commits to merge request - = merge_request_reference_link(@merge_request) + - updated_by_user_name = sanitize_name(@updated_by_user.name) + - mr_link = sanitize(merge_request_reference_link(@merge_request)) + = s_('Notify|%{updated_by_user_name} pushed new commits to merge request %{mr_link}').html_safe % {updated_by_user_name: updated_by_user_name, mr_link: mr_link} - if @total_existing_commits_count > 0 %ul @@ -13,8 +13,8 @@ = link_to(project_compare_url(@merge_request.target_project, from: @existing_commits.first[:short_id], to: @existing_commits.last[:short_id])) do #{@existing_commits.first[:short_id]}...#{@existing_commits.last[:short_id]} = precede ' - ' do - - commits_text = "#{@total_existing_commits_count} commit".pluralize(@total_existing_commits_count) - #{commits_text} from branch `#{@merge_request.target_branch}` + - commits_text = n_("%d commit", "%d commits", @total_existing_commits_count) % @total_existing_commits_count + = s_('Notify|%{commits_text} from branch `%{target_branch}`') % {commits_text: commits_text, target_branch: @merge_request.target_branch} - if @total_new_commits_count > 0 %ul @@ -24,4 +24,5 @@ = precede ' - ' do #{commit[:title]} - if @total_stripped_new_commits_count > 0 - %li And #{@total_stripped_new_commits_count} more + %li + = s_('Notify|And %{total_stripped_new_commits_count} more') % {total_stripped_new_commits_count: @total_stripped_new_commits_count} diff --git a/app/views/notify/remote_mirror_update_failed_email.html.haml b/app/views/notify/remote_mirror_update_failed_email.html.haml index 4fb0a4c5a8a..db95398d2d6 100644 --- a/app/views/notify/remote_mirror_update_failed_email.html.haml +++ b/app/views/notify/remote_mirror_update_failed_email.html.haml @@ -6,7 +6,7 @@ %td{ style: "vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" } %img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/ %td{ style: "vertical-align:middle;color:#ffffff;text-align:center;" } - A remote mirror update has failed. + = s_('Notify|A remote mirror update has failed.') %tr.spacer{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" } %td{ style: "height:18px;font-size:18px;line-height:18px;" } @@ -15,7 +15,8 @@ %table.table-info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } %tbody{ style: "font-size:15px;line-height:1.4;color:#8c8c8c;" } %tr - %td{ style: "font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-weight:300;padding:14px 0;margin:0;" } + = _('Project') %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" } - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" } @@ -24,17 +25,20 @@ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } = @project.name %tr - %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Remote mirror + %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = s_('Notify|Remote mirror') %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } = @remote_mirror.safe_url %tr - %td{ style: "font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Last update at - %td{ style: "font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } - = @remote_mirror.last_update_at + - update_at_start = '<td style="font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;">'.html_safe + - update_at_mid = '</td><td style="font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;">'.html_safe + - update_at_end = '</td>'.html_safe + = html_escape(s_('Notify|%{update_at_start} Last update at %{update_at_mid} %{last_update_at} %{update_at_end}')) % {update_at_start: update_at_start, update_at_mid: update_at_mid, last_update_at: @remote_mirror.last_update_at, update_at_end: update_at_end} + %tr.table-warning{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" } %td{ style: "border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" } - Logs may contain sensitive data. Please consider before forwarding this email. + = s_('Notify|Logs may contain sensitive data. Please consider before forwarding this email.') %tr.section{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" } %td{ style: "padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" } %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" } diff --git a/app/views/notify/removed_milestone_issue_email.html.haml b/app/views/notify/removed_milestone_issue_email.html.haml index 7e9205b6491..f411ea23832 100644 --- a/app/views/notify/removed_milestone_issue_email.html.haml +++ b/app/views/notify/removed_milestone_issue_email.html.haml @@ -1,2 +1,2 @@ %p - Milestone removed + = s_('Notify|Milestone removed') diff --git a/app/views/notify/removed_milestone_merge_request_email.html.haml b/app/views/notify/removed_milestone_merge_request_email.html.haml index 7e9205b6491..f411ea23832 100644 --- a/app/views/notify/removed_milestone_merge_request_email.html.haml +++ b/app/views/notify/removed_milestone_merge_request_email.html.haml @@ -1,2 +1,2 @@ %p - Milestone removed + = s_('Notify|Milestone removed') diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index 93806e6de8e..ee219914513 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -2,29 +2,30 @@ = stylesheet_link_tag 'mailers/highlighted_diff_email' %h3 - #{@message.author_name} #{@message.action_name} #{@message.ref_type} #{@message.ref_name} - at #{link_to(@message.project_name_with_namespace, project_url(@message.project))} + = s_('Notify|%{author_name} %{action_name} %{ref_type} %{ref_name} at %{project_link}').html_safe % {author_name: @message.author_name, action_name: @message.action_name, ref_type: @message.ref_type, ref_name: @message.ref_name, project_link: link_to(@message.project_name_with_namespace, strip_tags(project_url(@message.project)))} - if @message.compare - if @message.reverse_compare? %p - %strong WARNING: - The push did not contain any new commits, but force pushed to delete the commits and changes below. + %strong + = _('WARNING:') + = s_('Notify|The push did not contain any new commits, but force pushed to delete the commits and changes below.') %h4 - = @message.reverse_compare? ? "Deleted commits:" : "Commits:" + = @message.reverse_compare? ? _("Deleted commits:") : _("Commits:") %ul - @message.commits.each do |commit| %li %strong= link_to(commit.short_id, project_commit_url(@message.project, commit)) %div - %span by #{commit.author_name} - %i at #{commit.committed_date.to_s(:iso8601)} + = html_escape(s_('Notify|%{committed_by_start} by %{author_name} %{committed_by_end} %{committed_at_start} at %{committed_date} %{committed_at_end}')) % {committed_by_start: '<span>'.html_safe, author_name: commit.author_name, committed_by_end: '</span>'.html_safe, committed_at_start: '<i>'.html_safe, committed_date: commit.committed_date.to_s(:iso8601), committed_at_end: '</i>'.html_safe} %pre.commit-message = commit.safe_message - %h4 #{pluralize @message.diffs_count, "changed file"}: + %h4 + - changed_files = n_('%d changed file', '%d changed files', @message.diffs_count) % @message.diffs_count + = s_('Notify|%{changed_files}:') % {changed_files: changed_files} %ul - @message.diffs.each do |diff_file| @@ -47,9 +48,11 @@ - unless @message.disable_diffs? - if @message.compare_timeout - %h5 The diff was not included because it is too large. + %h5 + = s_('Notify|The diff was not included because it is too large.') - else - %h4 Changes: + %h4 + = _('Changes:') - @message.diffs.each do |diff_file| - file_hash = hexdigest(diff_file.file_path) %li{ id: file_hash } @@ -57,7 +60,7 @@ - if diff_file.deleted_file? %strong< = diff_file.old_path - deleted + = s_('deleted') - elsif diff_file.renamed_file? %strong< = diff_file.old_path @@ -68,7 +71,7 @@ %strong< = diff_file.new_path - if diff_file.too_large? - The diff for this file was not included because it is too large. + = s_('Notify|The diff for this file was not included because it is too large.') - else %hr - blob = diff_file.blob @@ -76,5 +79,5 @@ %table.code.white = render partial: "projects/diffs/email_line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file } - else - No preview for this file type + = s_('Notify|No preview for this file type') %br diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml index 209415e0aee..78dc21caf18 100644 --- a/app/views/notify/resolved_all_discussions_email.html.haml +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -1,3 +1,2 @@ %p - All discussions on merge request #{merge_request_reference_link(@merge_request)} - were resolved by #{sanitize_name(@resolved_by.name)} + = s_('Notify|All discussions on merge request %{mr_link} were resolved by %{name}').html_safe % { mr_link: merge_request_reference_link(@merge_request), name: sanitize_name(@resolved_by.name) } diff --git a/app/views/notify/send_admin_notification.html.haml b/app/views/notify/send_admin_notification.html.haml index f7f1528f332..20c44df360c 100644 --- a/app/views/notify/send_admin_notification.html.haml +++ b/app/views/notify/send_admin_notification.html.haml @@ -3,5 +3,5 @@ \---- %p - Don't want to receive updates from GitLab administrators? - = link_to 'Unsubscribe', @unsubscribe_url + = s_("Notify|Don't want to receive updates from GitLab administrators?") + = link_to _('Unsubscribe'), @unsubscribe_url diff --git a/app/views/notify/unapproved_merge_request_email.html.haml b/app/views/notify/unapproved_merge_request_email.html.haml index 0b8fbe14228..94e2d0377aa 100644 --- a/app/views/notify/unapproved_merge_request_email.html.haml +++ b/app/views/notify/unapproved_merge_request_email.html.haml @@ -79,9 +79,11 @@ %img{ alt: "✗", height: "13", src: image_url('mailers/approval/icon-x-orange-inverted.gif'), style: "display:block;", width: "13" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } - if @merge_request.respond_to? :approvals_required - %span Merge request was unapproved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required}) + %span + = s_('Notify|Merge request was unapproved (%{approvals_count}/%{approvals_required})') % {approvals_count: @merge_request.approvals.count, approvals_required: @merge_request.approvals_required} - else - %span Merge request was unapproved + %span + = s_('Notify|Merge request was unapproved') %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } @@ -92,12 +94,7 @@ %tr{ style: 'width:100%;' } %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" } %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } - %span{ style: "font-weight: 600;color:#333333;" } Merge request - %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference - %span was unapproved by - %img.avatar{ height: "24", src: avatar_icon_for_user(@unapproved_by, 24), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/ - %a.muted{ href: user_url(@unapproved_by), style: "color:#333333;text-decoration:none;" } - = @unapproved_by.name + = s_('Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{reviewer_highlight}was unapproved by%{highlight_end} %{reviewer_avatar} %{reviewer_link}').html_safe % merge_request_hash_param(@merge_request, @unapproved_by) %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } @@ -106,7 +103,8 @@ %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } + = _('Project') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) @@ -116,7 +114,8 @@ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } = @project.name %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = _('Branch') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody @@ -127,7 +126,8 @@ %span.muted{ style: "color:#333333;text-decoration:none;" } = @merge_request.source_branch %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } + = _('Author') %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml index 0ca9acba2de..0fde7fd4f19 100644 --- a/app/views/profiles/_email_settings.html.haml +++ b/app/views/profiles/_email_settings.html.haml @@ -25,8 +25,8 @@ = s_("Profiles|This email will be displayed on your public profile.") .form-group.gl-form-group - - commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits', target: '_blank') - - commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url } + - commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits') + - commit_email_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: commit_email_link_url } - commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more.%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe } = form.label :commit_email, s_('Profiles|Commit email') .gl-md-form-input-lg diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml index f444f236cfc..be835233528 100644 --- a/app/views/profiles/active_sessions/index.html.haml +++ b/app/views/profiles/active_sessions/index.html.haml @@ -10,6 +10,7 @@ .col-lg-8 .gl-mb-3 - .card.border-0 - %ul.list-group.list-group-flush - = render partial: 'profiles/active_sessions/active_session', collection: @sessions + = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c| + - c.body do + %ul.list-group.list-group-flush + = render partial: 'profiles/active_sessions/active_session', collection: @sessions diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index ef9e7512b57..1b8f0328a04 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -38,29 +38,29 @@ = render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? } %ul %li= s_('Profiles|Primary email') - - if @primary_email === current_user.commit_email_or_default + - if @primary_email == current_user.commit_email_or_default %li= s_('Profiles|Commit email') - - if @primary_email === current_user.public_email + - if @primary_email == current_user.public_email %li= s_('Profiles|Public email') - - if @primary_email === current_user.notification_email_or_default + - if @primary_email == current_user.notification_email_or_default %li= s_('Profiles|Default notification email') - @emails.reject(&:user_primary_email?).each do |email| %li{ data: { qa_selector: 'email_row_content' } } - .gl-display-flex.gl-justify-content-space-between{ style: 'flex-flow: wrap-reverse; row-gap: 0.5rem' } + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-gap-3 %div = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } - .gl-ml-n3 + %ul + - if email.email == current_user.commit_email_or_default + %li= s_('Profiles|Commit email') + - if email.email == current_user.public_email + %li= s_('Profiles|Public email') + - if email.email == current_user.notification_email_or_default + %li= s_('Profiles|Notification email') + .gl-display-flex.gl-justify-content-end.gl-align-items-flex-end.gl-flex-grow-1.gl-flex-wrap-wrap-reverse.gl-gap-3 - unless email.confirmed? - confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}" - = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default gl-ml-3' + = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-default' - = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do + = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger' do %span.sr-only= _('Remove') = sprite_icon('remove') - %ul - - if email.email === current_user.commit_email_or_default - %li= s_('Profiles|Commit email') - - if email.email === current_user.public_email - %li= s_('Profiles|Public email') - - if email.email === current_user.notification_email_or_default - %li= s_('Profiles|Notification email') diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml index b3784faed28..9804a3b7735 100644 --- a/app/views/profiles/gpg_keys/_form.html.haml +++ b/app/views/profiles/gpg_keys/_form.html.haml @@ -1,6 +1,6 @@ %div = form_for [:profile, @gpg_key], html: { class: 'js-requires-input' } do |f| - = form_errors(@gpg_key, pajamas_alert: true) + = form_errors(@gpg_key) .form-group = f.label :key, s_('Profiles|Key'), class: 'label-bold' diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index a749fbd1eec..6f7eb21b7e0 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -1,7 +1,7 @@ - max_date = ::Gitlab::CurrentSettings.max_ssh_key_lifetime_from_now.to_date if ssh_key_expiration_policy_enabled? %div = form_for [:profile, @key], html: { class: 'js-requires-input' } do |f| - = form_errors(@key, pajamas_alert: true) + = form_errors(@key) .form-group = f.label :key, s_('Profiles|Key'), class: 'label-bold' @@ -13,10 +13,12 @@ = f.text_field :title, class: "form-control gl-form-input input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|Example: MacBook key') %p.form-text.text-muted= s_('Profiles|Key titles are publicly visible.') + .form-row .col.form-group - = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold' - = f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, max: max_date, data: { qa_selector: 'key_expiry_date_field' } - %p.form-text.text-muted{ data: { qa_selector: 'key_expiry_date_field_description' } }= ssh_key_expires_field_description + .js-access-tokens-expires-at{ data: {min_date: Date.tomorrow, max_date: max_date, default_date_offset: 365, description: ssh_key_expires_field_description } } + = f.label :expires_at, s_('Profiles|Expiration date'), class: 'label-bold' + = f.text_field :expires_at, class: "gl-datepicker-input form-control gl-form-input", placeholder: 'YYYY-MM-DD', min: Date.tomorrow, max: max_date, data: { js_name: 'expiresAt' } + %p.form-text.text-muted= ssh_key_expires_field_description .js-add-ssh-key-validation-warning.hide .bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' } diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 178ed01c766..de4a19bdad7 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -16,7 +16,7 @@ %span.gl-text-truncate.gl-sm-ml-3 = key.fingerprint - .gl-mt-3= s_('Profiles|Created%{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2')} + .gl-mt-3= html_escape(s_('Profiles|Created%{time_ago}')) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2').html_safe} .key-list-item-dates %span.last-used-at.gl-mr-3 diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index 8f7ccadd108..8016d989ff1 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -19,7 +19,7 @@ %strong= @key.last_used_at.try(:to_s, :medium) || _('Never') .col-md-8 - = form_errors(@key, type: 'key', pajamas_alert: true) unless @key.valid? + = form_errors(@key, type: 'key') unless @key.valid? %pre.well-pre = @key.key .card diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml index b4db99a8bd4..c4de33dcd9e 100644 --- a/app/views/profiles/notifications/_email_settings.html.haml +++ b/app/views/profiles/notifications/_email_settings.html.haml @@ -1,6 +1,6 @@ - form = local_assigns.fetch(:form) .form-group - = form.label :notification_email, class: "label-bold" + = form.label :notification_email, _('Notification Email'), class: "label-bold" = form.select :notification_email, @user.public_verified_emails, { include_blank: _('Use primary email (%{email})') % { email: @user.email }, selected: @user.notification_email }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil) .help-block = local_assigns.fetch(:help_text, nil) diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 26c9b2f0ee1..0f4b130a774 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -25,7 +25,7 @@ = gitlab_ui_form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications gl-mt-3' } do |f| = render_if_exists 'profiles/notifications/email_settings', form: f - = label_tag :global_notification_level, "Global notification level", class: "label-bold" + = label_tag :global_notification_level, _('Global notification level'), class: "label-bold" %br .clearfix .form-group.float-left.global-notification-setting diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 46ae602359f..257255eb4d7 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -15,7 +15,7 @@ - else = _('Change your password or recover your current one') = form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f| - = form_errors(@user, pajamas_alert: true) + = form_errors(@user) - unless @user.password_automatically_set? .form-group diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index 5bcc92dcdfd..6f260eb4cc0 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -9,7 +9,7 @@ %br = _('After a successful password update you will be redirected to login screen.') - = form_errors(@user, pajamas_alert: true) + = form_errors(@user) - unless @user.password_automatically_set? .form-group.row diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index f8737a4e54a..e16108c5c22 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,4 +1,5 @@ - page_title _('Preferences') +- add_page_specific_style 'page_bundles/profiles/preferences' - @content_class = "limit-container-width" unless fluid_layout - user_theme_id = Gitlab::Themes.for_user(@user).id - user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id @@ -77,10 +78,10 @@ = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } .form-group = f.label :dashboard, class: 'label-bold' do - = s_('Preferences|Homepage content') + = s_('Preferences|Dashboard') = f.select :dashboard, dashboard_choices, {}, class: 'select2' .form-text.text-muted - = s_('Preferences|Choose what content you want to see on your homepage.') + = s_('Preferences|Choose what content you want to see by default on your dashboard.') = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index a64968cdcbb..f38d6021b18 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -2,8 +2,6 @@ - page_title s_("Profiles|Edit Profile") - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host -- availability = availability_values -- custom_emoji = @user.status&.customized? = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| .row.js-search-settings-section @@ -43,39 +41,12 @@ %h4.gl-mt-0= s_("Profiles|Current status") %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") .col-lg-8 - = f.fields_for :status, @user.status do |status_form| - - emoji_button = render Pajamas::ButtonComponent.new(button_options: { title: s_("Profiles|Add status emoji"), - class: 'js-toggle-emoji-menu emoji-menu-toggle-button has-tooltip' } ) do - - if custom_emoji - = emoji_icon(@user.status.emoji, class: 'gl-mr-0!') - %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) } - = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral') - = sprite_icon('smiley', css_class: 'award-control-icon-positive') - = sprite_icon('smile', css_class: 'award-control-icon-super-positive') - - reset_message_button = render Pajamas::ButtonComponent.new(icon: 'close', - button_options: { id: 'js-clear-user-status-button', - class: 'has-tooltip', - title: s_("Profiles|Clear status") } ) - - = status_form.hidden_field :emoji, id: 'js-status-emoji-field' - .form-group.gl-form-group - = status_form.label :message, s_("Profiles|Your status") - .input-group{ role: 'group' } - .input-group-prepend - = emoji_button - = status_form.text_field :message, - id: 'js-status-message-field', - class: 'form-control gl-form-input input-lg', - placeholder: s_("Profiles|What's your status?") - .input-group-append - = reset_message_button - .form-group.gl-form-group - = status_form.gitlab_ui_checkbox_component :availability, - s_("Profiles|Busy"), - help_text: s_('Profiles|An indicator appears next to your name and avatar.'), - checkbox_options: { data: { testid: "user-availability-checkbox" } }, - checked_value: availability["busy"], - unchecked_value: availability["not_set"] + #js-user-profile-set-status-form + = f.fields_for :status, @user.status do |status_form| + = status_form.hidden_field :emoji, data: { js_name: 'emoji' } + = status_form.hidden_field :message, data: { js_name: 'message' } + = status_form.hidden_field :availability, data: { js_name: 'availability' } + = status_form.hidden_field :clear_status_after, data: { js_name: 'clearStatusAfter' } .col-lg-12 %hr .row.user-time-preferences.js-search-settings-section diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index c1eaa84e99d..855c73fd323 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -81,7 +81,7 @@ .col-lg-8 - registration = webauthn_enabled ? @webauthn_registration : @u2f_registration - if registration.errors.present? - = form_errors(registration, pajamas_alert: true) + = form_errors(registration) - if webauthn_enabled = render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path - else diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 402affc7b0e..118f6fb1296 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -4,7 +4,7 @@ = render 'shared/event_filter' .controls.gl-display-flex = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do - = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') + = sprite_icon('rss', css_class: 'gl-icon') - if is_project_overview && can?(current_user, :download_code, @project) .project-clone-holder.d-none.d-md-inline-flex.gl-ml-2 = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 952c6daf415..9962e03995b 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,5 +1,8 @@ .form-actions.gl-display-flex - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } }) do + - submit_button_options = { type: :submit, variant: :confirm, button_options: { id: 'commit-changes', class: 'js-commit-button', data: { qa_selector: 'commit_button' } } } + = render Pajamas::ButtonComponent.new(**submit_button_options) do + = _('Commit changes') + = render Pajamas::ButtonComponent.new(loading: true, disabled: true, **submit_button_options.merge({ button_options: { class: 'js-commit-button-loading gl-display-none' } })) do = _('Commit changes') = render Pajamas::ButtonComponent.new(href: cancel_path, button_options: { class: 'gl-ml-3', id: 'cancel-changes', aria: { label: _('Discard changes') }, data: { confirm: leave_edit_message, confirm_btn_variant: "danger" } }) do diff --git a/app/views/projects/_errors.html.haml b/app/views/projects/_errors.html.haml index 5982aaf3622..2dba22d3be6 100644 --- a/app/views/projects/_errors.html.haml +++ b/app/views/projects/_errors.html.haml @@ -1 +1 @@ -= form_errors(@project, pajamas_alert: true) += form_errors(@project) diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index c220aa66c81..7ff58d12b9c 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -10,12 +10,12 @@ = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image') .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1.gl-line-height-24.gl-font-weight-bold{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } + %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1{ data: { qa_selector: 'project_name_content' }, itemprop: 'name' } = @project.name - %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } + %span.visibility-icon.gl-text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, options: { class: 'icon' }) = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project - .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'project_id_content' }, itemprop: 'identifier' } + .home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { qa_selector: 'project_id_content' }, itemprop: 'identifier' } - if can?(current_user, :read_project, @project) %span.gl-display-inline-block.gl-vertical-align-middle = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index cee3d9071b6..349cd88437f 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -17,6 +17,7 @@ selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", - templates: available_service_desk_templates_for(@project) } } + templates: available_service_desk_templates_for(@project), + public_project: "#{@project.public?}" } } - elsif show_callout?('promote_service_desk_dismissed') = render 'shared/promotions/promote_servicedesk' diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 4a21cb32c20..1409b28e735 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -2,7 +2,7 @@ - project_buttons = local_assigns.fetch(:project_buttons, false) - return unless anchors.any? -%ul.nav.gl-gap-3 +%ul.nav{ class: (project_buttons ? 'gl-gap-3' : 'gl-gap-5') } - anchors.each do |anchor| %li.nav-item = link_to_if(anchor.link, anchor.label, anchor.link, stat_anchor_attrs(anchor)) do diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml index 6a4760c3954..674b21b66b9 100644 --- a/app/views/projects/activity.html.haml +++ b/app/views/projects/activity.html.haml @@ -1,4 +1,6 @@ - page_title _("Activity") += render_if_exists 'shared/ultimate_feature_removal_banner', project: @project + = render 'projects/last_push' = render 'projects/activity' diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index b44c773adff..f2c4fe017f2 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,9 +1,9 @@ - page_title _("Blame"), @blob.path, @ref -#blob-content-holder.tree-holder +#blob-content-holder.tree-holder{ data: { testid: 'blob-content-holder' } } = render "projects/blob/breadcrumb", blob: @blob, blame: true - .file-holder + .file-holder.gl-overflow-hidden = render "projects/blob/header", blob: @blob, blame: true .file-blame-legend @@ -20,7 +20,7 @@ %span.legend-box.legend-box-9 %span.right-label Older - .table-responsive.file-content.blame.code{ class: user_color_scheme, data: { qa_selector: 'blame_file_content' } } + .table-responsive.file-content.blame.code{ class: "#{user_color_scheme} gl-rounded-0!", data: { qa_selector: 'blame_file_content' } } %table - current_line = @blame.first_line - @blame.groups.each do |blame_group| @@ -59,5 +59,11 @@ - current_line += line_count - - if blame_pagination - = paginate(blame_pagination, theme: "gitlab") + - if @blame_pagination && @blame_pagination.total_pages > 1 + .gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100 + = _('For faster browsing, not all history is shown.') + = render Pajamas::ButtonComponent.new(href: namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, no_pagination: true), size: :small, button_options: { class: 'gl-mt-3' }) do |c| + = _('View entire blame') + + - if @blame_pagination + = paginate(@blame_pagination, theme: "gitlab") diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 8260aa0fb7e..9c07713b9f8 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -6,11 +6,6 @@ .file-actions.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-md-justify-content-end< = render 'projects/blob/viewer_switcher', blob: blob unless blame = render 'shared/web_ide_button', blob: blob - .btn-group{ role: "group", class: ("gl-ml-3" if current_user) }> - = render_if_exists 'projects/blob/header_file_locks_link' - - if current_user - = replace_blob_link(@project, @ref, @path, blob: blob) - = delete_blob_link(@project, @ref, @path, blob: blob) .btn-group.gl-ml-3{ role: "group" } = copy_blob_source_button(blob) unless blame = open_raw_blob_button(blob) diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 220319d31b5..528999f5c89 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -2,6 +2,7 @@ - page_title _("Edit"), @blob.path, @ref - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco') +- add_page_specific_style 'page_bundles/editor' - if @conflict = render Pajamas::AlertComponent.new(alert_options: { class: 'gl-mb-5 gl-mt-5' }, diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 27f64104cf4..81b2715b228 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,5 +1,6 @@ - breadcrumb_title _("Repository") - page_title _("New File"), @path.presence, @ref +- add_page_specific_style 'page_bundles/editor' %h1.page-title.blob-new-page-title.gl-font-size-h-display = _('New file') diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 16ecc1cc5a0..33b2229f5d1 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -3,6 +3,7 @@ - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) - content_for :prefetch_asset_tags do - webpack_preload_asset_tag('monaco', prefetch: true) +- add_page_startup_graphql_call('repository/blob_info', { projectPath: @project.full_path, ref: current_ref, filePath: @blob.path, shouldFetchRawText: @blob.rendered_as_text? && !@blob.rich_viewer }) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml index af0e656d301..46665fdb450 100644 --- a/app/views/projects/branch_rules/_show.html.haml +++ b/app/views/projects/branch_rules/_show.html.haml @@ -9,4 +9,4 @@ = _('Define rules for who can push, merge, and the required approvals for each branch.') .settings-content.gl-pr-0 - #js-branch-rules + #js-branch-rules{ data: { project_path: @project.full_path } } diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index f8bee5a69e9..63d0cf7145d 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -11,12 +11,12 @@ = form_tag namespace_project_branches_path, method: :post, id: "new-branch-form", class: "js-create-branch-form js-requires-input" do .form-group.row - = label_tag :branch_name, nil, class: 'col-form-label col-sm-2' + = label_tag :branch_name, _('Branch name'), class: 'col-form-label col-sm-2' .col-sm-10 = text_field_tag :branch_name, params[:branch_name], required: true, autofocus: true, class: 'form-control js-branch-name monospace' .form-text.text-muted.text-danger.js-branch-name-error .form-group.row - = label_tag :ref, 'Create from', class: 'col-form-label col-sm-2' + = label_tag :ref, _('Create from'), class: 'col-form-label col-sm-2' .col-sm-10.create-from .dropdown = hidden_field_tag :ref, default_ref @@ -24,7 +24,8 @@ .text-left.dropdown-toggle-text= default_ref = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") = render 'shared/ref_dropdown', dropdown_class: 'wide' - .form-text.text-muted Existing branch name, tag, or commit SHA + .form-text.text-muted + = _('Existing branch name, tag, or commit SHA') .form-actions = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { type: 'submit', class: 'gl-mr-3' }) do = _('Create branch') diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 5fd1c5cd403..10a6bc6b524 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -25,7 +25,8 @@ .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' - %li.divider.mt-2 + = render_if_exists 'projects/buttons/kerberos_clone_field' + %li.divider.mt-2 %li.pt-2.gl-new-dropdown-item %label.label-bold{ class: 'gl-px-4!' } = _('Open in your IDE') @@ -51,4 +52,3 @@ %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } .gl-new-dropdown-item-text-wrapper = _("Xcode") - = render_if_exists 'projects/buttons/kerberos_clone_field' diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index bd096ed74f5..b48369322e4 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -82,7 +82,7 @@ - if stage %td - = job.stage + = job.stage_name %td - if job.duration diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index 18eac48d42a..bc352ff6c7d 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -1,4 +1,5 @@ - @force_fluid_layout = true +- add_page_specific_style 'page_bundles/editor' - add_page_specific_style 'page_bundles/pipelines' - add_page_specific_style 'page_bundles/pipeline_editor' diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 4007b657403..6b06584ea25 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -28,7 +28,7 @@ = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false } .control.d-none.d-md-block = link_to project_commits_path(@project, @id, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-default btn-icon' do - = sprite_icon('rss', css_class: 'qa-rss-icon') + = sprite_icon('rss') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index b1fb9c70a54..eba0f336f80 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -16,7 +16,7 @@ = _('A default branch cannot be chosen for an empty project.') - else .form-group - = f.label :default_branch, "Default branch", class: 'label-bold' + = f.label :default_branch, _("Default branch"), class: 'label-bold' = f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide', data: { qa_selector: 'default_branch_dropdown' }}) .form-group diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index a7dd69a9607..70df995cdf3 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -5,6 +5,8 @@ - expanded = expanded_by_default? - reduce_visibility_form_id = 'reduce-visibility-form' += render_if_exists 'shared/ultimate_feature_removal_banner', project: @project + %section.settings.general-settings.no-animate.expanded#js-general-settings .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') @@ -26,23 +28,13 @@ %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } -%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - = render_if_exists 'projects/merge_request_settings_description_text' - - .settings-content - = render_if_exists 'shared/promotions/promote_mr_features' - - = gitlab_ui_form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| - %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } - = render 'projects/merge_request_settings', form: f - = f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' } - -= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded - +- if show_merge_request_settings_callout? + %section.settings.expanded + = render Pajamas::AlertComponent.new(variant: :info, + title: _('Merge requests and approvals settings have moved.'), + alert_options: { class: 'js-merge-request-settings-callout gl-my-5', data: { feature_id: Users::CalloutsHelper::MERGE_REQUEST_SETTINGS_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| + = c.body do + = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } %section.settings.no-animate{ class: ('expanded' if expanded), data: { qa_selector: 'badges_settings_content' } } .settings-header diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 36347776ec9..a9913fe3d5e 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -3,7 +3,7 @@ #fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'), endpoint: new_project_fork_path(@project, format: :json), new_group_path: new_group_path, - project_full_path: project_path(@project), + project_full_path: @project.full_path, visibility_help_path: help_page_path("user/public_access"), project_id: @project.id, project_name: @project.name, diff --git a/app/views/projects/google_cloud/databases/cloudsql_form.html.haml b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml new file mode 100644 index 00000000000..05838717b49 --- /dev/null +++ b/app/views/projects/google_cloud/databases/cloudsql_form.html.haml @@ -0,0 +1,9 @@ +- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) +- add_to_breadcrumbs s_('CloudSeed|Databases'), project_google_cloud_databases_path(@project) +- breadcrumb_title @title +- page_title @title + +- @content_class = "limit-container-width" unless fluid_layout + += form_tag project_google_cloud_databases_path(@project), method: 'post' do + #js-google-cloud-databases-cloudsql-form{ data: @js_data } diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml index 36b5630611e..4cc218ff548 100644 --- a/app/views/projects/google_cloud/gcp_regions/index.html.haml +++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml @@ -1,5 +1,5 @@ - add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project) -- breadcrumb_title _('CloudSeed|Regions') +- breadcrumb_title s_('CloudSeed|Regions') - page_title s_('CloudSeed|Regions') - @content_class = "limit-container-width" unless fluid_layout diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml index 0fce3b7f8aa..e6f0e3e950c 100644 --- a/app/views/projects/harbor/repositories/index.html.haml +++ b/app/views/projects/harbor/repositories/index.html.haml @@ -4,8 +4,9 @@ #js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - "registry_host_url_with_port" => 'demo.harbor.com', + "repository_url" => @project.harbor_integration.hostname, + "harbor_integration_project_name" => @project.harbor_integration.project_name, + "project_name" => @project.name, connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, is_group_page: false.to_s, } } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 16b795ee3c9..11b652cc818 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -7,4 +7,5 @@ noteable_data: serialize_issuable(@issue, with_blocking_issues: true), noteable_type: 'Issue', target_type: 'issue', - current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json } } + current_user_data: UserSerializer.new.represent(current_user, {only_path: true}, CurrentUserEntity).to_json, + can_add_timeline_events: "#{can?(current_user, :admin_incident_management_timeline_event, @issue)}" } } diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 310a0c1a61e..466eca2fdb0 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -1,7 +1,7 @@ - if @related_branches.any? %h2.gl-font-lg = pluralize(@related_branches.size, 'Related Branch') - %ul.related-merge-requests.gl-pl-0 + %ul.related-merge-requests.gl-pl-0.gl-mb-3 - @related_branches.each do |branch| %li.gl-display-flex.gl-align-items-center - if branch[:pipeline_status].present? diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml index df2ffdd30ee..bc2136b89fb 100644 --- a/app/views/projects/issues/_work_item_links.html.haml +++ b/app/views/projects/issues/_work_item_links.html.haml @@ -1,2 +1,2 @@ - if Feature.enabled?(:work_items_hierarchy, @project) - .js-work-item-links-root{ data: { issuable_id: @issue.id, project_path: @project.full_path, wi: work_items_index_data(@project) } } + .js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } } diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index f7a02c521f5..f95689c0b1d 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -3,6 +3,7 @@ - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? += render_if_exists 'shared/ultimate_feature_removal_banner', project: @project - if labels_or_filters #js-promote-label-modal diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml index 80a58053ff7..64d35b4dfe6 100644 --- a/app/views/projects/merge_requests/_awards_block.html.haml +++ b/app/views/projects/merge_requests/_awards_block.html.haml @@ -1,5 +1,5 @@ .content-block.emoji-block.emoji-list-container.js-noteable-awards = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) do - .ml-auto.gl-my-2 + .gl-my-2.gl-xs-w-full #js-vue-sort-issue-discussions = render "projects/merge_requests/discussion_filter" diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index 62cd8bd94e3..22571b11639 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -11,6 +11,10 @@ .gl-new-dropdown-inner .gl-new-dropdown-contents %ul + - if !@merge_request.merged? && current_user && moved_mr_sidebar_enabled? + %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point + %li.gl-new-dropdown-divider + %hr.dropdown-divider - if can?(current_user, :update_merge_request, @merge_request) %li.gl-new-dropdown-item{ class: "gl-md-display-none!" } = link_to edit_project_merge_request_path(@project, @merge_request), class: 'dropdown-item' do @@ -32,17 +36,19 @@ .gl-new-dropdown-item-text-wrapper = _('Reopen') = display_issuable_type + - if moved_mr_sidebar_enabled? + %li.gl-new-dropdown-item#js-lock-entry-point + %li.gl-new-dropdown-item + %button.dropdown-item.js-copy-reference{ type: "button", data: { 'clipboard-text': @merge_request.to_reference(full: true) } } + .gl-new-dropdown-item-text-wrapper + = _('Copy reference') - unless current_controller?('conflicts') - - if current_user && moved_mr_sidebar_enabled? - - if !@merge_request.merged? + - unless issuable_author_is_current_user(@merge_request) + - if moved_mr_sidebar_enabled? %li.gl-new-dropdown-divider %hr.dropdown-divider - %li.gl-new-dropdown-item.js-sidebar-subscriptions-entry-point - - unless issuable_author_is_current_user(@merge_request) %li.gl-new-dropdown-item = link_to new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'dropdown-item' do .gl-new-dropdown-item-text-wrapper = _('Report abuse') - - if moved_mr_sidebar_enabled? - %li.gl-new-dropdown-item#js-lock-entry-point diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 4e69dad2e12..783e3ac97c1 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -10,7 +10,7 @@ window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}'; window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}'; - window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; + window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}'; window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}'; diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index cee8d2e92aa..17b1e5a757c 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -8,7 +8,7 @@ .col-lg-6 .card-new-merge-request %h2.gl-font-size-h2 - Source branch + = _('Source branch') .clearfix .merge-request-select.dropdown = f.hidden_field :source_project_id @@ -38,7 +38,7 @@ .col-lg-6 .card-new-merge-request %h2.gl-font-size-h2 - Target branch + = _('Target branch') .clearfix - projects = target_projects(@project) .merge-request-select.dropdown @@ -67,5 +67,5 @@ %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? - = form_errors(@merge_request, pajamas_alert: true) - = f.submit 'Compare branches and continue', class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" } + = form_errors(@merge_request) + = f.submit _('Compare branches and continue'), class: "gl-button btn btn-confirm mr-compare-btn gl-mt-4", data: { qa_selector: "compare_branches_button" } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 78976be5dd7..d34848c801d 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -16,11 +16,13 @@ - add_page_startup_api_call @endpoint_metadata_url .merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } + - if moved_mr_sidebar_enabled? + #js-merge-sticky-header{ data: { data: sticky_header_data.to_json } } = render "projects/merge_requests/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/mr_box" - .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } + .merge-request-tabs-holder{ class: "#{'js-tabs-affix' unless ENV['RAILS_ENV'] == 'test'} #{'gl-static' if moved_mr_sidebar_enabled?}" } .merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between{ class: "#{'is-merge-request' if Feature.enabled?(:moved_mr_sidebar, @project) && !fluid_layout}" } %ul.merge-request-tabs.nav-tabs.nav.nav-links.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0{ class: "#{'gl-w-full gl-lg-w-auto!' if Feature.enabled?(:moved_mr_sidebar, @project)}" } = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do @@ -37,19 +39,19 @@ = tab_link_for @merge_request, :pipelines do = _("Pipelines") = gl_badge_tag @number_of_pipelines, { size: :sm }, { class: 'js-pipelines-mr-count' } - = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do + = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab js-diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do = tab_link_for @merge_request, :diffs do = _("Changes") = gl_badge_tag @diffs_count, { size: :sm } - - if Feature.enabled?(:moved_mr_sidebar, @project) - .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.js-expand-sidebar{ class: "gl-lg-display-none!" } - = render Pajamas::ButtonComponent.new(size: 'small', - icon: 'chevron-double-lg-left', - button_options: { class: 'js-sidebar-toggle' }) do - = _('Expand') .d-flex.flex-wrap.align-items-center.justify-content-lg-end #js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } } - + - if moved_mr_sidebar_enabled? + - if !!@issuable_sidebar.dig(:current_user, :id) + .js-issuable-todo{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } } + .gl-ml-auto.gl-align-items-center.gl-display-none.gl-md-display-flex.gl-ml-3.js-expand-sidebar{ class: "gl-lg-display-none!" } + = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', + button_options: { class: 'js-sidebar-toggle' }) do + = _('Expand') .tab-content#diff-notes-app #js-diff-file-finder #js-code-navigation @@ -99,7 +101,7 @@ #js-review-bar -- if Feature.enabled?(:mr_experience_survey, @project) && current_user +- if current_user && Feature.enabled?(:mr_experience_survey, current_user) #js-mr-experience-survey{ data: { account_age: current_user.account_age_in_days } } = render 'projects/invite_members_modal', project: @project diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 0d56bf7793d..c11d5e7c9b6 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,6 +1,6 @@ = gitlab_ui_form_for [@project, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| - = form_errors(@milestone, pajamas_alert: true) + = form_errors(@milestone) .form-group.row .col-form-label.col-sm-2 = f.label :title, _('Title') diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index a90d5224d04..2ae7d300979 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -17,7 +17,7 @@ = gitlab_ui_form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| .panel.panel-default .panel-body - %div= form_errors(@project, pajamas_alert: true) + %div= form_errors(@project) .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' @@ -41,40 +41,4 @@ = c.body do = _('Mirror settings are only available to GitLab administrators.') - .panel.panel-default - .table-responsive - %table.table.push-pull-table - %thead - %tr - %th - = _('Mirrored repositories') - = render_if_exists 'projects/mirrors/mirrored_repositories_count' - %th= _('Direction') - %th= _('Last update attempt') - %th= _('Last successful update') - %th - %th - %tbody.js-mirrors-table-body - = render_if_exists 'projects/mirrors/table_pull_row' - - @project.remote_mirrors.each_with_index do |mirror, index| - - next if mirror.new_record? - %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row' } } - %td{ data: { qa_selector: 'mirror_repository_url_cell' } }= mirror.safe_url || _('Invalid URL') - %td= _('Push') - %td - = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') - %td{ data: { qa_selector: 'mirror_last_update_at_cell' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') - %td - - if mirror.disabled? - = render 'projects/mirrors/disabled_mirror_badge' - - if mirror.last_error.present? - = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) } - %td.gl-display-flex - - if mirror_settings_enabled - .btn-group.mirror-actions-group{ role: 'group' } - - if mirror.ssh_key_auth? - = clipboard_button(text: mirror.ssh_public_key, class: 'gl-button btn btn-default btn-icon', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button') - = render 'shared/remote_mirror_update_button', remote_mirror: mirror - = render Pajamas::ButtonComponent.new(variant: :danger, - icon: 'remove', - button_options: { class: 'js-delete-mirror qa-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } }) + = render 'projects/mirrors/mirror_repos_list' diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml new file mode 100644 index 00000000000..2dbcbd659c8 --- /dev/null +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -0,0 +1,47 @@ +- mirror_settings_enabled = can?(current_user, :admin_remote_mirror, @project) + +.panel.panel-default + .table-responsive + - if !@project.mirror? && @project.remote_mirrors.count == 0 + .gl-card.gl-mt-5 + .gl-card-header + %strong + = _('Mirrored repositories') + ' (0)' + .gl-card-body + = _('There are currently no mirrored repositories.') + - else + %table.table.push-pull-table + %thead + %tr + %th + = _('Mirrored repositories') + = render_if_exists 'projects/mirrors/mirrored_repositories_count' + %th= _('Direction') + %th= _('Last update attempt') + %th= _('Last successful update') + %th + %th + %tbody.js-mirrors-table-body + = render_if_exists 'projects/mirrors/table_pull_row' + - @project.remote_mirrors.each_with_index do |mirror, index| + - next if mirror.new_record? + %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row' } } + %td{ data: { qa_selector: 'mirror_repository_url_cell' } }= mirror.safe_url || _('Invalid URL') + %td= _('Push') + %td + = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') + %td{ data: { qa_selector: 'mirror_last_update_at_cell' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td + - if mirror.disabled? + = render 'projects/mirrors/disabled_mirror_badge' + - if mirror.last_error.present? + = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) } + %td.gl-display-flex + - if mirror_settings_enabled + .btn-group.mirror-actions-group{ role: 'group' } + - if mirror.ssh_key_auth? + = clipboard_button(text: mirror.ssh_public_key, class: 'gl-button btn btn-default btn-icon', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button') + = render 'shared/remote_mirror_update_button', remote_mirror: mirror + = render Pajamas::ButtonComponent.new(variant: :danger, + icon: 'remove', + button_options: { class: 'js-delete-mirror qa-delete-mirror rspec-delete-mirror', title: _('Remove'), data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' } }) diff --git a/app/views/projects/pages/_header.html.haml b/app/views/projects/pages/_header.html.haml index da35f2fdf09..cf51796e878 100644 --- a/app/views/projects/pages/_header.html.haml +++ b/app/views/projects/pages/_header.html.haml @@ -1,4 +1,4 @@ -- can_add_new_domain = can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) +- can_add_new_domain = can_create_pages_custom_domains?(current_user, @project) %h1.page-title.gl-font-size-h-display.with-button = s_('GitLabPages|Pages') diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml index cdd52a933e9..5dea6b02e36 100644 --- a/app/views/projects/pages/new.html.haml +++ b/app/views/projects/pages/new.html.haml @@ -1,4 +1,4 @@ -- if Feature.enabled?(:use_pipeline_wizard_for_pages, @group) +- if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group) #js-pages{ data: @pipeline_wizard_data } - else diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index d29030f992f..5d5ca2aaaf3 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for [@project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f| - = form_errors(@schedule, pajamas_alert: true) + = form_errors(@schedule) .form-group.row .col-md-9 = f.label :description, _('Description'), class: 'label-bold' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index c36c3ae5adf..10dc74647b2 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -2,7 +2,7 @@ %tr.pipeline-schedule-table-row %td = pipeline_schedule.description - %td.branch-name-cell + %td.branch-name-cell.gl-text-truncate - if pipeline_schedule.for_tag? = sprite_icon('tag', size: 12) - else @@ -17,7 +17,7 @@ %span ##{pipeline_schedule.last_pipeline.id} - else = s_("PipelineSchedules|None") - %td.next-run-cell + %td.gl-text-gray-500{ 'data-testid': 'next-run-cell' } - if pipeline_schedule.active? && pipeline_schedule.next_run_at = time_ago_with_tooltip(pipeline_schedule.real_next_run) - else diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index a8ad53db8c2..e83547fd8f8 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -42,7 +42,7 @@ #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json), blob_path: project_blob_path(@project, @pipeline.sha), - has_test_report: @pipeline.has_reports?(Ci::JobArtifact.test_reports).to_s, + has_test_report: @pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)).to_s, empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'), artifacts_expired_image_path: image_path('illustrations/pipeline.svg') } } = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 37fe80d2aaf..34305d15eff 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -2,6 +2,7 @@ - page_title _("Members") = render_if_exists 'projects/free_user_cap_alert', project: @project += render_if_exists 'shared/ultimate_feature_removal_banner', project: @project .row.gl-mt-3 .col-lg-12 diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml index d0fdd3a729a..9dde86f77b4 100644 --- a/app/views/projects/project_templates/_template.html.haml +++ b/app/views/projects/project_templates/_template.html.haml @@ -1,4 +1,4 @@ -.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_row' } } +.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_container' } } .logo.gl-mr-3.px-1 = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}" .description diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index 20cd45be6da..34fe9a29068 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -1,14 +1,14 @@ - content_for :merge_access_levels do .merge_access_levels-container = dropdown_tag('Select', - options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge-select wide', - dropdown_class: 'dropdown-menu-selectable qa-allowed-to-merge-dropdown rspec-allowed-to-merge-dropdown capitalize-header', - data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) + options: { toggle_class: 'js-allowed-to-merge wide', + dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown', + data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'allowed_to_merge_dropdown' }}) - content_for :push_access_levels do .push_access_levels-container = dropdown_tag('Select', - options: { toggle_class: "js-allowed-to-push qa-allowed-to-push-select js-multiselect wide", - dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown rspec-allowed-to-push-dropdown capitalize-header', - data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) + options: { toggle_class: "js-allowed-to-push js-multiselect wide", + dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown', + data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }}) = render 'projects/protected_branches/shared/create_protected_branch' diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 5964f2bfeda..64db51d5df2 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -1,4 +1,4 @@ -.protected-branches-list.js-protected-branches-list.qa-protected-branches-list +.protected-branches-list.js-protected-branches-list{ data: { testid: 'protected-branches-list' } } - if @protected_branches.empty? .card-header.bg-white = s_("ProtectedBranch|Protected branch (%{protected_branches_count})") % { protected_branches_count: 0 } diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index 35770c32f9f..277cbf00034 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -4,7 +4,7 @@ - c.header do = s_("ProtectedBranch|Protect a branch") - c.body do - = form_errors(@protected_branch, pajamas_alert: true) + = form_errors(@protected_branch) .form-group.row = f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12' .col-sm-12 diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index 6dd3b2e8d5e..098bd4a7eeb 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -1,6 +1,6 @@ - can_admin_project = can?(current_user, :admin_project, @project) -%tr.qa-protected-branch.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } +%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch), testid: 'protected-branch' } } %td %span.ref-name= protected_branch.name diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index e257117a32e..ba0935fff7d 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -4,7 +4,7 @@ .card-header = _('Protect a tag') .card-body - = form_errors(@protected_tag, pajamas_alert: true) + = form_errors(@protected_tag) .form-group.row = f.label :name, _('Tag:'), class: 'col-md-2 text-left text-md-right' .col-md-10.protected-tags-dropdown diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index ea77bda0b0f..81526685bfc 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -16,7 +16,7 @@ .row .col-lg-12 = gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings') do |f| - = form_errors(@project, pajamas_alert: true) + = form_errors(@project) %fieldset.builds-feature.js-auto-devops-settings .form-group = f.fields_for :auto_devops_attributes, @auto_devops do |form| diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 50e96528c0d..9419dacc16f 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -6,7 +6,7 @@ .row.gl-mt-3 .col-lg-12 = gitlab_ui_form_for @project, url: project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings') do |f| - = form_errors(@project, pajamas_alert: true) + = form_errors(@project) %fieldset.builds-feature .form-group = f.gitlab_ui_checkbox_component :public_builds, diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index dd9cc296d52..c1df7b88352 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -103,8 +103,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p - = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.") - = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer' + = _("Control how the CI_JOB_TOKEN CI/CD variable is used for API access between projects.") .settings-content = render 'ci/token_access/index' diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml new file mode 100644 index 00000000000..886e276dea5 --- /dev/null +++ b/app/views/projects/settings/merge_requests/show.html.haml @@ -0,0 +1,18 @@ +- breadcrumb_title _('Merge requests') +- page_title _('Merge requests') +- @content_class = 'limit-container-width' unless fluid_layout + +%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings.expanded{ class: [('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests') + = render_if_exists 'projects/merge_request_settings_description_text' + + .settings-content + = render_if_exists 'shared/promotions/promote_mr_features' + + = gitlab_ui_form_for @project, url: project_settings_merge_requests_path(@project), html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } + = render 'projects/merge_request_settings', form: f + = f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' } + += render_if_exists 'projects/settings/merge_requests/merge_request_approvals_settings', expanded: true diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 87e3e03099c..90e0ccce8b4 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -2,19 +2,6 @@ - page_title _('Monitor Settings') - breadcrumb_title _('Monitor Settings') -= render Pajamas::AlertComponent.new(variant: :danger, - dismissible: false, - title: s_('Deprecations|Feature deprecation and removal')) do |c| - = c.body do - - removal_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/7188' - - removal_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: removal_epic_link_url } - - opstrace_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/6976' - - opstrace_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="gl-link">'.html_safe % { url: opstrace_link_url } - - link_end = '</a>'.html_safe - = html_escape(s_('Deprecations|The metrics feature was deprecated in GitLab 14.7.')) - = html_escape(s_('Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0.')) % {removal_link_start: removal_epic_link_start, link_end: link_end } - = html_escape(s_('Deprecations|For information on a possible replacement, %{opstrace_link_start} learn more about Opstrace %{link_end}.')) % {opstrace_link_start: opstrace_link_start, link_end: link_end } - = render 'projects/settings/operations/metrics_dashboard' = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/alert_management' diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml index 795544b75a2..5244587c16d 100644 --- a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml +++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs _('Packages & Registries'), project_settings_packages_and_registries_path(@project) +- add_to_breadcrumbs _('Package and registry settings'), project_settings_packages_and_registries_path(@project) - breadcrumb_title s_('ContainerRegistry|Clean up image tags') -- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages & Registries') +- page_title s_('ContainerRegistry|Clean up image tags'), _('Package and registry settings') - @content_class = 'limit-container-width' unless fluid_layout #js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data } diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index d579981ebc0..e0ac07f5f31 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _('Packages & Registries') -- page_title _('Packages & Registries') +- breadcrumb_title _('Package and registry settings') +- page_title _('Package and registry settings') - @content_class = 'limit-container-width' unless fluid_layout #js-registry-settings{ data: settings_data } diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 1f529849b28..e9d1661a4f1 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -7,6 +7,7 @@ = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") = render_if_exists 'projects/free_user_cap_alert', project: @project += render_if_exists 'shared/ultimate_feature_removal_banner', project: @project = render partial: 'flash_messages', locals: { project: @project } = render 'clusters_deprecation_alert' diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 2721f94134c..fda797f3228 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -10,7 +10,7 @@ .nav-controls #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path(search: @search, sort: @sort), sort_options: tags_sort_options_hash.to_json } } = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon has-tooltip gl-ml-auto' do - = sprite_icon('rss', css_class: 'gl-icon qa-rss-icon') + = sprite_icon('rss', css_class: 'gl-icon') - if can?(current_user, :admin_tag, @project) = link_to new_project_tag_path(@project), class: 'btn gl-button btn-confirm', data: { qa_selector: "new_tag_button" } do = s_('TagsPage|New tag') diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 3b546888375..79fc1a64790 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -2,22 +2,21 @@ - default_ref = params[:ref] || @project.default_branch - if @error - = render Pajamas::AlertComponent.new(variant: :danger, dismissible: true, close_button_options: { class: 'gl-alert-dismiss' }) do |c| + = render Pajamas::AlertComponent.new(variant: :danger, dismissible: true ) do |c| = c.body do = @error %h1.page-title.gl-font-size-h-display = s_('TagsPage|New Tag') -%hr = form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "common-note-form tag-form js-quick-submit js-requires-input" do .form-group.row - = label_tag :tag_name, nil, class: 'col-form-label col-sm-2' - .col-sm-10 + .col-sm-12 + = label_tag :tag_name, nil = text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" } .form-group.row - = label_tag :ref, 'Create from', class: 'col-form-label col-sm-2' - .col-sm-10.create-from + .col-sm-12.create-from + = label_tag :ref, 'Create from' .dropdown = hidden_field_tag :ref, default_ref = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide js-branch-select monospace', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do @@ -27,15 +26,14 @@ .form-text.text-muted = s_('TagsPage|Existing branch name, tag, or commit SHA') .form-group.row - = label_tag :message, nil, class: 'col-form-label col-sm-2' - .col-sm-10 + .col-sm-12 + = label_tag :message, nil = text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" } .form-text.text-muted = tag_description_help_text - %hr .form-group.row - = label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2' - .col-sm-10 + .col-sm-12 + = label_tag :release_description, s_('TagsPage|Release notes'), class: 'gl-mb-0' .form-text.mb-3 - link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe - releases_page_path = project_releases_path(@project) @@ -49,7 +47,7 @@ = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field' = render 'shared/notes/hints' - .form-actions.gl-display-flex + .gl-display-flex = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3', data: { qa_selector: "create_tag_button" }, type: 'submit' }) do = s_('TagsPage|Create tag') = render Pajamas::ButtonComponent.new(href: project_tags_path(@project)) do diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index d24cfd61052..9043b8e60fc 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -1,5 +1,5 @@ = form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f| - = form_errors(@trigger, pajamas_alert: true) + = form_errors(@trigger) - if @trigger.token .form-group diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index 3de9bce14d4..5e2217d3c9f 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -1,6 +1,6 @@ - page_title s_("UsageQuota|Usage") -= render_if_exists 'namespaces/free_user_cap/projects/usage_quota_limitations_banner' += render_if_exists 'shared/ultimate_feature_removal_banner', project: @project = render Pajamas::AlertComponent.new(title: _('Repository usage recalculation started'), variant: :info, diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index dcfab046514..ef5e3e83103 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -13,7 +13,7 @@ - if search_service.scope == 'blobs' = _("in") .mx-md-1 - = render partial: "shared/ref_switcher", locals: { ref: repository_ref(search_service.project), form_path: request.fullpath, field_name: 'repository_ref' } + #js-blob-ref-switcher{ data: { "project-id" => search_service.project.id, "ref" => repository_ref(search_service.project), "field-name": "repository_ref" } } = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - else = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } diff --git a/app/views/shared/_email_with_badge.html.haml b/app/views/shared/_email_with_badge.html.haml index 5d837657943..5013d8e439a 100644 --- a/app/views/shared/_email_with_badge.html.haml +++ b/app/views/shared/_email_with_badge.html.haml @@ -1,5 +1,6 @@ - variant = verified ? :success : :danger - text = verified ? _('Verified') : _('Unverified') -= email -= gl_badge_tag text, { variant: variant }, { class: 'gl-ml-3' } +%span.gl-mr-3 + = email += gl_badge_tag text, { variant: variant } diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index 130e73a069f..89be816fc76 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -1,6 +1,7 @@ #blob-content.file-content.code.js-syntax-highlight - offset = defined?(first_line_number) ? first_line_number : 1 - - blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) + - if Feature.enabled?(:file_line_blame) + - blame_path = project_blame_path(@project, tree_join(@ref, blob.path)) .line-numbers{ class: "gl-px-0!", data: { blame_path: blame_path } } - if blob.data.present? - link = blob_link if defined?(blob_link) diff --git a/app/views/shared/_integration_settings.html.haml b/app/views/shared/_integration_settings.html.haml index d58be0f0f4a..84710b2ecc7 100644 --- a/app/views/shared/_integration_settings.html.haml +++ b/app/views/shared/_integration_settings.html.haml @@ -1,4 +1,4 @@ -= form_errors(integration, pajamas_alert: true) += form_errors(integration) %div{ data: { testid: "integration-settings-form" } } - if @default_integration diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index a49a0667d84..7314a7ddadc 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -11,13 +11,13 @@ .md-header = gl_tabs_nav({ class: 'clearfix nav-links'}) do %li.md-header-tab.active - %button.js-md-write-button + %button.js-md-write-button{ class: 'gl-py-3!' } = _("Write") %li.md-header-tab - %button.js-md-preview-button + %button.js-md-preview-button{ class: 'gl-py-3!' } = _("Preview") - %li.md-header-toolbar.active + %li.md-header-toolbar.active.gl-py-2 = render 'shared/blob/markdown_buttons', show_fullscreen_button: true .md-write-holder diff --git a/app/views/shared/access_tokens/_created_container.html.haml b/app/views/shared/access_tokens/_created_container.html.haml index c5a18d98b89..c0aaa46e761 100644 --- a/app/views/shared/access_tokens/_created_container.html.haml +++ b/app/views/shared/access_tokens/_created_container.html.haml @@ -3,7 +3,7 @@ = _('Your new %{type}') % { type: type } .form-group .input-group - = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'qa-created-access-token form-control js-select-on-focus', 'aria-describedby' => 'created-token-help-block' + = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'form-control js-select-on-focus', data: { qa_selector: 'created_access_token_field' }, 'aria-describedby' => 'created-token-help-block' %span.input-group-append = clipboard_button(text: new_token_value, title: _('Copy %{type}') % { type: type }, placement: 'left', class: 'input-group-text btn-default btn-clipboard') %span#created-token-help-block.form-text.text-muted.text-danger diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index dd4d2ab46c1..0c88ac66b8b 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -13,7 +13,7 @@ = gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f| - = form_errors(token, pajamas_alert: true) + = form_errors(token) .row .form-group.col diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index c070baf02b1..c3835386d5a 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -1,7 +1,7 @@ - board = local_assigns.fetch(:board, nil) - @no_breadcrumb_container = true - @no_container = true -- @content_wrapper_class = "#{@content_wrapper_class} gl-relative" +- @content_wrapper_class = "#{@content_wrapper_class} gl-relative gl-pb-0" - @content_class = "issue-boards-content js-focus-mode-board" - is_epic_board = board.to_type == "EpicBoard" - if is_epic_board diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 38985319ca5..93f31629ca7 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -2,7 +2,7 @@ - deploy_key = local_assigns.fetch(:deploy_key) - deploy_keys_project = deploy_key.deploy_keys_project_for(@project) -= form_errors(deploy_key, pajamas_alert: true) += form_errors(deploy_key) .form-group = form.label :title, class: 'col-form-label col-sm-2' diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index 4bedce71c0f..d76ef8feb62 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f| - = form_errors(@deploy_keys.new_key, pajamas_alert: true) + = form_errors(@deploy_keys.new_key) .form-group.row = f.label :title, class: "label-bold" = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'deploy_key_title_field' } diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index aa4a3deaac4..79bf35e2726 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -1,4 +1,4 @@ -- expanded = expand_deploy_tokens_section?(@new_deploy_token) +- expanded = expand_deploy_tokens_section?(@new_deploy_token, @created_deploy_token) %section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } } .settings-header @@ -8,11 +8,10 @@ %p = description .settings-content - - if @new_deploy_token.persisted? - = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token + - if @created_deploy_token + = render 'shared/deploy_tokens/new_deploy_token', deploy_token: @created_deploy_token %h5.gl-mt-0 = s_('DeployTokens|New deploy token') = render 'shared/deploy_tokens/form', group_or_project: group_or_project, token: @new_deploy_token, presenter: @deploy_tokens %hr = render 'shared/deploy_tokens/table', group_or_project: group_or_project, active_tokens: @deploy_tokens - diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml index 9810754f52b..b40e2630011 100644 --- a/app/views/shared/doorkeeper/applications/_form.html.haml +++ b/app/views/shared/doorkeeper/applications/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f| - = form_errors(@application, pajamas_alert: true) + = form_errors(@application) .form-group = f.label :name, class: 'label-bold' diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml index f533b5b5a4d..562b1aee4ca 100644 --- a/app/views/shared/doorkeeper/applications/_show.html.haml +++ b/app/views/shared/doorkeeper/applications/_show.html.haml @@ -15,7 +15,14 @@ %td = _('Secret') %td - = clipboard_button(clipboard_text: @application.secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button") + - if Feature.enabled?('hash_oauth_secrets') + - if @application.plaintext_secret + = clipboard_button(clipboard_text: @application.plaintext_secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button") + %span= _('This is the only time the secret is accessible. Copy the secret and store it securely.') + - else + = _('The secret is only available when you first create the application.') + - else + = clipboard_button(clipboard_text: @application.secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button") %tr %td = _('Callback URL') diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 164773f9b60..f8304d5e44e 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -1,7 +1,7 @@ - user = local_assigns.fetch(:user, current_user) - access = user&.max_member_access_for_group(group.id) -%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!#{' no-description' if group.description.blank?}" } +%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!" } .avatar-container.rect-avatar.s40.gl-flex-shrink-0 = link_to group do = group_icon(group, class: "avatar s40") diff --git a/app/views/shared/groups/_visibility_level.html.haml b/app/views/shared/groups/_visibility_level.html.haml deleted file mode 100644 index 1a13de9b76a..00000000000 --- a/app/views/shared/groups/_visibility_level.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -= f.label :visibility_level, class: 'label-bold' do - = _('Visibility level') -.js-visibility-level-dropdown{ data: { visibility_level_options: visibility_level_options(@group).to_json, default_level: f.object.visibility_level } } diff --git a/app/views/shared/hook_logs/_recent_deliveries_table.html.haml b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml index 3c91c2f6ab4..500eb29fa93 100644 --- a/app/views/shared/hook_logs/_recent_deliveries_table.html.haml +++ b/app/views/shared/hook_logs/_recent_deliveries_table.html.haml @@ -23,7 +23,7 @@ - if hook_logs.present? - = paginate hook_logs, theme: 'gitlab' + = paginate_without_count hook_logs - else .gl-text-center.gl-mt-7 %h4= _('No webhook events') diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index f0e4b915ac8..69ff477d415 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,7 +1,7 @@ - show_calendar_button = local_assigns.fetch(:show_calendar_button, true) = link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') , 'aria-label': _('Subscribe to RSS feed') do - = sprite_icon('rss', css_class: 'qa-rss-icon') + = sprite_icon('rss') - if show_calendar_button = link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar'), 'aria-label': _('Subscribe to calendar') do diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index ae8b266c092..53eb6f4c63b 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -3,7 +3,7 @@ - project = @target_project || @project - presenter = local_assigns.fetch(:presenter, nil) -= form_errors(issuable, pajamas_alert: true) += form_errors(issuable) - if @conflict = render Pajamas::AlertComponent.new(variant: :danger, diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 6da094924a0..f2ce0676a9a 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -20,13 +20,7 @@ .js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| - - if signed_in && moved_sidebar_enabled - .block.to-do - .title.hide-collapsed.gl-font-weight-bold.gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mt-2{ class: 'gl-mb-0!' } - = _('To-Do') - .js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } } - - .block.assignee{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } } + .block.assignee.qa-assignee-block{ class: "#{'gl-mt-3' if !signed_in && moved_sidebar_enabled}", data: { qa_selector: 'assignee_block_container' } } = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in - if issuable_sidebar[:supports_severity] @@ -88,7 +82,8 @@ .js-sidebar-participants-entry-point .block.with-sub-blocks - #js-reference-entry-point + - if !moved_sidebar_enabled + #js-reference-entry-point - if issuable_type == 'merge_request' && !moved_sidebar_enabled .sub-block.js-sidebar-source-branch .sidebar-collapsed-icon.js-dont-change-state diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 2fd4c598580..e9b04579808 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -6,7 +6,7 @@ max_assignees: dropdown_options[:data][:"max-select"], directly_invite_members: can_admin_project_member?(@project) } } .title.hide-collapsed - = _('Assignee') + = s_('Label|Assignee') = gl_loading_icon(inline: true) .js-sidebar-assignee-data.selectbox.hide-collapsed diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index cd976b88304..3f78f29ea24 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -2,7 +2,7 @@ #js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } } .title.hide-collapsed - = _('Reviewer') + = _('Reviewers') = gl_loading_icon(inline: true) .selectbox.hide-collapsed diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 8ab002f755f..634e927f891 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -31,10 +31,14 @@ - if issuable.merged? %code= target_title - unless issuable.new_record? || issuable.merged? - %span.dropdown.gl-ml-2.d-inline-block - = form.hidden_field(:target_branch, - { class: 'target_branch js-target-branch-select ref-name mw-xl', - data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) + .merge-request-select.dropdown.gl-w-auto + = form.hidden_field :target_branch + = dropdown_toggle form.object.target_branch.presence || _("Select branch"), { toggle: "dropdown", 'field-name': "#{form.object_name}[target_branch]", 'refs-url': refs_project_path(@project, sort: 'updated_desc', find: 'branches'), selected: form.object.target_branch, default_text: _("Select branch") }, { toggle_class: "js-compare-dropdown js-target-branch monospace" } + .dropdown-menu.dropdown-menu-selectable.js-target-branch-dropdown.target_branch.ref-name.git-revision-dropdown + = dropdown_title(_("Select branch")) + = dropdown_filter(_("Search branches")) + = dropdown_content + = dropdown_loading - if source_level < target_level = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c| diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 5831460d59a..09086d3aa82 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -11,9 +11,9 @@ - if issuable.can_remove_source_branch?(current_user) .form-check.gl-mb-3 = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input' + = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input js-form-update' = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do - Delete source branch when merge request is accepted. + = _("Delete source branch when merge request is accepted.") - if !project.squash_never? .form-check - if project.squash_always? @@ -21,9 +21,9 @@ = check_box_tag 'merge_request[squash]', '1', project.squash_enabled_by_default?, class: 'form-check-input', disabled: 'true' - else = hidden_field_tag 'merge_request[squash]', '0', id: nil - = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input' + = check_box_tag 'merge_request[squash]', '1', issuable_squash_option?(issuable, project), class: 'form-check-input js-form-update' = label_tag 'merge_request[squash]', class: 'form-check-label' do - Squash commits when merge request is accepted. + = _("Squash commits when merge request is accepted.") = link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer' - if project.squash_always? .gl-text-gray-400 diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index efecffbcc2e..bd9afc3ce69 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -1,4 +1,4 @@ -= form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : _('Assignee'), class: "col-12" += form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : s_('SearchToken|Assignee'), class: "col-12" .col-12 .issuable-form-select-holder.selectbox - issuable.assignees.each do |assignee| diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml index d2c851a4e49..61e28f18d3b 100644 --- a/app/views/shared/issue_type/_emoji_block.html.haml +++ b/app/views/shared/issue_type/_emoji_block.html.haml @@ -4,8 +4,7 @@ .row.gl-m-0.gl-justify-content-space-between .js-noteable-awards = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path - .new-branch-col.gl-my-2.gl-font-size-0 + .new-branch-col.gl-display-flex.gl-my-2.gl-font-size-0.gl-gap-3 = render_if_exists "projects/issues/timeline_toggle", issuable: issuable - #js-vue-sort-issue-discussions #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } } = render 'new_branch' if show_new_branch_button? diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index cf8bd23b153..e6d6d0998dc 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -1,5 +1,5 @@ = form_for @label, as: :label, url: url, html: { class: 'label-form js-quick-submit js-requires-input' } do |f| - = form_errors(@label, pajamas_alert: true) + = form_errors(@label) .form-group.row .col-12 diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 59d1537aa2b..01548325c83 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -43,7 +43,8 @@ - if milestone.merge_requests_enabled? · = link_to pluralize(milestone.total_merge_requests_count, _('Merge request')), merge_requests_path - .float-lg-right.light #{milestone.percent_complete}% complete + .float-lg-right.light + = format(s_('Milestone|%{percentage}%{percent} complete'), percentage: milestone.percent_complete, percent: '%') .col-md-2 .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end - if @project # if in milestones list on project level diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 44740db5a00..fb000b9aab1 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -9,7 +9,7 @@ - else = html_escape(s_('MarkdownToolbar|Supports %{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd}')) % { markdownDocsLinkStart: markdownLinkStart, markdownDocsLinkEnd: '</a>'.html_safe } - if supports_file_upload - %span.uploading-container + %span.uploading-container.gl-line-height-32 %span.uploading-progress-container.hide = sprite_icon('paperclip', css_class: 'gl-icon gl-vertical-align-text-bottom') %span.attaching-file-message diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 51a5c9dd38f..a5170b199e8 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -1,5 +1,5 @@ - form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : '' -- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...' +- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : _('Filter by name') = form_tag filter_projects_path, method: :get, class: 'project-filter-form', data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index e0079a95cec..024b06fe97a 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -1,5 +1,5 @@ = gitlab_ui_form_for runner, url: runner_form_url do |f| - = form_errors(runner, pajamas_alert: true) + = form_errors(runner) .form-group.row = label :active, _("Active"), class: 'col-form-label col-sm-2' .col-sm-10 diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index fe68244f1da..afe72767b9a 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -1,4 +1,4 @@ -= form_errors(hook, pajamas_alert: true) += form_errors(hook) .form-group = form.label :url, s_('Webhooks|URL'), class: 'label-bold' diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml index 5d07b0f95ab..5ec82ad6702 100644 --- a/app/views/shared/web_hooks/_index.html.haml +++ b/app/views/shared/web_hooks/_index.html.haml @@ -1,5 +1,5 @@ %hr -.card +.card#webhooks-index .card-header %h5 = hook_class.underscore.humanize.titleize.pluralize diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml new file mode 100644 index 00000000000..d9155b397b8 --- /dev/null +++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml @@ -0,0 +1,13 @@ +- return unless show_project_hook_failed_callout?(project: @project) + +- content_for :after_flash_content do + = render Pajamas::AlertComponent.new(variant: :danger, + title: s_('Webhooks|Webhook disabled'), + alert_options: { class: 'gl-my-4 js-web-hook-disabled-callout', + data: { feature_id: Users::CalloutsHelper::WEB_HOOK_DISABLED, dismiss_endpoint: project_callouts_path, project_id: @project.id, defer_links: 'true'} }) do |c| + = c.body do + = s_('Webhooks|A webhook in this project was automatically disabled after being retried multiple times.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshoot-webhooks'), target: '_blank', rel: 'noopener noreferrer' + = c.actions do + = link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button' diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index 0d5e59919cb..34bedbd928a 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -1,6 +1,6 @@ - page_info = { last_commit_sha: @page.last_commit_sha, persisted: @page.persisted?, title: @page.title, content: @page.content || '', format: @page.format.to_s, uploads_path: uploads_path, path: wiki_page_path(@wiki, @page), wiki_path: wiki_path(@wiki), help_path: help_page_path('user/project/wiki/index'), markdown_help_path: help_page_path('user/markdown'), markdown_preview_path: wiki_page_path(@wiki, @page, action: :preview_markdown), create_path: wiki_path(@wiki, action: :create) } .gl-mt-3 - = form_errors(@page, truncate: :title, pajamas_alert: true) + = form_errors(@page, truncate: :title) #js-wiki-form{ data: { page_info: page_info.to_json, format_options: wiki_markup_hash_by_name_id.to_json } } diff --git a/app/views/shared/wikis/_wiki_content.html.haml b/app/views/shared/wikis/_wiki_content.html.haml index 42e8037bb0f..780e4c4746d 100644 --- a/app/views/shared/wikis/_wiki_content.html.haml +++ b/app/views/shared/wikis/_wiki_content.html.haml @@ -1,2 +1,2 @@ -.js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } } +.js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json } } = render_wiki_content(@page) diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml index dab3b940b9a..12eddb4a61e 100644 --- a/app/views/shared/wikis/git_error.html.haml +++ b/app/views/shared/wikis/git_error.html.haml @@ -9,6 +9,6 @@ .gl-mt-5.gl-mb-3 .gl-display-flex.gl-justify-content-space-between %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page') - .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content' } } + .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content' } } = _('The page could not be displayed because it timed out.') = html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe } diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 6591e8fae7b..3841113231c 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -27,6 +27,6 @@ - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding = link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' } - .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } + .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } = render 'shared/wikis/sidebar' diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 25070138128..952023b3745 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -36,7 +36,7 @@ - if can?(current_user, :read_user_profile, @user) = link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do - = sprite_icon('rss', css_class: 'qa-rss-icon') + = sprite_icon('rss') - if current_user && current_user.admin? = link_to [:admin, @user], class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 8bba5e36b52..9b282340d0a 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -786,6 +786,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:users_migrate_records_to_ghost_user_in_batches + :worker_name: Users::MigrateRecordsToGhostUserInBatchesWorker + :feature_category: :users + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:x509_issuer_crl_check :worker_name: X509IssuerCrlCheckWorker :feature_category: :source_code_management @@ -1056,6 +1065,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_import_protected_branch + :worker_name: Gitlab::GithubImport::ImportProtectedBranchWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_import_pull_request :worker_name: Gitlab::GithubImport::ImportPullRequestWorker :feature_category: :importers @@ -1083,6 +1101,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_import_release_attachments + :worker_name: Gitlab::GithubImport::ImportReleaseAttachmentsWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_refresh_import_jid :worker_name: Gitlab::GithubImport::RefreshImportJidWorker :feature_category: :importers @@ -1101,6 +1128,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_stage_import_attachments + :worker_name: Gitlab::GithubImport::Stage::ImportAttachmentsWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_stage_import_base_data :worker_name: Gitlab::GithubImport::Stage::ImportBaseDataWorker :feature_category: :importers @@ -1146,6 +1182,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: github_importer:github_import_stage_import_protected_branches + :worker_name: Gitlab::GithubImport::Stage::ImportProtectedBranchesWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: github_importer:github_import_stage_import_pull_requests :worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsWorker :feature_category: :importers @@ -1578,6 +1623,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: pipeline_background:ci_job_artifacts_track_artifact_report + :worker_name: Ci::JobArtifacts::TrackArtifactReportWorker + :feature_category: :code_testing + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: pipeline_background:ci_pending_builds_update_group :worker_name: Ci::PendingBuilds::UpdateGroupWorker :feature_category: :continuous_integration @@ -2379,6 +2433,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: google_cloud_fetch_google_ip_list + :worker_name: GoogleCloud::FetchGoogleIpListWorker + :feature_category: :build_artifacts + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: group_destroy :worker_name: GroupDestroyWorker :feature_category: :subgroups @@ -2415,6 +2478,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: groups_update_two_factor_requirement_for_members + :worker_name: Groups::UpdateTwoFactorRequirementForMembersWorker + :feature_category: :authentication_and_authorization + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: import_issues_csv :worker_name: ImportIssuesCsvWorker :feature_category: :team_planning @@ -2496,6 +2568,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: issues_close + :worker_name: Issues::CloseWorker + :feature_category: :source_code_management + :has_external_dependencies: false + :urgency: :high + :resource_boundary: :unknown + :weight: 2 + :idempotent: true + :tags: [] - :name: issues_placement :worker_name: Issues::PlacementWorker :feature_category: :team_planning diff --git a/app/workers/analytics/usage_trends/counter_job_worker.rb b/app/workers/analytics/usage_trends/counter_job_worker.rb index b3a8f7dd3c2..e6de623f784 100644 --- a/app/workers/analytics/usage_trends/counter_job_worker.rb +++ b/app/workers/analytics/usage_trends/counter_job_worker.rb @@ -3,6 +3,8 @@ module Analytics module UsageTrends class CounterJobWorker + TIMEOUT = 250.seconds + extend ::Gitlab::Utils::Override include ApplicationWorker @@ -15,24 +17,27 @@ module Analytics idempotent! - def perform(measurement_identifier, min_id, max_id, recorded_at) + def perform(measurement_identifier, min_id, max_id, recorded_at, partial_results = nil) query_scope = ::Analytics::UsageTrends::Measurement.identifier_query_mapping[measurement_identifier].call - count = if min_id.nil? || max_id.nil? # table is empty - 0 - else - counter(query_scope, min_id, max_id) - end + result = counter(query_scope, min_id, max_id, partial_results) + + # If the batch counter timed out, schedule a job to continue counting later + if result[:status] == :timeout + return self.class.perform_async(measurement_identifier, result[:continue_from], max_id, recorded_at, result[:partial_results]) + end - return if count == Gitlab::Database::BatchCounter::FALLBACK + return if result[:status] != :completed - UsageTrends::Measurement.insert_all([{ recorded_at: recorded_at, count: count, identifier: measurement_identifier }]) + UsageTrends::Measurement.insert_all([{ recorded_at: recorded_at, count: result[:count], identifier: measurement_identifier }]) end private - def counter(query_scope, min_id, max_id) - Gitlab::Database::BatchCount.batch_count(query_scope, start: min_id, finish: max_id) + def counter(query_scope, min_id, max_id, partial_results) + return { status: :completed, count: 0 } if min_id.nil? || max_id.nil? # table is empty + + Gitlab::Database::BatchCount.batch_count_with_timeout(query_scope, start: min_id, finish: max_id, timeout: TIMEOUT, partial_results: partial_results) end end end diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb index 36a50735fed..7503ea3d800 100644 --- a/app/workers/ci/build_finished_worker.rb +++ b/app/workers/ci/build_finished_worker.rb @@ -36,10 +36,10 @@ module Ci build.update_coverage Ci::BuildReportResultService.new.execute(build) - build.feature_flagged_execute_hooks + build.execute_hooks ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? build.track_deployment_usage - build.track_verify_usage + build.track_verify_environment_usage if build.failed? && !build.auto_retry_expected? ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id) diff --git a/app/workers/ci/job_artifacts/track_artifact_report_worker.rb b/app/workers/ci/job_artifacts/track_artifact_report_worker.rb new file mode 100644 index 00000000000..3df8c284ab3 --- /dev/null +++ b/app/workers/ci/job_artifacts/track_artifact_report_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class TrackArtifactReportWorker + include ApplicationWorker + + data_consistency :delayed + + include PipelineBackgroundQueue + + feature_category :code_testing + + idempotent! + + def perform(pipeline_id) + Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + Ci::JobArtifacts::TrackArtifactReportService.new.execute(pipeline) + end + end + end + end +end diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb index 127eb3b6f44..53bed0fa9da 100644 --- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb @@ -20,7 +20,7 @@ module Ci return unless pipeline pipeline.root_ancestor.try do |root_ancestor_pipeline| - next unless root_ancestor_pipeline.self_and_descendants_complete? + next unless root_ancestor_pipeline.self_and_project_descendants_complete? Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute end diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb index 73501315575..3a506470743 100644 --- a/app/workers/cleanup_container_repository_worker.rb +++ b/app/workers/cleanup_container_repository_worker.rb @@ -24,7 +24,7 @@ class CleanupContainerRepositoryWorker return unless valid? Projects::ContainerRepository::CleanupTagsService - .new(container_repository, current_user, params) + .new(container_repository: container_repository, current_user: current_user, params: params) .execute end diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb index e21a7ee35e7..8c7e17587d0 100644 --- a/app/workers/flush_counter_increments_worker.rb +++ b/app/workers/flush_counter_increments_worker.rb @@ -11,6 +11,7 @@ class FlushCounterIncrementsWorker data_consistency :always sidekiq_options retry: 3 + loggable_arguments 0, 2 # The increments in `ProjectStatistics` are owned by several teams depending # on the counter diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 70d18d8004c..fdf4ec6f396 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -25,6 +25,8 @@ module Gitlab issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker, issue_events: Stage::ImportIssueEventsWorker, notes: Stage::ImportNotesWorker, + attachments: Stage::ImportAttachmentsWorker, + protected_branches: Stage::ImportProtectedBranchesWorker, lfs_objects: Stage::ImportLfsObjectsWorker, finish: Stage::FinishImportWorker }.freeze diff --git a/app/workers/gitlab/github_import/import_protected_branch_worker.rb b/app/workers/gitlab/github_import/import_protected_branch_worker.rb new file mode 100644 index 00000000000..c083d8ee867 --- /dev/null +++ b/app/workers/gitlab/github_import/import_protected_branch_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportProtectedBranchWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + worker_resource_boundary :cpu + + def representation_class + Gitlab::GithubImport::Representation::ProtectedBranch + end + + def importer_class + Importer::ProtectedBranchImporter + end + + def object_type + :protected_branch + end + end + end +end diff --git a/app/workers/gitlab/github_import/import_release_attachments_worker.rb b/app/workers/gitlab/github_import/import_release_attachments_worker.rb new file mode 100644 index 00000000000..c6f45ec1d7c --- /dev/null +++ b/app/workers/gitlab/github_import/import_release_attachments_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportReleaseAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker + include ObjectImporter + + def representation_class + Representation::ReleaseAttachments + end + + def importer_class + Importer::ReleaseAttachmentsImporter + end + + def object_type + :release_attachment + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb new file mode 100644 index 00000000000..e9086dca503 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 5 + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + return skip_to_next_stage(project) if feature_disabled?(project) + + waiters = importers.each_with_object({}) do |importer, hash| + waiter = start_importer(project, importer, client) + hash[waiter.key] = waiter.jobs_remaining + end + move_to_next_stage(project, waiters) + end + + private + + # For future issue/mr/milestone/etc attachments importers + def importers + [::Gitlab::GithubImport::Importer::ReleasesAttachmentsImporter] + end + + def start_importer(project, importer, client) + info(project.id, message: "starting importer", importer: importer.name) + importer.new(project, client).execute + end + + def skip_to_next_stage(project) + info(project.id, message: "skipping importer", importer: 'Attachments') + move_to_next_stage(project) + end + + def move_to_next_stage(project, waiters = {}) + AdvanceStageWorker.perform_async( + project.id, + waiters, + :protected_branches + ) + end + + def feature_disabled?(project) + Feature.disabled?(:github_importer_attachments_import, project.group, type: :ops) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index 167b3e147a3..b53e31ce40e 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -21,11 +21,7 @@ module Gitlab hash[waiter.key] = waiter.jobs_remaining end - AdvanceStageWorker.perform_async( - project.id, - waiters, - :lfs_objects - ) + AdvanceStageWorker.perform_async(project.id, waiters, :attachments) end def importers(project) diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb new file mode 100644 index 00000000000..6d6dea10e64 --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportProtectedBranchesWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 3 + include GithubImport::Queue + include StageMethods + + # client - An instance of Gitlab::GithubImport::Client. + # project - An instance of Project. + def import(client, project) + info(project.id, + message: "starting importer", + importer: 'Importer::ProtectedBranchesImporter') + waiter = Importer::ProtectedBranchesImporter + .new(project, client) + .execute + + project.import_state.refresh_jid_expiration + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :lfs_objects + ) + rescue StandardError => e + Gitlab::Import::ImportFailureService.track( + project_id: project.id, + error_source: self.class.name, + exception: e, + metrics: true + ) + + raise(e) + end + end + end + end +end diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb index 3824cc1f3ef..eabe988dfc2 100644 --- a/app/workers/gitlab/jira_import/import_issue_worker.rb +++ b/app/workers/gitlab/jira_import/import_issue_worker.rb @@ -15,8 +15,7 @@ module Gitlab loggable_arguments 3 def perform(project_id, jira_issue_id, issue_attributes, waiter_key) - issue_id = create_issue(issue_attributes, project_id) - JiraImport.cache_issue_mapping(issue_id, jira_issue_id, project_id) + create_issue(issue_attributes, project_id) rescue StandardError => ex # Todo: Record jira issue id(or better jira issue key), # so that we can report the list of failed to import issues to the user diff --git a/app/workers/gitlab_service_ping_worker.rb b/app/workers/gitlab_service_ping_worker.rb index a974667e5e0..b02e7318585 100644 --- a/app/workers/gitlab_service_ping_worker.rb +++ b/app/workers/gitlab_service_ping_worker.rb @@ -15,17 +15,24 @@ class GitlabServicePingWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3, dead: false sidekiq_retry_in { |count| (count + 1) * 8.hours.to_i } - def perform - # Disable service ping for GitLab.com + def perform(options = {}) + # Sidekiq does not support keyword arguments, so the args need to be + # passed the old pre-Ruby 2.0 way. + # + # See https://github.com/mperham/sidekiq/issues/2372 + triggered_from_cron = options.fetch('triggered_from_cron', true) + skip_db_write = options.fetch('skip_db_write', false) + + # Disable service ping for GitLab.com unless called manually # See https://gitlab.com/gitlab-org/gitlab/-/issues/292929 for details - return if Gitlab.com? + return if Gitlab.com? && triggered_from_cron # Multiple Sidekiq workers could run this. We should only do this at most once a day. in_lock(LEASE_KEY, ttl: LEASE_TIMEOUT) do # Splay the request over a minute to avoid thundering herd problems. sleep(rand(0.0..60.0).round(3)) - ServicePing::SubmitService.new(payload: usage_data).execute + ServicePing::SubmitService.new(payload: usage_data, skip_db_write: skip_db_write).execute end end diff --git a/app/workers/google_cloud/create_cloudsql_instance_worker.rb b/app/workers/google_cloud/create_cloudsql_instance_worker.rb index 3c15c59b8d9..8c4f4c83339 100644 --- a/app/workers/google_cloud/create_cloudsql_instance_worker.rb +++ b/app/workers/google_cloud/create_cloudsql_instance_worker.rb @@ -8,30 +8,15 @@ module GoogleCloud feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned idempotent! - def perform(user_id, project_id, options = {}) + def perform(user_id, project_id, params = {}) user = User.find(user_id) project = Project.find(project_id) + params = params.with_indifferent_access - google_oauth2_token = options[:google_oauth2_token] - gcp_project_id = options[:gcp_project_id] - instance_name = options[:instance_name] - database_version = options[:database_version] - environment_name = options[:environment_name] - is_protected = options[:is_protected] - - params = { - google_oauth2_token: google_oauth2_token, - gcp_project_id: gcp_project_id, - instance_name: instance_name, - database_version: database_version, - environment_name: environment_name, - is_protected: is_protected - } - - response = GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute + response = ::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute if response[:status] == :error - raise response[:message] + raise "Error SetupCloudsqlInstanceService: #{response.to_json}" end end end diff --git a/app/workers/google_cloud/fetch_google_ip_list_worker.rb b/app/workers/google_cloud/fetch_google_ip_list_worker.rb new file mode 100644 index 00000000000..b14b4e735dc --- /dev/null +++ b/app/workers/google_cloud/fetch_google_ip_list_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module GoogleCloud + class FetchGoogleIpListWorker + include ApplicationWorker + + data_consistency :delayed + feature_category :build_artifacts + urgency :low + deduplicate :until_executing + idempotent! + + def perform + GoogleCloud::FetchGoogleIpListService.new.execute + end + end +end diff --git a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb new file mode 100644 index 00000000000..ac1d3589516 --- /dev/null +++ b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Worker for updating two factor requirement for all group members +module Groups + class UpdateTwoFactorRequirementForMembersWorker + include ApplicationWorker + + data_consistency :always + + idempotent! + + feature_category :authentication_and_authorization + + def perform(group_id) + group = Group.find_by_id(group_id) + + return unless group + + group.update_two_factor_requirement_for_members + end + end +end diff --git a/app/workers/issues/close_worker.rb b/app/workers/issues/close_worker.rb new file mode 100644 index 00000000000..0d540ee8c4f --- /dev/null +++ b/app/workers/issues/close_worker.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Issues + class CloseWorker + include ApplicationWorker + + data_consistency :always + + sidekiq_options retry: 3 + + idempotent! + deduplicate :until_executed, including_scheduled: true + feature_category :source_code_management + urgency :high + weight 2 + + def perform(project_id, issue_id, issue_type, params = {}) + project = Project.find_by_id(project_id) + + unless project + logger.info(structured_payload(message: "Project not found.", project_id: project_id)) + return + end + + issue = case issue_type + when "ExternalIssue" + ExternalIssue.new(issue_id, project) + else + Issue.find_by_id(issue_id) + end + + unless issue + logger.info(structured_payload(message: "Issue not found.", issue_id: issue_id)) + return + end + + author = User.find_by_id(params["closed_by"]) + + unless author + logger.info(structured_payload(message: "User not found.", user_id: params["closed_by"])) + return + end + + commit = Commit.build_from_sidekiq_hash(project, params["commit_hash"]) + service = Issues::CloseService.new(project: project, current_user: author) + + service.execute(issue, commit: commit) + end + end +end diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb index aab5767e0f1..4f0cc71cd91 100644 --- a/app/workers/namespaces/onboarding_issue_created_worker.rb +++ b/app/workers/namespaces/onboarding_issue_created_worker.rb @@ -18,7 +18,7 @@ module Namespaces namespace = Namespace.find_by_id(namespace_id) return unless namespace - OnboardingProgressService.new(namespace).execute(action: :issue_created) + Onboarding::ProgressService.new(namespace).execute(action: :issue_created) end end end diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/namespaces/onboarding_pipeline_created_worker.rb index 4172e286474..c3850880df0 100644 --- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb +++ b/app/workers/namespaces/onboarding_pipeline_created_worker.rb @@ -18,7 +18,7 @@ module Namespaces namespace = Namespace.find_by_id(namespace_id) return unless namespace - OnboardingProgressService.new(namespace).execute(action: :pipeline_created) + Onboarding::ProgressService.new(namespace).execute(action: :pipeline_created) end end end diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb index 77a31d85a9a..49629428459 100644 --- a/app/workers/namespaces/onboarding_progress_worker.rb +++ b/app/workers/namespaces/onboarding_progress_worker.rb @@ -19,7 +19,7 @@ module Namespaces namespace = Namespace.find_by_id(namespace_id) return unless namespace && action - OnboardingProgressService.new(namespace).execute(action: action.to_sym) + Onboarding::ProgressService.new(namespace).execute(action: action.to_sym) end end end diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb index 4d17cf9a6e2..a1b349eedd3 100644 --- a/app/workers/namespaces/onboarding_user_added_worker.rb +++ b/app/workers/namespaces/onboarding_user_added_worker.rb @@ -15,7 +15,7 @@ module Namespaces def perform(namespace_id) namespace = Namespace.find(namespace_id) - OnboardingProgressService.new(namespace).execute(action: :user_added) + Onboarding::ProgressService.new(namespace).execute(action: :user_added) end end end diff --git a/app/workers/namespaces/process_sync_events_worker.rb b/app/workers/namespaces/process_sync_events_worker.rb index 2bf2a4a6ef8..d0124c69781 100644 --- a/app/workers/namespaces/process_sync_events_worker.rb +++ b/app/workers/namespaces/process_sync_events_worker.rb @@ -13,7 +13,7 @@ module Namespaces urgency :high idempotent! - deduplicate :until_executing + deduplicate :until_executed, if_deduplicated: :reschedule_once def perform results = ::Ci::ProcessSyncEventsService.new( diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index b7d938e6b68..3e681c3f111 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -11,7 +11,7 @@ module ObjectStorage include ObjectStorageQueue feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned - loggable_arguments 0, 1, 2, 3 + loggable_arguments 0 SanityCheckError = Class.new(StandardError) @@ -67,41 +67,19 @@ module ObjectStorage include Report # rubocop: disable CodeReuse/ActiveRecord - def self.enqueue!(uploads, model_class, mounted_as, to_store) - sanity_check!(uploads, model_class, mounted_as) - - perform_async(uploads.ids, model_class.to_s, mounted_as, to_store) + def self.enqueue!(uploads, to_store) + perform_async(uploads.ids, to_store) end # rubocop: enable CodeReuse/ActiveRecord - # We need to be sure all the uploads are for the same uploader and model type - # and that the mount point exists if provided. - # - def self.sanity_check!(uploads, model_class, mounted_as) - upload = uploads.first - uploader_class = upload.uploader.constantize - uploader_types = uploads.map(&:uploader).uniq - model_types = uploads.map(&:model_type).uniq - model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class - - raise(SanityCheckError, _("Multiple uploaders found: %{uploader_types}") % { uploader_types: uploader_types }) unless uploader_types.count == 1 - raise(SanityCheckError, _("Multiple model types found: %{model_types}") % { model_types: model_types }) unless model_types.count == 1 - raise(SanityCheckError, _("Mount point %{mounted_as} not found in %{model_class}.") % { mounted_as: mounted_as, model_class: model_class }) unless model_has_mount - end - # rubocop: disable CodeReuse/ActiveRecord def perform(*args) - args_check!(args) - - (ids, model_type, mounted_as, to_store) = args + ids, to_store = retrieve_applicable_args!(args) - @model_class = model_type.constantize - @mounted_as = mounted_as&.to_sym @to_store = to_store uploads = Upload.preload(:model).where(id: ids) - sanity_check!(uploads) results = migrate(uploads) report!(results) @@ -111,31 +89,22 @@ module ObjectStorage end # rubocop: enable CodeReuse/ActiveRecord - def sanity_check!(uploads) - self.class.sanity_check!(uploads, @model_class, @mounted_as) - end - - def args_check!(args) - return if args.count == 4 + private - case args.count - when 3 then raise SanityCheckError, _("Job is missing the `model_type` argument.") - else - raise SanityCheckError, _("Job has wrong arguments format.") - end - end + def retrieve_applicable_args!(args) + return args if args.count == 2 + return args.values_at(0, 3) if args.count == 4 - def build_uploaders(uploads) - uploads.map { |upload| upload.retrieve_uploader(@mounted_as) } + raise SanityCheckError, _("Job has wrong arguments format.") end def migrate(uploads) - build_uploaders(uploads).map(&method(:process_uploader)) + uploads.map(&method(:process_upload)) end - def process_uploader(uploader) - MigrationResult.new(uploader.upload).tap do |result| - uploader.migrate!(@to_store) + def process_upload(upload) + MigrationResult.new(upload).tap do |result| + upload.retrieve_uploader.migrate!(@to_store) rescue StandardError => e result.error = e end diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index a4dfe11c394..cd6ce6eb28b 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -34,7 +34,7 @@ class ProcessCommitWorker return unless user - commit = build_commit(project, commit_hash) + commit = Commit.build_from_sidekiq_hash(project, commit_hash) author = commit.author || user process_commit_message(project, commit, user, author, default) @@ -51,12 +51,22 @@ class ProcessCommitWorker end def close_issues(project, user, author, commit, issues) - # We don't want to run permission related queries for every single issue, - # therefore we use IssueCollection here and skip the authorization check in - # Issues::CloseService#execute. - IssueCollection.new(issues).updatable_by_user(user).each do |issue| - Issues::CloseService.new(project: project, current_user: author) - .close_issue(issue, closed_via: commit) + if Feature.enabled?(:process_issue_closure_in_background, project) + Issues::CloseWorker.bulk_perform_async_with_contexts( + issues, + arguments_proc: -> (issue) { + [project.id, issue.id, issue.class.to_s, { closed_by: author.id, commit_hash: commit.to_hash }] + }, + context_proc: -> (issue) { { project: project } } + ) + else + # We don't want to run permission related queries for every single issue, + # therefore we use IssueCollection here and skip the authorization check in + # Issues::CloseService#execute. + IssueCollection.new(issues).updatable_by_user(user).each do |issue| + Issues::CloseService.new(project: project, current_user: author) + .close_issue(issue, closed_via: commit) + end end end @@ -75,19 +85,4 @@ class ProcessCommitWorker .with_first_mention_not_earlier_than(commit.committed_date) .update_all(first_mentioned_in_commit_at: commit.committed_date) end - - def build_commit(project, hash) - date_suffix = '_date' - - # When processing Sidekiq payloads various timestamps are stored as Strings. - # Commit in turn expects Time-like instances upon input, so we have to - # manually parse these values. - hash.each do |key, value| - if key.to_s.end_with?(date_suffix) && value.is_a?(String) - hash[key] = Time.zone.parse(value) - end - end - - Commit.from_hash(hash, project) - end end diff --git a/app/workers/projects/inactive_projects_deletion_cron_worker.rb b/app/workers/projects/inactive_projects_deletion_cron_worker.rb index a280c9203d6..ba6d44ec4a5 100644 --- a/app/workers/projects/inactive_projects_deletion_cron_worker.rb +++ b/app/workers/projects/inactive_projects_deletion_cron_worker.rb @@ -39,8 +39,6 @@ module Projects raise TimeoutError end - next unless Feature.enabled?(:inactive_projects_deletion, project.root_namespace) - with_context(project: project, user: admin_user) do deletion_warning_email_sent_on = notified_inactive_projects["project:#{project.id}"] diff --git a/app/workers/projects/process_sync_events_worker.rb b/app/workers/projects/process_sync_events_worker.rb index 57f3e3dee5e..4bbe1b65e5a 100644 --- a/app/workers/projects/process_sync_events_worker.rb +++ b/app/workers/projects/process_sync_events_worker.rb @@ -13,7 +13,7 @@ module Projects urgency :high idempotent! - deduplicate :until_executing + deduplicate :until_executed, if_deduplicated: :reschedule_once def perform results = ::Ci::ProcessSyncEventsService.new( diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb index dc1efce51ce..768579214c6 100644 --- a/app/workers/ssh_keys/expired_notification_worker.rb +++ b/app/workers/ssh_keys/expired_notification_worker.rb @@ -15,19 +15,20 @@ module SshKeys # rubocop: disable CodeReuse/ActiveRecord def perform - order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'expires_at_utc', - order_expression: Arel.sql("date(expires_at AT TIME ZONE 'UTC')").asc, - nullable: :not_nullable, - distinct: false, - add_to_projections: true - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'id', - order_expression: Key.arel_table[:id].asc - ) - ]) + order = Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'expires_at_utc', + order_expression: Arel.sql("date(expires_at AT TIME ZONE 'UTC')").asc, + nullable: :not_nullable, + distinct: false, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: Key.arel_table[:id].asc + ) + ]) scope = Key.expired_today_and_not_notified.order(order) diff --git a/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb new file mode 100644 index 00000000000..ddddfc106ae --- /dev/null +++ b/app/workers/users/migrate_records_to_ghost_user_in_batches_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Users + class MigrateRecordsToGhostUserInBatchesWorker + include ApplicationWorker + include Gitlab::ExclusiveLeaseHelpers + include CronjobQueue # rubocop: disable Scalability/CronWorkerContext + + sidekiq_options retry: false + feature_category :users + data_consistency :always + idempotent! + + def perform + return unless Feature.enabled?(:user_destroy_with_limited_execution_time_worker) + + in_lock(self.class.name.underscore, ttl: Gitlab::Utils::ExecutionTracker::MAX_RUNTIME, retries: 0) do + Users::MigrateRecordsToGhostUserInBatchesService.new.execute + end + end + end +end |