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/assets/javascripts | |
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/assets/javascripts')
681 files changed, 11518 insertions, 4605 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/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql deleted file mode 100644 index 45a7793e559..00000000000 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ /dev/null @@ -1,66 +0,0 @@ -#import "ee_else_ce/repository/queries/path_locks.fragment.graphql" - -query getBlobInfo( - $projectPath: ID! - $filePath: String! - $ref: String! - $shouldFetchRawText: Boolean! -) { - project(fullPath: $projectPath) { - userPermissions { - pushCode - downloadCode - createMergeRequestIn - forkProject - } - ...ProjectPathLocksFragment - repository { - empty - blobs(paths: [$filePath], ref: $ref) { - nodes { - id - webPath - name - size - rawSize - rawTextBlob @include(if: $shouldFetchRawText) - fileType - language - path - blamePath - editBlobPath - gitpodBlobUrl - ideEditPath - forkAndEditPath - ideForkAndEditPath - codeNavigationPath - projectBlobPathRoot - forkAndViewPath - environmentFormattedExternalUrl - environmentExternalUrlForRouteMap - canModifyBlob - canCurrentUserPushToBranch - archived - storedExternally - externalStorage - externalStorageUrl - rawPath - replacePath - pipelineEditorPath - simpleViewer { - fileType - tooLarge - type - renderError - } - richViewer { - fileType - tooLarge - type - renderError - } - } - } - } - } -} 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> |