diff options
Diffstat (limited to 'app/assets')
35 files changed, 747 insertions, 191 deletions
diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js index 8903b742537..961cecee298 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ b/app/assets/javascripts/ci_variable_list/store/mutations.js @@ -74,7 +74,7 @@ export default { variable_type: displayText.variableText, key: '', secret_value: '', - protected: false, + protected_variable: false, masked: false, environment_scope: displayText.allEnvironmentsText, }; @@ -103,7 +103,7 @@ export default { }, [types.SET_VARIABLE_PROTECTED](state) { - state.variable.protected = true; + state.variable.protected_variable = true; }, [types.UPDATE_VARIABLE_KEY](state, key) { diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index d94adc3760f..ae119c2b1fd 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -1,6 +1,5 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index e827aacac13..c64839e5019 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -34,15 +34,6 @@ export default { panelResizing: resizing, }); }, - [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { - Object.assign(entry.lastCommit, { - id: lastCommit.commit.id, - url: lastCommit.commit_path, - message: lastCommit.commit.message, - author: lastCommit.commit.author_name, - updatedAt: lastCommit.commit.authored_date, - }); - }, [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) { Object.assign(state, { lastCommitMsg, diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 1c5fe9fe9a5..f074e6880d0 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -25,13 +25,6 @@ export const dataStructure = () => ({ changed: false, staged: false, lastCommitSha: '', - lastCommit: { - id: '', - url: '', - message: '', - updatedAt: '', - author: '', - }, rawPath: '', binary: false, raw: '', diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index ae8c586ff8c..fe6ca3a2a07 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -127,6 +127,7 @@ export default { 'projectPath', 'canAccessOperationsSettings', 'operationsSettingsPath', + 'currentDashboard', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), isOutOfTheBoxDashboard() { @@ -164,11 +165,14 @@ export default { methods: { ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']), selectDashboard(dashboard) { - const params = { - dashboard: encodeURIComponent(dashboard.path), - }; - - redirectTo(mergeUrlParams(params, window.location.href)); + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent( + dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, + ); + redirectTo(`${baseURL}/${dashboardPath}`); }, debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) { this.filterEnvironments(searchTerm); @@ -193,6 +197,17 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, + getEnvironmentPath(environment) { + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent(this.currentDashboard || ''); + // The environment_metrics_spec.rb requires the URL to not have + // slashes. Hence, this additional check. + const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL; + return mergeUrlParams({ environment }, url); + }, }, modalIds: { addMetric: 'addMetric', @@ -255,7 +270,7 @@ export default { :key="environment.id" :active="environment.name === currentEnvironmentName" active-class="is-active" - :href="environment.metrics_path" + :href="getEnvironmentPath(environment.id)" >{{ environment.name }}</gl-dropdown-item > </div> diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index 6e7df9efbb1..307154c9a84 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -1,6 +1,5 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import { getParameterValues } from '~/lib/utils/url_utility'; import { createStore } from './stores'; import createRouter from './router'; import { stateAndPropsFromDataset } from './utils'; @@ -11,11 +10,9 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - const [encodedDashboard] = getParameterValues('dashboard'); - const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null; const { metricsDashboardBasePath, ...dataset } = el.dataset; - const { initState, dataProps } = stateAndPropsFromDataset({ currentDashboard, ...dataset }); + const { initState, dataProps } = stateAndPropsFromDataset(dataset); const store = createStore(initState); const router = createRouter(metricsDashboardBasePath); diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue index 519a20d7be3..df0e2d7f8f6 100644 --- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue +++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import Dashboard from '../components/dashboard.vue'; export default { @@ -11,6 +12,16 @@ export default { required: true, }, }, + created() { + // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path + // and the new format <project>/-/metrics/:dashboardPath + const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard; + const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null; + this.setCurrentDashboard({ currentDashboard }); + }, + methods: { + ...mapActions('monitoringDashboard', ['setCurrentDashboard']), + }, }; </script> <template> diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js index acfcd03f928..fedfebe33e9 100644 --- a/app/assets/javascripts/monitoring/router/constants.js +++ b/app/assets/javascripts/monitoring/router/constants.js @@ -1,3 +1,4 @@ export const BASE_DASHBOARD_PAGE = 'dashboard'; +export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard'; export default {}; diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js index 1e0cc1715a7..4b82791178a 100644 --- a/app/assets/javascripts/monitoring/router/routes.js +++ b/app/assets/javascripts/monitoring/router/routes.js @@ -1,6 +1,6 @@ import DashboardPage from '../pages/dashboard_page.vue'; -import { BASE_DASHBOARD_PAGE } from './constants'; +import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; /** * Because the cluster health page uses the dashboard @@ -12,7 +12,12 @@ import { BASE_DASHBOARD_PAGE } from './constants'; export default [ { name: BASE_DASHBOARD_PAGE, - path: '*', + path: '/', + component: DashboardPage, + }, + { + name: CUSTOM_DASHBOARD_PAGE, + path: '/:dashboard(.*)', component: DashboardPage, }, ]; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index cac04faae98..3da3aa2cb58 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -97,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => { }); }; +export const setCurrentDashboard = ({ commit }, { currentDashboard }) => { + commit(types.SET_CURRENT_DASHBOARD, currentDashboard); +}; + // All Data /** diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index e1fa037c5bb..d408628fc4d 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -9,6 +9,8 @@ export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; +export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD'; + // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 28c6b14a029..744441c8935 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -97,6 +97,10 @@ export default { state.isUpdatingStarredValue = false; }, + [types.SET_CURRENT_DASHBOARD](state, currentDashboard) { + state.currentDashboard = currentDashboard; + }, + /** * Deployments and environments */ diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 63762e414df..e65c18c07a9 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -8,6 +8,8 @@ import initFilePickers from '~/file_pickers'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; import initProjectRemoveModal from '~/projects/project_remove_modal'; +import UserCallout from '~/user_callout'; +import initServiceDesk from '~/projects/settings_service_desk'; document.addEventListener('DOMContentLoaded', () => { initFilePickers(); @@ -16,6 +18,9 @@ document.addEventListener('DOMContentLoaded', () => { initProjectRemoveModal(); mountBadgeSettings(PROJECT_BADGE); + new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new + initServiceDesk(); + initProjectLoadingSpinner(); initProjectPermissionsSettings(); setupTransferEdit('.js-project-transfer-form', 'select.select2'); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js new file mode 100644 index 00000000000..72003b61c8a --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js @@ -0,0 +1,30 @@ +/* eslint-disable class-methods-use-this */ +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; + +const AUTHOR_PARAM_KEY = 'author_username'; + +export default class FilteredSearchServiceDesk extends FilteredSearchManager { + constructor(supportBotData) { + super({ + page: 'service_desk', + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + }); + + this.supportBotData = supportBotData; + } + + canEdit(tokenName) { + return tokenName !== 'author'; + } + + modifyUrlParams(paramsArray) { + const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`; + const onlyValidParams = paramsArray.filter(param => param.indexOf(AUTHOR_PARAM_KEY) === -1); + + // unshift ensures author param is always first token element + onlyValidParams.unshift(supportBotParamPair); + + return onlyValidParams; + } +} diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js new file mode 100644 index 00000000000..56054f5fc80 --- /dev/null +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -0,0 +1,11 @@ +import FilteredSearchServiceDesk from './filtered_search'; + +document.addEventListener('DOMContentLoaded', () => { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + + filteredSearchManager.setup(); +}); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 4efabcb7df3..5ef1f959b2c 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -1,12 +1,19 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { getWeekdayNames } from '~/lib/utils/datetime_utility'; +const KEY_EVERY_DAY = 'everyDay'; +const KEY_EVERY_WEEK = 'everyWeek'; +const KEY_EVERY_MONTH = 'everyMonth'; +const KEY_CUSTOM = 'custom'; + export default { components: { - GlSprintf, + GlFormRadio, + GlFormRadioGroup, GlLink, + GlSprintf, }, props: { initialCronInterval: { @@ -22,6 +29,7 @@ export default { randomWeekDayIndex: this.generateRandomWeekDayIndex(), randomDay: this.generateRandomDay(), inputNameAttribute: 'schedule[cron]', + radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY, cronInterval: this.initialCronInterval, cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', }; @@ -29,14 +37,11 @@ export default { computed: { cronIntervalPresets() { return { - everyDay: `0 ${this.randomHour} * * *`, - everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, - everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`, + [KEY_EVERY_DAY]: `0 ${this.randomHour} * * *`, + [KEY_EVERY_WEEK]: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`, + [KEY_EVERY_MONTH]: `0 ${this.randomHour} ${this.randomDay} * *`, }; }, - intervalIsPreset() { - return Object.values(this.cronIntervalPresets).includes(this.cronInterval); - }, formattedTime() { if (this.randomHour > 12) { return `${this.randomHour - 12}:00pm`; @@ -45,24 +50,36 @@ export default { } return `${this.randomHour}:00am`; }, + radioOptions() { + return [ + { + value: KEY_EVERY_DAY, + text: sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }), + }, + { + value: KEY_EVERY_WEEK, + text: sprintf(s__('Every week (%{weekday} at %{time})'), { + weekday: this.weekday, + time: this.formattedTime, + }), + }, + { + value: KEY_EVERY_MONTH, + text: sprintf(s__('Every month (Day %{day} at %{time})'), { + day: this.randomDay, + time: this.formattedTime, + }), + }, + { + value: KEY_CUSTOM, + text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'), + link: this.cronSyntaxUrl, + }, + ]; + }, weekday() { return getWeekdayNames()[this.randomWeekDayIndex]; }, - everyDayText() { - return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime }); - }, - everyWeekText() { - return sprintf(s__('Every week (%{weekday} at %{time})'), { - weekday: this.weekday, - time: this.formattedTime, - }); - }, - everyMonthText() { - return sprintf(s__('Every month (Day %{day} at %{time})'), { - day: this.randomDay, - time: this.formattedTime, - }); - }, }, watch: { cronInterval() { @@ -72,38 +89,18 @@ export default { gl.pipelineScheduleFieldErrors.updateFormValidityState(); }); }, - }, - // If at the mounting stage the default is still an empty string, we - // know we are not editing an existing field so we update it so - // that the default is the first radio option - mounted() { - if (this.cronInterval === '') { - this.cronInterval = this.cronIntervalPresets.everyDay; - } + radioValue: { + immediate: true, + handler(val) { + if (val !== KEY_CUSTOM) { + this.cronInterval = this.cronIntervalPresets[val]; + } + }, + }, }, methods: { - setCustomInput(e) { - if (!this.isEditingCustom) { - this.isEditingCustom = true; - this.$refs.customInput.click(); - // Because we need to manually trigger the click on the radio btn, - // it will add a space to update the v-model. If the user is typing - // and the space is added, it will feel very unituitive so we reset - // the value to the original - this.cronInterval = e.target.value; - } - if (this.intervalIsPreset) { - this.isEditingCustom = false; - } - }, - toggleCustomInput(shouldEnable) { - this.isEditingCustom = shouldEnable; - - if (shouldEnable) { - // We need to change the value so other radios don't remain selected - // because the model (cronInterval) hasn't changed. The server trims it. - this.cronInterval = `${this.cronInterval} `; - } + onCustomInput() { + this.radioValue = KEY_CUSTOM; }, generateRandomHour() { return Math.floor(Math.random() * 23); @@ -119,89 +116,33 @@ export default { </script> <template> - <div class="interval-pattern-form-group"> - <div class="cron-preset-radio-input"> - <input - id="every-day" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyDay" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-day"> - {{ everyDayText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="every-week" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyWeek" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-week"> - {{ everyWeekText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="every-month" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyMonth" - class="label-bold" - type="radio" - @click="toggleCustomInput(false)" - /> - - <label class="label-bold" for="every-month"> - {{ everyMonthText }} - </label> - </div> - - <div class="cron-preset-radio-input"> - <input - id="custom" - ref="customInput" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronInterval" - class="label-bold" - type="radio" - @click="toggleCustomInput(true)" - /> - - <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label> - - <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')"> - <template #link="{content}"> - <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - - <div class="cron-interval-input-wrapper"> - <input - id="schedule_cron" - v-model="cronInterval" - :placeholder="__('Define a custom pattern with cron syntax')" - :name="inputNameAttribute" - class="form-control inline cron-interval-input" - type="text" - required="true" - @input="setCustomInput" - /> - </div> + <div> + <gl-form-radio-group v-model="radioValue" :name="inputNameAttribute"> + <gl-form-radio + v-for="option in radioOptions" + :key="option.value" + :value="option.value" + :data-testid="option.value" + > + <gl-sprintf v-if="option.link" :message="option.text"> + <template #link="{content}"> + <gl-link :href="option.link" target="_blank" class="gl-font-sm"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <template v-else>{{ option.text }}</template> + </gl-form-radio> + </gl-form-radio-group> + <input + id="schedule_cron" + v-model="cronInterval" + :placeholder="__('Define a custom pattern with cron syntax')" + :name="inputNameAttribute" + class="form-control inline cron-interval-input" + type="text" + required="true" + @input="onCustomInput" + /> </div> </template> 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 new file mode 100644 index 00000000000..d61569fcd6e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -0,0 +1,160 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ServiceDeskSetting from './service_desk_setting.vue'; +import ServiceDeskService from '../services/service_desk_service'; +import eventHub from '../event_hub'; + +export default { + name: 'ServiceDeskRoot', + components: { + GlAlert, + ServiceDeskSetting, + }, + props: { + initialIsEnabled: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + initialIncomingEmail: { + type: String, + required: false, + default: '', + }, + selectedTemplate: { + type: String, + required: false, + default: '', + }, + outgoingName: { + type: String, + required: false, + default: '', + }, + projectKey: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: false, + default: () => [], + }, + }, + + data() { + return { + isEnabled: this.initialIsEnabled, + incomingEmail: this.initialIncomingEmail, + isTemplateSaving: false, + isAlertShowing: false, + alertVariant: 'danger', + alertMessage: '', + }; + }, + + created() { + eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); + eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate); + + this.service = new ServiceDeskService(this.endpoint); + + if (this.isEnabled && !this.incomingEmail) { + this.fetchIncomingEmail(); + } + }, + + beforeDestroy() { + eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); + eventHub.$off('serviceDeskTemplateSave', this.onSaveTemplate); + }, + + methods: { + fetchIncomingEmail() { + this.service + .fetchIncomingEmail() + .then(({ data }) => { + const email = data.service_desk_address; + if (!email) { + throw new Error(__("Response didn't include `service_desk_address`")); + } + + this.incomingEmail = email; + }) + .catch(() => + this.showAlert(__('An error occurred while fetching the Service Desk address.')), + ); + }, + + onEnableToggled(isChecked) { + this.isEnabled = isChecked; + this.incomingEmail = ''; + + this.service + .toggleServiceDesk(isChecked) + .then(({ data }) => { + const email = data.service_desk_address; + if (isChecked && !email) { + throw new Error(__("Response didn't include `service_desk_address`")); + } + + this.incomingEmail = email; + }) + .catch(() => { + const message = isChecked + ? __('An error occurred while enabling Service Desk.') + : __('An error occurred while disabling Service Desk.'); + + this.showAlert(message); + }); + }, + + onSaveTemplate({ selectedTemplate, outgoingName, projectKey }) { + this.isTemplateSaving = true; + this.service + .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled) + .then(() => this.showAlert(__('Template was successfully saved.'), 'success')) + .catch(() => + this.showAlert( + __('An error occurred while saving the template. Please check if the template exists.'), + ), + ) + .finally(() => { + this.isTemplateSaving = false; + }); + }, + + showAlert(message, variant = 'danger') { + this.isAlertShowing = true; + this.alertMessage = message; + this.alertVariant = variant; + }, + + onDismiss() { + this.isAlertShowing = false; + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss"> + {{ alertMessage }} + </gl-alert> + <service-desk-setting + :is-enabled="isEnabled" + :incoming-email="incomingEmail" + :initial-selected-template="selectedTemplate" + :initial-outgoing-name="outgoingName" + :initial-project-key="projectKey" + :templates="templates" + :is-template-saving="isTemplateSaving" + /> + </div> +</template> 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 new file mode 100644 index 00000000000..43c20fea43e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -0,0 +1,169 @@ +<script> +import { GlDeprecatedButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import eventHub from '../event_hub'; + +export default { + name: 'ServiceDeskSetting', + directives: { + tooltip, + }, + components: { + ClipboardButton, + GlDeprecatedButton, + GlFormSelect, + GlToggle, + GlLoadingIcon, + }, + mixins: [glFeatureFlagsMixin()], + props: { + isEnabled: { + type: Boolean, + required: true, + }, + incomingEmail: { + type: String, + required: false, + default: '', + }, + initialSelectedTemplate: { + type: String, + required: false, + default: '', + }, + initialOutgoingName: { + type: String, + required: false, + default: '', + }, + initialProjectKey: { + type: String, + required: false, + default: '', + }, + templates: { + type: Array, + required: false, + default: () => [], + }, + isTemplateSaving: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + selectedTemplate: this.initialSelectedTemplate, + outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), + projectKey: this.initialProjectKey, + }; + }, + computed: { + templateOptions() { + return [''].concat(this.templates); + }, + hasProjectKeySupport() { + return Boolean(this.glFeatures.serviceDeskCustomAddress); + }, + }, + methods: { + onCheckboxToggle(isChecked) { + eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked); + }, + onSaveTemplate() { + eventHub.$emit('serviceDeskTemplateSave', { + selectedTemplate: this.selectedTemplate, + outgoingName: this.outgoingName, + projectKey: this.projectKey, + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-toggle + id="service-desk-checkbox" + :value="isEnabled" + class="d-inline-block align-middle mr-1" + label="Service desk" + label-position="left" + @change="onCheckboxToggle" + /> + <label class="align-middle" for="service-desk-checkbox"> + {{ __('Activate Service Desk') }} + </label> + <div v-if="isEnabled" class="row mt-3"> + <div class="col-md-9 mb-0"> + <strong id="incoming-email-describer" class="d-block mb-1"> + {{ __('Forward external support email address to') }} + </strong> + <template v-if="incomingEmail"> + <div class="input-group"> + <input + ref="service-desk-incoming-email" + type="text" + class="form-control incoming-email h-auto" + :placeholder="__('Incoming email')" + :aria-label="__('Incoming email')" + aria-describedby="incoming-email-describer" + :value="incomingEmail" + disabled="true" + /> + <div class="input-group-append"> + <clipboard-button + :title="__('Copy')" + :text="incomingEmail" + css-class="btn qa-clipboard-button" + /> + </div> + </div> + </template> + <template v-else> + <gl-loading-icon :inline="true" /> + <span class="sr-only">{{ __('Fetching incoming email') }}</span> + </template> + + <label for="service-desk-template-select" class="mt-3"> + {{ __('Template to append to all Service Desk issues') }} + </label> + <gl-form-select + id="service-desk-template-select" + v-model="selectedTemplate" + :options="templateOptions" + /> + <label for="service-desk-email-from-name" class="mt-3"> + {{ __('Email display name') }} + </label> + <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" /> + <span class="form-text text-muted"> + {{ __('Emails sent from Service Desk will have this name') }} + </span> + <template v-if="hasProjectKeySupport"> + <label for="service-desk-project-suffix" class="mt-3"> + {{ __('Project name suffix') }} + </label> + <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" /> + <span class="form-text text-muted mb-3"> + {{ + __( + 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.', + ) + }} + </span> + </template> + <gl-deprecated-button + variant="success" + :disabled="isTemplateSaving" + @click="onSaveTemplate" + >{{ __('Save template') }}</gl-deprecated-button + > + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/event_hub.js b/app/assets/javascripts/projects/settings_service_desk/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js new file mode 100644 index 00000000000..15c077de72e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ServiceDeskRoot from './components/service_desk_root.vue'; + +export default () => { + const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root'); + if (serviceDeskRootElement) { + // eslint-disable-next-line no-new + new Vue({ + el: serviceDeskRootElement, + components: { + ServiceDeskRoot, + }, + data() { + const { dataset } = serviceDeskRootElement; + return { + initialIsEnabled: parseBoolean(dataset.enabled), + endpoint: dataset.endpoint, + incomingEmail: dataset.incomingEmail, + selectedTemplate: dataset.selectedTemplate, + outgoingName: dataset.outgoingName, + projectKey: dataset.projectKey, + templates: JSON.parse(dataset.templates), + }; + }, + render(createElement) { + return createElement('service-desk-root', { + props: { + initialIsEnabled: this.initialIsEnabled, + endpoint: this.endpoint, + initialIncomingEmail: this.incomingEmail, + selectedTemplate: this.selectedTemplate, + outgoingName: this.outgoingName, + projectKey: this.projectKey, + templates: this.templates, + }, + }); + }, + }); + } +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js new file mode 100644 index 00000000000..d707763c64e --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js @@ -0,0 +1,27 @@ +import axios from '~/lib/utils/axios_utils'; + +class ServiceDeskService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchIncomingEmail() { + return axios.get(this.endpoint); + } + + toggleServiceDesk(enable) { + return axios.put(this.endpoint, { service_desk_enabled: enable }); + } + + updateTemplate({ selectedTemplate, outgoingName, projectKey = '' }, isEnabled) { + const body = { + issue_template_key: selectedTemplate, + outgoing_name: outgoingName, + project_key: projectKey, + service_desk_enabled: isEnabled, + }; + return axios.put(this.endpoint, body); + } +} + +export default ServiceDeskService; diff --git a/app/assets/javascripts/repository/components/web_ide_link.vue b/app/assets/javascripts/repository/components/web_ide_link.vue new file mode 100644 index 00000000000..6549d5a3878 --- /dev/null +++ b/app/assets/javascripts/repository/components/web_ide_link.vue @@ -0,0 +1,47 @@ +<script> +import TreeActionLink from './tree_action_link.vue'; +import { __ } from '~/locale'; +import { webIDEUrl } from '~/lib/utils/url_utility'; + +export default { + components: { + TreeActionLink, + }, + props: { + projectPath: { + type: String, + required: true, + }, + refSha: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: false, + default: true, + }, + forkPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + showLinkToFork() { + return !this.canPushCode && this.forkPath; + }, + text() { + return this.showLinkToFork ? __('Edit fork in Web IDE') : __('Web IDE'); + }, + path() { + const path = this.showLinkToFork ? this.forkPath : this.projectPath; + return webIDEUrl(`/${path}/edit/${this.refSha}/-/${this.$route.params.path || ''}`); + }, + }, +}; +</script> + +<template> + <tree-action-link :path="path" :text="text" data-qa-selector="web_ide_button" /> +</template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 01db4e363ba..4f80ab4ff5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -4,18 +4,26 @@ import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; import TreeActionLink from './components/tree_action_link.vue'; +import WebIdeLink from './components/web_ide_link.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { updateFormAction } from './utils/dom'; import { parseBoolean } from '../lib/utils/common_utils'; -import { webIDEUrl } from '../lib/utils/url_utility'; import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; - const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; + const { + canPushCode, + projectPath, + projectShortPath, + forkPath, + ref, + escapedRef, + fullName, + } = dataset; const router = createRouter(projectPath, escapedRef); apolloProvider.clients.defaultClient.cache.writeData({ @@ -117,11 +125,12 @@ export default function setupVueRepositoryList() { el: webIdeLinkEl, router, render(h) { - return h(TreeActionLink, { + return h(WebIdeLink, { props: { - path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`), - text: __('Web IDE'), - cssClass: 'qa-web-ide-button', + projectPath, + refSha: ref, + forkPath, + canPushCode: parseBoolean(canPushCode), }, }); }, diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js index 34cb74efabe..70d29b5b3df 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js @@ -1,6 +1,7 @@ import renderBlockHtml from './renderers/render_html_block'; import renderKramdownList from './renderers/render_kramdown_list'; import renderKramdownText from './renderers/render_kramdown_text'; +import renderIdentifierInstanceText from './renderers/render_identifier_instance_text'; import renderIdentifierParagraph from './renderers/render_identifier_paragraph'; import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text'; import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline'; @@ -9,7 +10,7 @@ const htmlInlineRenderers = [renderFontAwesomeHtmlInline]; const htmlBlockRenderers = [renderBlockHtml]; const listRenderers = [renderKramdownList]; const paragraphRenderers = [renderIdentifierParagraph]; -const textRenderers = [renderKramdownText, renderEmbeddedRubyText]; +const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText]; const executeRenderer = (renderers, node, context) => { const availableRenderer = renderers.find(renderer => renderer.canRender(node, context)); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js index 6937d2acb47..5b89390932c 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js @@ -32,6 +32,8 @@ export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => // Complete helpers (open plus close) +export const buildTextToken = content => buildToken('text', null, { content }); + export const buildUneditableTokens = token => { return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js new file mode 100644 index 00000000000..a9c3dfcd728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -0,0 +1,40 @@ +import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token'; + +/* +Use case examples: +- Majority: two bracket pairs, back-to-back, each with content (including spaces) + - `[environment terraform plans][terraform]` + - `[an issue labelled `~"master:broken"`][broken-master-issues]` +- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) + - `[this link][]` + - `[this link]` + +Regexp notes: + - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces) + - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces) + - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`) + - Each of the three parts is non-captured, but the match as a whole is captured +*/ +const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; + +const isIdentifierInstance = literal => { + // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + identifierInstanceRegex.lastIndex = 0; + return identifierInstanceRegex.test(literal); +}; + +const canRender = ({ literal }) => isIdentifierInstance(literal); + +const tokenize = text => { + const matches = text.split(identifierInstanceRegex); + const tokens = matches.map(match => { + const token = buildTextToken(match); + return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token; + }); + + return tokens.flat(); +}; + +const render = (_, { origin }) => tokenize(origin().content); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js index e94e7d46f85..746e38e98e8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js @@ -1,6 +1,7 @@ export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', + Embedded: 'embedded', }; export const LIST_BUFFER_SIZE = 5; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue index f45c14f8344..cf77aa37d14 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue @@ -8,12 +8,16 @@ export default { GlIcon, }, computed: { - ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']), + ...mapGetters([ + 'dropdownButtonText', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), }, methods: { ...mapActions(['toggleDropdownContents']), handleButtonClick(e) { - if (this.isDropdownVariantStandalone) { + if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { this.toggleDropdownContents(); e.stopPropagation(); } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index ba8d8391952..94671f8a109 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -88,12 +88,16 @@ export default { @click.prevent="handleColorClick(color)" /> </div> - <div class="color-input-container d-flex"> + <div class="color-input-container gl-display-flex"> <span class="dropdown-label-color-preview position-relative position-relative d-inline-block" :style="{ backgroundColor: selectedColor }" ></span> - <gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" /> + <gl-form-input + v-model.trim="selectedColor" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :placeholder="__('Use custom color #FF0000')" + /> </div> </div> <div class="dropdown-actions clearfix pt-2 px-2"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index af16088b6b9..ef506d00d9a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -36,7 +36,7 @@ export default { 'footerCreateLabelTitle', 'footerManageLabelTitle', ]), - ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']), + ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), visibleLabels() { if (this.searchKey) { return this.labels.filter(label => @@ -126,16 +126,19 @@ export default { <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <gl-loading-icon v-if="labelsFetchInProgress" - class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100" + class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100" size="md" /> - <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2"> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + > <span class="flex-grow-1">{{ labelsListTitle }}</span> <gl-button :aria-label="__('Close')" variant="link" size="small" - class="dropdown-header-button p-0" + class="dropdown-header-button gl-p-0!" icon="close" @click="toggleDropdownContents" /> @@ -165,17 +168,21 @@ export default { </li> </smart-virtual-list> </div> - <div v-if="isDropdownVariantSidebar" class="dropdown-footer"> + <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer"> <ul class="list-unstyled"> <li v-if="allowLabelCreate"> <gl-link - class="d-flex w-100 flex-row text-break-word label-item" + class="gl-display-flex w-100 flex-row text-break-word label-item" @click="toggleDropdownContentsCreateView" - >{{ footerCreateLabelTitle }}</gl-link > + {{ footerCreateLabelTitle }} + </gl-link> </li> <li> - <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> + <gl-link + :href="labelsManagePath" + class="gl-display-flex flex-row text-break-word label-item" + > {{ footerManageLabelTitle }} </gl-link> </li> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index f38b66fdfdf..258a87e62b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -74,6 +74,11 @@ export default { required: false, default: '', }, + dropdownButtonText: { + type: String, + required: false, + default: __('Label'), + }, labelsListTitle: { type: String, required: false, @@ -97,7 +102,11 @@ export default { }, computed: { ...mapState(['showDropdownButton', 'showDropdownContents']), - ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']), + ...mapGetters([ + 'isDropdownVariantSidebar', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), dropdownButtonVisible() { return this.isDropdownVariantSidebar ? this.showDropdownButton : true; }, @@ -116,6 +125,7 @@ export default { allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, allowScopedLabels: this.allowScopedLabels, + dropdownButtonText: this.dropdownButtonText, selectedLabels: this.selectedLabels, labelsFetchPath: this.labelsFetchPath, labelsManagePath: this.labelsManagePath, @@ -200,7 +210,10 @@ export default { <template> <div class="labels-select-wrapper position-relative" - :class="{ 'is-standalone': isDropdownVariantStandalone }" + :class="{ + 'is-standalone': isDropdownVariantStandalone, + 'is-embedded': isDropdownVariantEmbedded, + }" > <template v-if="isDropdownVariantSidebar"> <dropdown-value-collapsed @@ -221,7 +234,7 @@ export default { ref="dropdownContents" /> </template> - <template v-if="isDropdownVariantStandalone"> + <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> <dropdown-button v-show="dropdownButtonVisible" /> <dropdown-contents v-if="dropdownButtonVisible && showDropdownContents" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js index c39222959a9..e035a866048 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js @@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => { : state.selectedLabels; if (!selectedLabels.length) { - return __('Label'); + return state.dropdownButtonText || __('Label'); } else if (selectedLabels.length > 1) { return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { firstLabelName: selectedLabels[0].title, @@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria */ export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone; +/** + * Returns boolean representing whether dropdown variant + * is `embedded` + * @param {object} state + */ +export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index 6a6c0b4c0ee..3f3358d4805 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -6,6 +6,7 @@ export default () => ({ labelsCreateTitle: '', footerCreateLabelTitle: '', footerManageLabelTitle: '', + dropdownButtonText: '', // Paths namespace: '', diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 9da0b0da598..32c276ea6d2 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -1089,6 +1089,10 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { .dropdown-label-color-preview { border: 1px solid $gray-100; border-right: 0; + + &[style] { + border-color: transparent; + } } } } diff --git a/app/assets/stylesheets/pages/service_desk.scss b/app/assets/stylesheets/pages/service_desk.scss new file mode 100644 index 00000000000..34ab5eb1b74 --- /dev/null +++ b/app/assets/stylesheets/pages/service_desk.scss @@ -0,0 +1,7 @@ +.service-desk-issues { + .non-empty-state { + text-align: left; + padding-bottom: $gl-padding-top; + border-bottom: 1px solid $border-color; + } +} |