diff options
Diffstat (limited to 'app')
1424 files changed, 26003 insertions, 17225 deletions
diff --git a/app/assets/images/aws-cloud-formation.png b/app/assets/images/aws-cloud-formation.png Binary files differnew file mode 100644 index 00000000000..1d078309d86 --- /dev/null +++ b/app/assets/images/aws-cloud-formation.png diff --git a/app/assets/images/cluster_app_logos/fluentd.png b/app/assets/images/cluster_app_logos/fluentd.png Binary files differdeleted file mode 100644 index 6d42578f2ce..00000000000 --- a/app/assets/images/cluster_app_logos/fluentd.png +++ /dev/null diff --git a/app/assets/images/mailers/in_product_marketing/experience-0.png b/app/assets/images/mailers/in_product_marketing/experience-0.png Binary files differnew file mode 100644 index 00000000000..346204d1db1 --- /dev/null +++ b/app/assets/images/mailers/in_product_marketing/experience-0.png diff --git a/app/assets/images/mailers/members/issues.png b/app/assets/images/mailers/members/issues.png Binary files differnew file mode 100644 index 00000000000..b68a0a33d24 --- /dev/null +++ b/app/assets/images/mailers/members/issues.png diff --git a/app/assets/images/mailers/members/merge-request-open.png b/app/assets/images/mailers/members/merge-request-open.png Binary files differnew file mode 100644 index 00000000000..2485b0ca970 --- /dev/null +++ b/app/assets/images/mailers/members/merge-request-open.png diff --git a/app/assets/images/mailers/members/users.png b/app/assets/images/mailers/members/users.png Binary files differnew file mode 100644 index 00000000000..b3954ecbf61 --- /dev/null +++ b/app/assets/images/mailers/members/users.png diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue index 5d074698ea4..90c9113e0e1 100644 --- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -2,7 +2,7 @@ import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -192,9 +192,11 @@ export default { window.location.reload(); } if (!values[0] && !values[1]) { - createFlash( - s__('ContextCommits|Failed to create/remove context commits. Please try again.'), - ); + createFlash({ + message: s__( + 'ContextCommits|Failed to create/remove context commits. Please try again.', + ), + }); } }); } else if (this.uniqueCommits.length > 0) { diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js index 7b6f4c81bd2..4e5a2c7b371 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/actions.js +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -1,6 +1,6 @@ import _ from 'lodash'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -71,7 +71,9 @@ export const createContextCommits = ({ state }, { commits, forceReload = false } }) .catch(() => { if (forceReload) { - createFlash(s__('ContextCommits|Failed to create context commits. Please try again.')); + createFlash({ + message: s__('ContextCommits|Failed to create context commits. Please try again.'), + }); } return false; @@ -111,7 +113,9 @@ export const removeContextCommits = ({ state }, forceReload = false) => }) .catch(() => { if (forceReload) { - createFlash(s__('ContextCommits|Failed to delete context commits. Please try again.')); + createFlash({ + message: s__('ContextCommits|Failed to delete context commits. Please try again.'), + }); } return false; diff --git a/app/assets/javascripts/admin/statistics_panel/store/actions.js b/app/assets/javascripts/admin/statistics_panel/store/actions.js index 459f11c02f1..77782cdc187 100644 --- a/app/assets/javascripts/admin/statistics_panel/store/actions.js +++ b/app/assets/javascripts/admin/statistics_panel/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -21,5 +21,7 @@ export const receiveStatisticsSuccess = ({ commit }, statistics) => export const receiveStatisticsError = ({ commit }, error) => { commit(types.RECEIVE_STATISTICS_ERROR, error); - createFlash(s__('AdminDashboard|Error loading the statistics. Please try again')); + createFlash({ + message: s__('AdminDashboard|Error loading the statistics. Please try again'), + }); }; diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index 2fd96e38f8e..ede5c26e487 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -2,16 +2,13 @@ import { GlSkeletonLoader, GlTable } from '@gitlab/ui'; import createFlash from '~/flash'; import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; +import { thWidthClass } from '~/lib/utils/table_utility'; import { s__, __ } from '~/locale'; import UserDate from '~/vue_shared/components/user_date.vue'; import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql'; import UserActions from './user_actions.vue'; import UserAvatar from './user_avatar.vue'; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; -const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`; - export default { components: { GlSkeletonLoader, @@ -112,7 +109,7 @@ export default { :empty-text="s__('AdminUsers|No users found')" show-empty stacked="md" - data-qa-selector="user_row_content" + :tbody-tr-attr="{ 'data-qa-selector': 'user_row_content' }" > <template #cell(name)="{ item: user }"> <user-avatar :user="user" :admin-user-path="paths.adminUser" /> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 8ea977698e1..e59d7fc058a 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -26,6 +26,7 @@ import { } from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants'; import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ALERTS_STATUS_TABS, SEVERITY_LEVELS, trackAlertListViewsOptions } from '../constants'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; @@ -114,6 +115,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'], apollo: { alerts: { @@ -275,7 +277,7 @@ export default { </gl-sprintf> </gl-alert> - <alerts-deprecation-warning /> + <alerts-deprecation-warning v-if="!glFeatures.managedAlertsDeprecation" /> <paginated-table-with-search-and-tabs :show-error-msg="showErrorMsg" diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index e8daad8811e..696e7f359d1 100644 --- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -3,7 +3,6 @@ import { GlButton, GlSprintf, GlLink, - GlIcon, GlFormGroup, GlFormCheckbox, GlDropdown, @@ -22,7 +21,6 @@ export default { GlSprintf, GlLink, GlFormGroup, - GlIcon, GlFormCheckbox, GlDropdown, GlDropdownItem, @@ -98,7 +96,7 @@ export default { <label class="gl-display-inline-flex" for="alert-integration-settings-issue-template"> {{ $options.i18n.incidentTemplate.label }} <gl-link :href="$options.ISSUE_TEMPLATES_DOCS_LINK" target="_blank"> - <gl-icon name="question" :size="12" /> + <span class="gl-font-weight-normal gl-pl-2">{{ $options.i18n.introLinkText }}</span> </gl-link> </label> <gl-dropdown @@ -134,7 +132,7 @@ export default { ref="submitBtn" data-qa-selector="save_changes_button" :disabled="loading" - variant="success" + variant="confirm" type="submit" class="js-no-auto-disable" > diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index d9e5878b9e3..f4cc0678c38 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -23,7 +23,6 @@ import getCurrentIntegrationQuery from '../graphql/queries/get_current_integrati export const i18n = { deleteIntegration: s__('AlertSettings|Delete integration'), editIntegration: s__('AlertSettings|Edit integration'), - title: s__('AlertsIntegrations|Current integrations'), emptyState: s__('AlertsIntegrations|No integrations have been added yet.'), status: { enabled: { @@ -141,7 +140,6 @@ export default { <template> <div class="incident-management-list"> - <h5 class="gl-font-lg gl-mt-5">{{ $options.i18n.title }}</h5> <gl-table class="integration-list" :items="integrations" @@ -155,7 +153,7 @@ export default { <span v-if="item.active" data-testid="integration-activated-status"> <gl-icon v-gl-tooltip - name="check-circle-filled" + name="check" :size="16" class="gl-text-green-500 gl-hover-cursor-pointer gl-mr-3" :title="$options.i18n.status.enabled.tooltip" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 3917e4c5fdd..902bad780ad 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlAlert } from '@gitlab/ui'; +import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; @@ -30,6 +30,7 @@ import { INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, DEFAULT_ERROR, } from '../utils/error_messages'; +import AlertsForm from './alerts_form.vue'; import IntegrationsList from './alerts_integrations_list.vue'; import AlertSettingsForm from './alerts_settings_form.vue'; @@ -38,9 +39,12 @@ export default { i18n, components: { IntegrationsList, + AlertsForm, AlertSettingsForm, - GlButton, GlAlert, + GlButton, + GlTabs, + GlTab, }, inject: { projectPath: { @@ -167,7 +171,7 @@ export default { if (testAfterSubmit) { this.viewIntegration(integration, tabIndices.sendTestAlert); } else { - this.clearCurrentIntegration(type); + this.clearCurrentIntegration({ type }); } createFlash({ @@ -347,46 +351,51 @@ export default { </script> <template> - <div> - <gl-alert - v-if="showSuccessfulCreateAlert" - class="gl-mt-n2" - :primary-button-text="$options.i18n.integrationCreated.btnCaption" - :title="$options.i18n.integrationCreated.title" - @primaryAction="viewCreatedIntegration" - @dismiss="showSuccessfulCreateAlert = false" - > - {{ $options.i18n.integrationCreated.successMsg }} - </gl-alert> + <gl-tabs data-testid="alert-integration-settings"> + <gl-tab :title="$options.i18n.settingsTabs.currentIntegrations"> + <gl-alert + v-if="showSuccessfulCreateAlert" + class="gl-mt-n2" + :primary-button-text="$options.i18n.integrationCreated.btnCaption" + :title="$options.i18n.integrationCreated.title" + @primaryAction="viewCreatedIntegration" + @dismiss="showSuccessfulCreateAlert = false" + > + {{ $options.i18n.integrationCreated.successMsg }} + </gl-alert> - <integrations-list - :integrations="integrations" - :loading="loading" - @edit-integration="editIntegration" - @delete-integration="deleteIntegration" - /> - <gl-button - v-if="canAddIntegration && !formVisible" - category="secondary" - variant="confirm" - data-testid="add-integration-btn" - class="gl-mt-3" - @click="setFormVisibility(true)" - > - {{ $options.i18n.addNewIntegration }} - </gl-button> - <alert-settings-form - v-if="formVisible" - :loading="isUpdating" - :can-add-integration="canAddIntegration" - :alert-fields="alertFields" - :tab-index="tabIndex" - @create-new-integration="createNewIntegration" - @update-integration="updateIntegration" - @reset-token="resetToken" - @clear-current-integration="clearCurrentIntegration" - @test-alert-payload="testAlertPayload" - @save-and-test-alert-payload="saveAndTestAlertPayload" - /> - </div> + <integrations-list + :integrations="integrations" + :loading="loading" + @edit-integration="editIntegration" + @delete-integration="deleteIntegration" + /> + <gl-button + v-if="canAddIntegration && !formVisible" + category="secondary" + variant="confirm" + data-testid="add-integration-btn" + class="gl-mt-3" + @click="setFormVisibility(true)" + > + {{ $options.i18n.addNewIntegration }} + </gl-button> + <alert-settings-form + v-if="formVisible" + :loading="isUpdating" + :can-add-integration="canAddIntegration" + :alert-fields="alertFields" + :tab-index="tabIndex" + @create-new-integration="createNewIntegration" + @update-integration="updateIntegration" + @reset-token="resetToken" + @clear-current-integration="clearCurrentIntegration" + @test-alert-payload="testAlertPayload" + @save-and-test-alert-payload="saveAndTestAlertPayload" + /> + </gl-tab> + <gl-tab :title="$options.i18n.settingsTabs.integrationSettings"> + <alerts-form class="gl-pt-3" data-testid="alert-integration-settings-tab" /> + </gl-tab> + </gl-tabs> </template> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index 4a180ed2bc0..b93119d6e6a 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -93,6 +93,10 @@ export const i18n = { integrationRemoved: s__('AlertsIntegrations|The integration is deleted.'), alertSent: s__('AlertsIntegrations|The test alert should now be visible in your alerts list.'), addNewIntegration: s__('AlertSettings|Add new integration'), + settingsTabs: { + currentIntegrations: s__('AlertSettings|Current integrations'), + integrationSettings: s__('AlertSettings|Alert settings'), + }, }; export const integrationSteps = { @@ -156,3 +160,31 @@ export const tabIndices = { }; export const testAlertModalId = 'confirmSendTestAlert'; + +/* Alerts integration settings constants */ + +export const I18N_ALERT_SETTINGS_FORM = { + saveBtnLabel: __('Save changes'), + introText: __('Action to take when receiving an alert. %{docsLink}'), + introLinkText: __('Learn more.'), + createIncident: { + label: __('Create an incident. Incidents are created for each alert triggered.'), + }, + incidentTemplate: { + label: __('Incident template (optional).'), + }, + sendEmail: { + label: __('Send a single email notification to Owners and Maintainers for new alerts.'), + }, + autoCloseIncidents: { + label: __( + 'Automatically close associated incident when a recovery alert notification resolves an alert', + ), + }, +}; + +export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') }; +export const TAKING_INCIDENT_ACTION_DOCS_LINK = + '/help/operations/metrics/alerts#trigger-actions-from-alerts'; +export const ISSUE_TEMPLATES_DOCS_LINK = + '/help/user/project/description_templates#create-an-issue-template'; diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js index 72817f636ff..15862f4034a 100644 --- a/app/assets/javascripts/alerts_settings/graphql.js +++ b/app/assets/javascripts/alerts_settings/graphql.js @@ -1,9 +1,15 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './graphql/fragmentTypes.json'; import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql'; +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + Vue.use(VueApollo); const resolvers = { @@ -50,7 +56,9 @@ const resolvers = { export default new VueApollo({ defaultClient: createDefaultClient(resolvers, { - cacheConfig: {}, + cacheConfig: { + fragmentMatcher, + }, assumeImmutableResults: true, }), }); diff --git a/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json b/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json new file mode 100644 index 00000000000..07dfc43aa6c --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"UNION","name":"AlertManagementIntegration","possibleTypes":[{"name":"AlertManagementHttpIntegration"},{"name":"AlertManagementPrometheusIntegration"}]}]}} diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 953a867b2b7..19cdf1d7a19 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -1,5 +1,6 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; import { parseBoolean } from '~/lib/utils/common_utils'; import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue'; import apolloProvider from './graphql'; @@ -19,14 +20,37 @@ export default (el) => { return null; } - const { alertsUsageUrl, projectPath, multiIntegrations, alertFields } = el.dataset; + const { + alertsUsageUrl, + projectPath, + multiIntegrations, + alertFields, + templates, + createIssue, + issueTemplateKey, + sendEmail, + autoCloseIncident, + pagerdutyResetKeyPath, + operationsSettingsEndpoint, + } = el.dataset; + const service = new IncidentsSettingsService(operationsSettingsEndpoint, pagerdutyResetKeyPath); return new Vue({ el, components: { AlertSettingsWrapper, }, provide: { + service, + alertSettings: { + templates: JSON.parse(templates), + createIssue: parseBoolean(createIssue), + issueTemplateKey, + sendEmail: parseBoolean(sendEmail), + autoCloseIncident: parseBoolean(autoCloseIncident), + pagerdutyResetKeyPath, + operationsSettingsEndpoint, + }, alertsUsageUrl, projectPath, multiIntegrations: parseBoolean(multiIntegrations), diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index 80ad36d0519..0b4fa879b03 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -1,7 +1,6 @@ <script> -import * as Sentry from '@sentry/browser'; import MetricCard from '~/analytics/shared/components/metric_card.vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { number } from '~/lib/utils/unit_format'; import { s__ } from '~/locale'; import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql'; @@ -34,8 +33,11 @@ export default { }); }, error(error) { - createFlash(this.$options.i18n.loadCountsError); - Sentry.captureException(error); + createFlash({ + message: this.$options.i18n.loadCountsError, + captureError: true, + error, + }); }, }, }, diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 516235657cb..41cc2036a6b 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; @@ -18,11 +18,14 @@ const Api = { groupMembersPath: '/api/:version/groups/:id/members', groupMilestonesPath: '/api/:version/groups/:id/milestones', subgroupsPath: '/api/:version/groups/:id/subgroups', + descendantGroupsPath: '/api/:version/groups/:id/descendant_groups', namespacesPath: '/api/:version/namespaces.json', groupInvitationsPath: '/api/:version/groups/:id/invitations', groupPackagesPath: '/api/:version/groups/:id/packages', projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagePath: '/api/:version/projects/:id/packages/:package_id', + projectPackageFilePath: + '/api/:version/projects/:id/packages/:package_id/package_files/:package_file_id', groupProjectsPath: '/api/:version/groups/:id/projects.json', groupSharePath: '/api/:version/groups/:id/share', projectsPath: '/api/:version/projects.json', @@ -124,6 +127,15 @@ const Api = { return axios.delete(url); }, + deleteProjectPackageFile(projectId, packageId, fileId) { + const url = Api.buildUrl(this.projectPackageFilePath) + .replace(':id', projectId) + .replace(':package_id', packageId) + .replace(':package_file_id', fileId); + + return axios.delete(url); + }, + containerRegistryDetails(registryId, options = {}) { const url = Api.buildUrl(this.containerRegistryDetailsPath).replace(':id', registryId); return axios.get(url, options); @@ -443,7 +455,9 @@ const Api = { }) .then(({ data }) => (callback ? callback(data) : data)) .catch(() => { - flash(__('Something went wrong while fetching projects')); + createFlash({ + message: __('Something went wrong while fetching projects'), + }); if (callback) { callback(); } @@ -631,7 +645,11 @@ const Api = { params: { ...defaults, ...options }, }) .then(({ data }) => callback(data)) - .catch(() => flash(__('Something went wrong while fetching projects'))); + .catch(() => + createFlash({ + message: __('Something went wrong while fetching projects'), + }), + ); }, branches(id, query = '', options = {}) { @@ -866,7 +884,7 @@ const Api = { }, trackRedisHllUserEvent(event) { - if (!gon.features?.usageDataApi) { + if (!gon.current_user_id || !gon.features?.usageDataApi) { return null; } diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js new file mode 100644 index 00000000000..58494c5a2b8 --- /dev/null +++ b/app/assets/javascripts/api/analytics_api.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams'; +const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; + +const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { + if (valueStreamId) { + return buildApiUrl(PROJECT_VSA_STAGES_PATH) + .replace(':project_path', projectPath) + .replace(':value_stream_id', valueStreamId); + } + return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); +}; + +export const getProjectValueStreams = (projectPath) => { + const url = buildProjectValueStreamPath(projectPath); + return axios.get(url); +}; + +export const getProjectValueStreamStages = (projectPath, valueStreamId) => { + const url = buildProjectValueStreamPath(projectPath, valueStreamId); + return axios.get(url); +}; + +// NOTE: legacy VSA request use a different path +// the `requestPath` provides a full url for the request +export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) => + axios.get(`${requestPath}/events/${stageId}`, { params }); + +export const getProjectValueStreamMetrics = (requestPath, params) => + axios.get(requestPath, { params }); diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index d4ba46656e6..d6c9e1d42cc 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -3,9 +3,9 @@ import { buildApiUrl } from './api_utils'; import { DEFAULT_PER_PAGE } from './constants'; const GROUPS_PATH = '/api/:version/groups.json'; +const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups'; -export function getGroups(query, options, callback = () => {}) { - const url = buildApiUrl(GROUPS_PATH); +const axiosGet = (url, query, options, callback) => { return axios .get(url, { params: { @@ -19,4 +19,14 @@ export function getGroups(query, options, callback = () => {}) { return data; }); +}; + +export function getGroups(query, options, callback = () => {}) { + const url = buildApiUrl(GROUPS_PATH); + return axiosGet(url, query, options, callback); +} + +export function getDescendentGroups(parentGroupId, query, options, callback = () => {}) { + const url = buildApiUrl(DESCENDANT_GROUPS_PATH.replace(':id', parentGroupId)); + return axiosGet(url, query, options, callback); } diff --git a/app/assets/javascripts/api/markdown_api.js b/app/assets/javascripts/api/markdown_api.js new file mode 100644 index 00000000000..5c9c1713bd8 --- /dev/null +++ b/app/assets/javascripts/api/markdown_api.js @@ -0,0 +1,11 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const MARKDOWN_PATH = '/api/:version/markdown'; + +export function getMarkdown(options) { + const url = buildApiUrl(MARKDOWN_PATH); + return axios.post(url, { + ...options, + }); +} diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index b65a8b4fa9c..7c4ff830a9d 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -3,7 +3,7 @@ import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import { escape, debounce } from 'lodash'; import { mapActions, mapState } from 'vuex'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import createEmptyBadge from '../empty_badge'; import Badge from './badge.vue'; @@ -134,26 +134,36 @@ export default { if (this.isEditing) { return this.saveBadge() .then(() => { - createFlash(s__('Badges|Badge saved.'), 'notice'); + createFlash({ + message: s__('Badges|Badge saved.'), + type: 'notice', + }); this.wasValidated = false; }) .catch((error) => { - createFlash( - s__('Badges|Saving the badge failed, please check the entered URLs and try again.'), - ); + createFlash({ + message: s__( + 'Badges|Saving the badge failed, please check the entered URLs and try again.', + ), + }); throw error; }); } return this.addBadge() .then(() => { - createFlash(s__('Badges|New badge added.'), 'notice'); + createFlash({ + message: s__('Badges|New badge added.'), + type: 'notice', + }); this.wasValidated = false; }) .catch((error) => { - createFlash( - s__('Badges|Adding the badge failed, please check the entered URLs and try again.'), - ); + createFlash({ + message: s__( + 'Badges|Adding the badge failed, please check the entered URLs and try again.', + ), + }); throw error; }); }, diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 8d5bd216367..825807e833e 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlModal } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { s__ } from '~/locale'; import Badge from './badge.vue'; import BadgeForm from './badge_form.vue'; @@ -40,10 +40,15 @@ export default { onSubmitModal() { this.deleteBadge(this.badgeInModal) .then(() => { - createFlash(s__('Badges|The badge was deleted.'), 'notice'); + createFlash({ + message: s__('Badges|The badge was deleted.'), + type: 'notice', + }); }) .catch((error) => { - createFlash(s__('Badges|Deleting the badge failed, please try again.')); + createFlash({ + message: s__('Badges|Deleting the badge failed, please try again.'), + }); throw error; }); }, diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 753608cf6f7..0eb4e6e7709 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -46,9 +46,13 @@ export default { } if (this.discussion) { - return sprintf(__("%{authorsName}'s thread"), { - authorsName: this.discussion.notes.find((note) => !note.system).author.name, - }); + return sprintf( + __("%{authorsName}'s thread"), + { + authorsName: this.discussion.notes.find((note) => !note.system).author.name, + }, + false, + ); } return __('Your new comment'); 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 88be64d0a1a..a8c0b064595 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 @@ -41,11 +41,18 @@ export const deleteDraft = ({ commit, getters }, draft) => }) .catch(() => flash(__('An error occurred while deleting the comment'))); -export const fetchDrafts = ({ commit, getters }) => +export const fetchDrafts = ({ commit, getters, state, dispatch }) => service .fetchDrafts(getters.getNotesData.draftsPath) .then((res) => res.data) .then((data) => commit(types.SET_BATCH_COMMENTS_DRAFTS, data)) + .then(() => { + state.drafts.forEach((draft) => { + if (!draft.line_code) { + dispatch('convertToDiscussion', draft.discussion_id, { root: true }); + } + }); + }) .catch(() => flash(__('An error occurred while fetching pending comments'))); export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => { diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index bf7a87144f9..ef445548e6e 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,4 +1,3 @@ -import 'document-register-element'; import { initEmojiMap, getEmojiInfo, diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index f5b2d266c18..5fecadf2794 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -30,6 +30,24 @@ let renderedMermaidBlocks = 0; let mermaidModule = {}; +// Whitelist pages where we won't impose any restrictions +// on mermaid rendering +const WHITELISTED_PAGES = [ + // Group wiki + 'groups:wikis:show', + 'groups:wikis:edit', + 'groups:wikis:create', + + // Project wiki + 'projects:wikis:show', + 'projects:wikis:edit', + 'projects:wikis:create', + + // Project files + 'projects:show', + 'projects:blob:show', +]; + export function initMermaid(mermaid) { let theme = 'neutral'; @@ -46,7 +64,7 @@ export function initMermaid(mermaid) { theme, flowchart: { useMaxWidth: true, - htmlLabels: false, + htmlLabels: true, }, securityLevel: 'strict', }); @@ -120,8 +138,10 @@ function renderMermaidEl(el) { function renderMermaids($els) { if (!$els.length) return; + const pageName = document.querySelector('body').dataset.page; + // A diagram may have been truncated in search results which will cause errors, so abort the render. - if (document.querySelector('body').dataset.page === 'search:show') return; + if (pageName === 'search:show') return; importMermaidModule() .then(() => { @@ -140,10 +160,11 @@ function renderMermaids($els) { * up the entire thread and causing a DoS. */ if ( - (source && source.length > MAX_CHAR_LIMIT) || - renderedChars > MAX_CHAR_LIMIT || - renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || - shouldLazyLoadMermaidBlock(source) + !WHITELISTED_PAGES.includes(pageName) && + ((source && source.length > MAX_CHAR_LIMIT) || + renderedChars > MAX_CHAR_LIMIT || + renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || + shouldLazyLoadMermaidBlock(source)) ) { const html = ` <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index c63dba05f10..005ef103ded 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -105,6 +105,12 @@ export const TOGGLE_PERFORMANCE_BAR = { defaultKeys: ['p b'], // eslint-disable-line @gitlab/require-i18n-strings }; +export const HIDE_APPEARING_CONTENT = { + id: 'globalShortcuts.hideAppearingContent', + description: __('Hide tooltips or popovers'), + defaultKeys: ['esc'], +}; + export const TOGGLE_CANARY = { id: 'globalShortcuts.toggleCanary', description: __('Toggle GitLab Next'), @@ -492,6 +498,7 @@ export const GLOBAL_SHORTCUTS_GROUP = { GO_TO_YOUR_MERGE_REQUESTS, GO_TO_YOUR_TODO_LIST, TOGGLE_PERFORMANCE_BAR, + HIDE_APPEARING_CONTENT, ], }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 03cba78cf31..ac2a4184176 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -12,6 +12,7 @@ import { START_SEARCH, FOCUS_FILTER_BAR, TOGGLE_PERFORMANCE_BAR, + HIDE_APPEARING_CONTENT, TOGGLE_CANARY, TOGGLE_MARKDOWN_PREVIEW, GO_TO_YOUR_TODO_LIST, @@ -78,6 +79,7 @@ export default class Shortcuts { Mousetrap.bind(keysFor(START_SEARCH), Shortcuts.focusSearch); Mousetrap.bind(keysFor(FOCUS_FILTER_BAR), this.focusFilter.bind(this)); Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar); + Mousetrap.bind(keysFor(HIDE_APPEARING_CONTENT), Shortcuts.hideAppearingContent); Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary); const findFileURL = document.body.dataset.findFile; @@ -202,6 +204,18 @@ export default class Shortcuts { } } + static hideAppearingContent(e) { + const elements = document.querySelectorAll('.tooltip, .popover'); + + elements.forEach((element) => { + element.style.display = 'none'; + }); + + if (e.preventDefault) { + e.preventDefault(); + } + } + /** * Initializes markdown editor shortcuts on the provided `<textarea>` element * diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue new file mode 100644 index 00000000000..78ecb82f2cd --- /dev/null +++ b/app/assets/javascripts/blob/components/table_contents.vue @@ -0,0 +1,74 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +function getHeaderNumber(el) { + return parseInt(el.tagName.match(/\d+/)[0], 10); +} + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + data() { + return { + isHidden: false, + items: [], + }; + }, + mounted() { + this.blobViewer = document.querySelector('.blob-viewer[data-type="rich"]'); + + this.observer = new MutationObserver(() => { + if (this.blobViewer.classList.contains('hidden')) { + this.isHidden = true; + } else if (this.blobViewer.getAttribute('data-loaded') === 'true') { + this.isHidden = false; + this.generateHeaders(); + } + }); + + if (this.blobViewer) { + this.observer.observe(this.blobViewer, { + attributes: true, + }); + } + }, + beforeDestroy() { + if (this.observer) { + this.observer.disconnect(); + } + }, + methods: { + generateHeaders() { + const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')]; + + if (headers.length) { + const firstHeader = getHeaderNumber(headers[0]); + + headers.forEach((el) => { + this.items.push({ + text: el.textContent.trim(), + anchor: el.querySelector('a').getAttribute('id'), + spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0), + }); + }); + } + }, + }, +}; +</script> + +<template> + <gl-dropdown v-if="!isHidden && items.length" icon="list-bulleted" class="gl-mr-2" lazy> + <gl-dropdown-item v-for="(item, index) in items" :key="index" :href="`#${item.anchor}`"> + <span + :style="{ 'padding-left': `${item.spacing}px` }" + class="gl-display-block" + data-testid="tableContentsLink" + > + {{ item.text }} + </span> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index d26af07d54f..76d9b18b777 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import initCodeQualityWalkthrough from '~/code_quality_walkthrough'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; import BlobFileDropzone from '../blob/blob_file_dropzone'; @@ -84,7 +84,11 @@ export default () => { initPopovers(); initCodeQualityWalkthroughStep(); }) - .catch((e) => createFlash(e)); + .catch((e) => + createFlash({ + message: e, + }), + ); cancelLink.on('click', () => { window.onbeforeunload = null; diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index ab2fc80e653..7c8d0d5ded0 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import EditorLite from '~/editor/editor_lite'; import { FileTemplateExtension } from '~/editor/extensions/editor_file_template_ext'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { insertFinalNewline } from '~/lib/utils/text_utility'; @@ -21,7 +21,11 @@ export default class EditBlob { this.editor.use(new MarkdownExtension()); addEditorMarkdownListeners(this.editor); }) - .catch((e) => createFlash(`${BLOB_EDITOR_ERROR}: ${e}`)); + .catch((e) => + createFlash({ + message: `${BLOB_EDITOR_ERROR}: ${e}`, + }), + ); } this.initModePanesAndLinks(); @@ -94,7 +98,11 @@ export default class EditBlob { currentPane.empty().append(data); currentPane.renderGFM(); }) - .catch(() => createFlash(BLOB_PREVIEW_ERROR)); + .catch(() => + createFlash({ + message: BLOB_PREVIEW_ERROR, + }), + ); } this.$toggleButton.show(); diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index f53d41dd0f4..e14a770411e 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,6 +1,6 @@ import { sortBy, cloneDeep } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { ListType, NOT_FILTER, AssigneeIdParamValues } from './constants'; +import { ListType } from './constants'; export function getMilestone() { return null; @@ -40,7 +40,7 @@ export function formatListIssues(listIssues) { let listItemsCount; const listData = listIssues.nodes.reduce((map, list) => { - listItemsCount = list.issues.count; + listItemsCount = list.issuesCount; let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); @@ -175,45 +175,106 @@ export function isListDraggable(list) { return list.listType !== ListType.backlog && list.listType !== ListType.closed; } -export function transformNotFilters(filters) { - return Object.keys(filters) - .filter((key) => key.startsWith(NOT_FILTER)) - .reduce((obj, key) => { - return { - ...obj, - [key.substring(4, key.length - 1)]: filters[key], - }; - }, {}); -} - -export function getSupportedParams(filters, supportedFilters) { - return supportedFilters.reduce((acc, f) => { - /** - * TODO the API endpoint for the classic boards - * accepts assignee wildcard value as 'assigneeId' param - - * while the GraphQL query accepts the value in 'assigneWildcardId' field. - * Once we deprecate the classics boards, - * we should change the filtered search bar to use 'asssigneeWildcardId' as a token name. - */ - if (f === 'assigneeId' && filters[f]) { - return AssigneeIdParamValues.includes(filters[f]) - ? { - ...acc, - assigneeWildcardId: filters[f].toUpperCase(), - } - : acc; - } - - if (filters[f]) { - return { - ...acc, - [f]: filters[f], - }; - } - - return acc; - }, {}); -} +export const FiltersInfo = { + assigneeUsername: { + negatedSupport: true, + }, + assigneeId: { + // assigneeId should be renamed to assigneeWildcardId. + // Classic boards used 'assigneeId' + remap: () => 'assigneeWildcardId', + }, + assigneeWildcardId: { + negatedSupport: false, + transform: (val) => val.toUpperCase(), + }, + authorUsername: { + negatedSupport: true, + }, + labelName: { + negatedSupport: true, + }, + milestoneTitle: { + negatedSupport: true, + }, + myReactionEmoji: { + negatedSupport: true, + }, + releaseTag: { + negatedSupport: true, + }, + search: { + negatedSupport: false, + }, +}; + +/** + * @param {Object} filters - ex. { search: "foobar", "not[authorUsername]": "root", } + * @returns {Object} - ex. [ ["search", "foobar", false], ["authorUsername", "root", true], ] + */ +const parseFilters = (filters) => { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + const isNegated = (x) => x.startsWith('not[') && x.endsWith(']'); + + return Object.entries(filters).map(([k, v]) => { + const isNot = isNegated(k); + const filterKey = isNot ? k.slice(4, -1) : k; + + return [filterKey, v, isNot]; + }); +}; + +/** + * Returns an object of filter key/value pairs used as variables in GraphQL requests. + * (warning: filter values are not validated) + * + * @param {Object} objParam.filters - filters extracted from url params. ex. { search: "foobar", "not[authorUsername]": "root", } + * @param {string} objParam.issuableType - issuable type e.g., issue. + * @param {Object} objParam.filterInfo - data on filters such as how to transform filter value, if filter can be negated, etc. + * @param {Object} objParam.filterFields - data on what filters are available for given issuableType (based on GraphQL schema) + */ +export const filterVariables = ({ filters, issuableType, filterInfo, filterFields }) => + parseFilters(filters) + .map(([k, v, negated]) => { + // for legacy reasons, some filters need to be renamed to correct GraphQL fields. + const remapAvailable = filterInfo[k]?.remap; + const remappedKey = remapAvailable ? filterInfo[k].remap(k, v) : k; + + return [remappedKey, v, negated]; + }) + .filter(([k, , negated]) => { + // remove unsupported filters (+ check if the filters support negation) + const supported = filterFields[issuableType].includes(k); + if (supported) { + return negated ? filterInfo[k].negatedSupport : true; + } + + return false; + }) + .map(([k, v, negated]) => { + // if the filter value needs a special transformation, apply it (e.g., capitalization) + const transform = filterInfo[k]?.transform; + const newVal = transform ? transform(v) : v; + + return [k, newVal, negated]; + }) + .reduce( + (acc, [k, v, negated]) => { + return negated + ? { + ...acc, + not: { + ...acc.not, + [k]: v, + }, + } + : { + ...acc, + [k]: v, + }; + }, + { not: {} }, + ); // EE-specific feature. Find the implementation in the `ee/`-folder export function transformBoardConfig() { @@ -228,5 +289,4 @@ export default { fullLabelId, fullIterationId, isListDraggable, - transformNotFilters, }; diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 85f001d9d61..2aee84b805f 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -1,21 +1,25 @@ <script> import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import Tracking from '~/tracking'; export default { components: { GlButton, }, + mixins: [Tracking.mixin()], methods: { ...mapActions(['setAddColumnFormVisibility']), + handleClick() { + this.setAddColumnFormVisibility(true); + this.track('click_button', { label: 'create_list' }); + }, }, }; </script> <template> <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> - <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)" - >{{ __('Create list') }} - </gl-button> + <gl-button variant="confirm" @click="handleClick">{{ __('Create list') }} </gl-button> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 2821b799cef..1e780f9ef84 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; +import Tracking from '~/tracking'; import BoardCardInner from './board_card_inner.vue'; export default { @@ -7,6 +8,7 @@ export default { components: { BoardCardInner, }, + mixins: [Tracking.mixin()], props: { list: { type: Object, @@ -40,6 +42,12 @@ export default { this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 ); }, + isDisabled() { + return this.disabled || !this.item.id || this.item.isLoading; + }, + isDraggable() { + return !this.disabled && this.item.id && !this.item.isLoading; + }, }, methods: { ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), @@ -48,10 +56,11 @@ export default { if (e.target.closest('.js-no-trigger')) return; const isMultiSelect = e.ctrlKey || e.metaKey; - if (isMultiSelect) { + if (isMultiSelect && gon?.features?.boardMultiSelect) { this.toggleBoardItemMultiSelection(this.item); } else { this.toggleBoardItem({ boardItem: this.item }); + this.track('click_card', { label: 'right_sidebar' }); } }, }, @@ -63,9 +72,10 @@ export default { data-qa-selector="board_card" :class="{ 'multi-select': multiSelectVisible, - 'user-can-drag': !disabled && item.id, - 'is-disabled': disabled || !item.id, + 'user-can-drag': isDraggable, + 'is-disabled': isDisabled, 'is-active': isActive, + 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading, }" :index="index" :data-item-id="item.id" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 0cb2e64042e..2f4e9044b9e 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -1,5 +1,5 @@ <script> -import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlLabel, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; @@ -17,6 +17,7 @@ import IssueTimeEstimate from './issue_time_estimate.vue'; export default { components: { GlLabel, + GlLoadingIcon, GlIcon, UserAvatarLink, TooltipOnTruncate, @@ -181,9 +182,13 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{ - item.title - }}</a> + <a + :href="item.path || item.webUrl || ''" + :title="item.title" + :class="{ 'gl-text-gray-400!': item.isLoading }" + @mousemove.stop + >{{ item.title }}</a + > </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> @@ -206,6 +211,7 @@ export default { <div class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" > + <gl-loading-icon v-if="item.isLoading" size="md" class="mt-3" /> <span v-if="item.referencePath" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index c9e667d526c..cc7262f3a39 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -24,11 +24,6 @@ export default { type: Boolean, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), @@ -92,14 +87,8 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" :class="{ 'board-column-highlighted': highlighted }" > - <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> - <board-list - ref="board-list" - :disabled="disabled" - :board-items="listItems" - :list="list" - :can-admin-list="canAdminList" - /> + <board-list-header :list="list" :disabled="disabled" /> + <board-list ref="board-list" :disabled="disabled" :board-items="listItems" :list="list" /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_column_deprecated.vue b/app/assets/javascripts/boards/components/board_column_deprecated.vue index 3dc77654e28..7c090dfaa53 100644 --- a/app/assets/javascripts/boards/components/board_column_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_column_deprecated.vue @@ -26,11 +26,6 @@ export default { type: Boolean, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -110,7 +105,7 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" :class="{ 'board-column-highlighted': list.highlighted }" > - <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + <board-list-header :list="list" :disabled="disabled" /> <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" /> </div> </div> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index b8a38d833ad..b770ac06e89 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -106,7 +106,6 @@ export default { v-for="(list, index) in boardListsToUse" :key="index" ref="board" - :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e1f8457c0e2..16a8a9d253f 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,16 +1,17 @@ <script> import { GlDrawer } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; +import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; -import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import { contentTop } from '~/lib/utils/common_utils'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { headerHeight: `${contentTop()}px`, @@ -18,19 +19,18 @@ export default { GlDrawer, BoardSidebarTitle, SidebarAssigneesWidget, + SidebarDateWidget, SidebarConfidentialityWidget, BoardSidebarTimeTracker, BoardSidebarLabelsSelect, - BoardSidebarDueDate, SidebarSubscriptionsWidget, - BoardSidebarMilestoneSelect, - BoardSidebarEpicSelect: () => - import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'), + SidebarDropdownWidget, BoardSidebarWeightInput: () => import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), - SidebarIterationWidget: () => - import('ee_component/sidebar/components/sidebar_iteration_widget.vue'), + IterationSidebarDropdownWidget: () => + import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'), }, + mixins: [glFeatureFlagMixin()], inject: { multipleAssigneesFeatureAvailable: { default: false, @@ -89,20 +89,57 @@ export default { :allow-multiple-assignees="multipleAssigneesFeatureAvailable" @assignees-updated="setAssignees" /> - <board-sidebar-epic-select v-if="epicFeatureAvailable" class="epic" /> + <sidebar-dropdown-widget + v-if="epicFeatureAvailable" + :iid="activeBoardItem.iid" + issuable-attribute="epic" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + data-testid="sidebar-epic" + /> <div> - <board-sidebar-milestone-select /> - <sidebar-iteration-widget - v-if="iterationFeatureAvailable" + <sidebar-dropdown-widget :iid="activeBoardItem.iid" + issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" - :iterations-workspace-path="groupPathForActiveIssue" + :attr-workspace-path="projectPathForActiveIssue" :issuable-type="issuableType" - class="gl-mt-5" + data-testid="sidebar-milestones" /> + <template v-if="!glFeatures.iterationCadences"> + <sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + issuable-attribute="iteration" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + data-qa-selector="iteration_container" + /> + </template> + <template v-else> + <iteration-sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + data-qa-selector="iteration_container" + /> + </template> </div> <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> - <board-sidebar-due-date /> + <sidebar-date-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-due-date" + /> <board-sidebar-labels-select class="labels" /> <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" /> <sidebar-confidentiality-widget diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index e564af0c353..13388f02f1f 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -3,6 +3,7 @@ import { pickBy } from 'lodash'; import { mapActions } from 'vuex'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; export default { @@ -104,7 +105,9 @@ export default { }, getFilterParams(filters = []) { const notFilters = filters.filter((item) => item.value.operator === '!='); - const equalsFilters = filters.filter((item) => item.value.operator === '='); + const equalsFilters = filters.filter( + (item) => item?.value?.operator === '=' || item.type === FILTERED_SEARCH_TERM, + ); return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } }; }, diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 78da4137d69..aa75a0d68f5 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,7 +1,7 @@ <script> -import { GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import { GlModal, GlAlert } from '@gitlab/ui'; +import { mapGetters, mapActions, mapState } from 'vuex'; +import ListLabel from '~/boards/models/label'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName } from '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -44,6 +44,7 @@ export default { BoardScope: () => import('ee_component/boards/components/board_scope.vue'), GlModal, BoardConfigurationOptions, + GlAlert, }, inject: { fullPath: { @@ -107,6 +108,7 @@ export default { }; }, computed: { + ...mapState(['error']), ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; @@ -222,9 +224,7 @@ export default { } }, methods: { - setIteration(iterationId) { - this.board.iteration_id = iterationId; - }, + ...mapActions(['setError', 'unsetError']), boardCreateResponse(data) { return data.createBoard.board.webPath; }, @@ -235,6 +235,9 @@ export default { : ''; return `${path}${param}`; }, + cancel() { + this.$emit('cancel'); + }, async createOrUpdateBoard() { const response = await this.$apollo.mutate({ mutation: this.currentMutation, @@ -263,7 +266,7 @@ export default { await this.deleteBoard(); visitUrl(this.rootPath); } catch { - Flash(this.$options.i18n.deleteErrorMessage); + this.setError({ message: this.$options.i18n.deleteErrorMessage }); } finally { this.isLoading = false; } @@ -272,15 +275,12 @@ export default { const url = await this.createOrUpdateBoard(); visitUrl(url); } catch { - Flash(this.$options.i18n.saveErrorMessage); + this.setError({ message: this.$options.i18n.saveErrorMessage }); } finally { this.isLoading = false; } } }, - cancel() { - this.$emit('cancel'); - }, resetFormState() { if (this.isNewForm) { // Clear the form when we open the "New board" modal @@ -289,6 +289,25 @@ export default { this.board = { ...boardDefaults, ...this.currentBoard }; } }, + setIteration(iterationId) { + this.board.iteration_id = iterationId; + }, + setBoardLabels(labels) { + labels.forEach((label) => { + if (label.set && !this.board.labels.find((l) => l.id === label.id)) { + this.board.labels.push( + new ListLabel({ + id: label.id, + title: label.title, + color: label.color, + textColor: label.text_color, + }), + ); + } else if (!label.set) { + this.board.labels = this.board.labels.filter((selected) => selected.id !== label.id); + } + }); + }, }, }; </script> @@ -308,6 +327,15 @@ export default { @close="cancel" @hide.prevent > + <gl-alert + v-if="error" + class="gl-mb-3" + variant="danger" + :dismissible="true" + @dismiss="unsetError" + > + {{ error }} + </gl-alert> <p v-if="isDeleteForm" data-testid="delete-confirmation-message"> {{ $options.i18n.deleteConfirmationMessage }} </p> @@ -346,6 +374,7 @@ export default { :group-id="groupId" :weights="weights" @set-iteration="setIteration" + @set-board-labels="setBoardLabels" /> </form> </gl-modal> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 94e29f3ad86..81740b5cd17 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { mapActions, mapGetters, mapState } from 'vuex'; import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; +import Tracking from '~/tracking'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -21,6 +22,13 @@ export default { BoardCard, BoardNewIssue, GlLoadingIcon, + GlIntersectionObserver, + }, + mixins: [Tracking.mixin()], + inject: { + canAdminList: { + default: false, + }, }, props: { disabled: { @@ -35,11 +43,6 @@ export default { type: Array, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -65,7 +68,7 @@ export default { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, hasNextPage() { - return this.pageInfoByListId[this.list.id].hasNextPage; + return this.pageInfoByListId[this.list.id]?.hasNextPage; }, loading() { return this.listsFlags[this.list.id]?.isLoading; @@ -86,7 +89,9 @@ export default { : this.$options.i18n.showingAllIssues; }, treeRootWrapper() { - return this.canAdminList ? Draggable : 'ul'; + return this.canAdminList && !this.listsFlags[this.list.id]?.addItemToListInProgress + ? Draggable + : 'ul'; }, treeRootOptions() { const options = { @@ -108,19 +113,21 @@ export default { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); }, - }, - created() { - eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); - eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); - }, - mounted() { - // Scroll event on list to load more - this.listRef.addEventListener('scroll', this.onScroll); + 'list.id': { + handler(id, oldVal) { + if (id) { + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); + eventHub.$off(`toggle-issue-form-${oldVal}`, this.toggleForm); + eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop); + } + }, + immediate: true, + }, }, beforeDestroy() { eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); - this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { ...mapActions(['fetchItemsForList', 'moveItem']), @@ -142,28 +149,31 @@ export default { toggleForm() { this.showIssueForm = !this.showIssueForm; }, - onScroll() { - window.requestAnimationFrame(() => { - if ( - !this.loadingMore && - this.scrollTop() > this.scrollHeight() - this.scrollOffset && - this.hasNextPage - ) { - this.loadNextPage(); - } - }); + onReachingListBottom() { + if (!this.loadingMore && this.hasNextPage) { + this.showCount = true; + this.loadNextPage(); + } }, handleDragOnStart() { sortableStart(); + this.track('drag_card', { label: 'board' }); }, handleDragOnEnd(params) { sortableEnd(); - const { newIndex, oldIndex, from, to, item } = params; + const { oldIndex, from, to, item } = params; + let { newIndex } = params; const { itemId, itemIid, itemPath } = item.dataset; - const { children } = to; + let { children } = to; let moveBeforeId; let moveAfterId; + children = Array.from(children).filter((card) => card.classList.contains('board-card')); + + if (newIndex > children.length) { + newIndex = children.length; + } + const getItemId = (el) => Number(el.dataset.itemId); // If item is being moved within the same list @@ -226,6 +236,7 @@ export default { :data-board="list.id" :data-board-type="list.listType" :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" + draggable=".board-card" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" data-testid="tree-root-wrapper" @start="handleDragOnStart" @@ -240,15 +251,17 @@ export default { :item="item" :disabled="disabled" /> - <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> - <gl-loading-icon - v-if="loadingMore" - :label="$options.i18n.loadingMoreboardItems" - data-testid="count-loading-icon" - /> - <span v-if="showingAllItems">{{ showingAllItemsText }}</span> - <span v-else>{{ paginatedIssueText }}</span> - </li> + <gl-intersection-observer @appear="onReachingListBottom"> + <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> + <gl-loading-icon + v-if="loadingMore" + :label="$options.i18n.loadingMoreboardItems" + data-testid="count-loading-icon" + /> + <span v-if="showingAllItems">{{ showingAllItemsText }}</span> + <span v-else>{{ paginatedIssueText }}</span> + </li> + </gl-intersection-observer> </component> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 0534e027c86..9b3e7e1547d 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { Sortable, MultiDrag } from 'sortablejs'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { sprintf, __ } from '~/locale'; import eventHub from '../eventhub'; @@ -91,6 +91,13 @@ export default { } }); }, + 'list.id': { + handler(id) { + if (id) { + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + } + }, + }, }, created() { eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); @@ -295,7 +302,9 @@ export default { } if (!toList) { - createFlash(__('Something went wrong while performing the action.')); + createFlash({ + message: __('Something went wrong while performing the action.'), + }); } if (!isSameList) { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index f94697172ac..bf8396f52a6 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -14,6 +14,7 @@ import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import Tracking from '~/tracking'; import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType } from '../constants'; import eventHub from '../eventhub'; @@ -38,6 +39,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin()], inject: { boardId: { default: '', @@ -98,6 +100,12 @@ export default { showListDetails() { return !this.list.collapsed || !this.isSwimlanesHeader; }, + showListHeaderActions() { + if (this.isLoggedIn) { + return this.isNewIssueShown || this.isSettingsShown; + } + return false; + }, itemsCount() { return this.list.issuesCount; }, @@ -149,6 +157,8 @@ export default { } this.setActiveId({ id: this.list.id, sidebarType: LIST }); + + this.track('click_button', { label: 'list_settings' }); }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); @@ -170,6 +180,11 @@ export default { // When expanding/collapsing, the tooltip on the caret button sometimes stays open. // Close all tooltips manually to prevent dangling tooltips. this.$root.$emit(BV_HIDE_TOOLTIP); + + this.track('click_toggle_button', { + label: 'toggle_list', + property: collapsed ? 'closed' : 'open', + }); }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { @@ -351,10 +366,7 @@ export default { <!-- EE end --> </span> </div> - <gl-button-group - v-if="isNewIssueShown || isSettingsShown" - class="board-list-button-group pl-2" - > + <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2"> <gl-button v-if="isNewIssueShown" v-show="!list.collapsed" diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue index 429ffd4cd06..bc29728fc55 100644 --- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue @@ -35,6 +35,9 @@ export default { GlTooltip: GlTooltipDirective, }, inject: { + currentUserId: { + default: null, + }, boardId: { default: '', }, @@ -63,7 +66,7 @@ export default { computed: { ...mapState(['activeId']), isLoggedIn() { - return Boolean(gon.current_user_id); + return Boolean(this.currentUserId); }, listType() { return this.list.type; @@ -89,6 +92,12 @@ export default { showListDetails() { return this.list.isExpanded || !this.isSwimlanesHeader; }, + showListHeaderActions() { + if (this.isLoggedIn) { + return this.isNewIssueShown || this.isSettingsShown; + } + return false; + }, issuesCount() { return this.list.issuesSize; }, @@ -320,10 +329,7 @@ export default { </template> </span> </div> - <gl-button-group - v-if="isNewIssueShown || isSettingsShown" - class="board-list-button-group pl-2" - > + <gl-button-group v-if="showListHeaderActions" class="board-list-button-group pl-2"> <gl-button v-if="isNewIssueShown" ref="newIssueBtn" diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 144cae15ab3..a63b49f9508 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -102,7 +102,7 @@ export default { ref="submitButton" :disabled="disabled" class="float-left js-no-auto-disable" - variant="success" + variant="confirm" category="primary" type="submit" > diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 3d7f1f38a34..75975c77df5 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -6,6 +6,7 @@ import boardsStore from '~/boards/stores/boards_store'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; +import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; // NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. @@ -21,7 +22,7 @@ export default { BoardSettingsListTypes: () => import('ee_component/boards/components/board_settings_list_types.vue'), }, - mixins: [glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['canAdminList'], data() { return { @@ -72,6 +73,7 @@ export default { // eslint-disable-next-line no-alert if (window.confirm(__('Are you sure you want to remove this list?'))) { if (this.shouldUseGraphQL || this.isEpicBoard) { + this.track('click_button', { label: 'remove_list' }); this.removeList(this.activeId); } else { this.activeList.destroy(); diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index fdb60d0ae6a..30e304b8a65 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -3,6 +3,7 @@ import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { formType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; export default { components: { @@ -12,6 +13,7 @@ export default { GlTooltip: GlTooltipDirective, GlModalDirective, }, + mixins: [Tracking.mixin()], props: { boardsStore: { type: Object, @@ -37,6 +39,7 @@ export default { }, methods: { showPage() { + this.track('click_button', { label: 'edit_board' }); eventHub.$emit('showBoardModal', formType.edit); if (this.boardsStore) { this.boardsStore.showPage(formType.edit); diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue index 2652fac1818..6e90731cc2f 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -95,6 +95,9 @@ export default { } return __('Blocked issue'); }, + assignees() { + return this.issue.assignees.filter((_, index) => this.shouldRenderAssignee(index)); + }, }, methods: { isIndexLessThanlimit(index) { @@ -215,8 +218,7 @@ export default { </div> <div class="board-card-assignee gl-display-flex"> <user-avatar-link - v-for="(assignee, index) in issue.assignees" - v-if="shouldRenderAssignee(index)" + v-for="assignee in assignees" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" diff --git a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue index fe56833016e..8ddf50cb357 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue @@ -10,7 +10,7 @@ export default { }, props: { estimate: { - type: Number, + type: [Number, String], required: true, }, }, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue deleted file mode 100644 index 13e1e232676..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ /dev/null @@ -1,110 +0,0 @@ -<script> -import { GlButton, GlDatepicker } from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; -import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; - -export default { - components: { - BoardEditableItem, - GlButton, - GlDatepicker, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), - hasDueDate() { - return this.activeBoardItem.dueDate != null; - }, - parsedDueDate() { - if (!this.hasDueDate) { - return null; - } - - return parsePikadayDate(this.activeBoardItem.dueDate); - }, - formattedDueDate() { - if (!this.hasDueDate) { - return ''; - } - - return dateInWords(this.parsedDueDate, true); - }, - }, - methods: { - ...mapActions(['setActiveIssueDueDate']), - async openDatePicker() { - await this.$nextTick(); - this.$refs.datePicker.calendar.show(); - }, - async setDueDate(date) { - this.loading = true; - this.$refs.sidebarItem.collapse(); - - try { - const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null; - await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue }); - } catch (e) { - createFlash({ message: this.$options.i18n.updateDueDateError }); - } finally { - this.loading = false; - } - }, - }, - i18n: { - dueDate: __('Due date'), - removeDueDate: __('remove due date'), - updateDueDateError: __('An error occurred when updating the issue due date'), - }, -}; -</script> - -<template> - <board-editable-item - ref="sidebarItem" - class="board-sidebar-due-date" - data-testid="sidebar-due-date" - :title="$options.i18n.dueDate" - :loading="loading" - @open="openDatePicker" - > - <template v-if="hasDueDate" #collapsed> - <div class="gl-display-flex gl-align-items-center"> - <strong class="gl-text-gray-900">{{ formattedDueDate }}</strong> - <span class="gl-mx-2">-</span> - <gl-button - variant="link" - class="gl-text-gray-500!" - data-testid="reset-button" - :disabled="loading" - @click="setDueDate(null)" - > - {{ $options.i18n.removeDueDate }} - </gl-button> - </div> - </template> - <gl-datepicker - ref="datePicker" - :value="parsedDueDate" - show-clear-button - @input="setDueDate" - @clear="setDueDate(null)" - /> - </board-editable-item> -</template> -<style> -/* - * This can be removed after closing: - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048 - */ -.board-sidebar-due-date .gl-datepicker, -.board-sidebar-due-date .gl-datepicker-input { - width: 100%; -} -</style> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 919ef0d3783..29febd0fa51 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -3,7 +3,6 @@ import { GlLabel } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import Api from '~/api'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -50,10 +49,10 @@ export default { /* Labels fetched in epic boards are always group-level labels and the correct path are passed from the backend (injected through labelsFetchPath) - + For issue boards, we should always include project-level labels and use a different endpoint. (it requires knowing the project path of a selected issue.) - + Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget. And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653. @@ -74,7 +73,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveBoardItemLabels']), + ...mapActions(['setActiveBoardItemLabels', 'setError']), async setLabels(payload) { this.loading = true; this.$refs.sidebarItem.collapse(); @@ -88,7 +87,7 @@ export default { const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveBoardItemLabels(input); } catch (e) { - createFlash({ message: __('An error occurred while updating labels.') }); + this.setError({ error: e, message: __('An error occurred while updating labels.') }); } finally { this.loading = false; } @@ -101,7 +100,7 @@ export default { const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveBoardItemLabels(input); } catch (e) { - createFlash({ message: __('An error occurred when removing the label.') }); + this.setError({ error: e, message: __('An error occurred when removing the label.') }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue deleted file mode 100644 index ad225c7bf5c..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ /dev/null @@ -1,158 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; -import { __, s__ } from '~/locale'; -import projectMilestones from '../../graphql/project_milestones.query.graphql'; - -export default { - components: { - BoardEditableItem, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlDropdownDivider, - }, - data() { - return { - milestones: [], - searchTitle: '', - loading: false, - edit: false, - }; - }, - apollo: { - milestones: { - query: projectMilestones, - debounce: 250, - skip() { - return !this.edit; - }, - variables() { - return { - fullPath: this.projectPath, - searchTitle: this.searchTitle, - state: 'active', - includeAncestors: true, - }; - }, - update(data) { - const edges = data?.project?.milestones?.edges ?? []; - return edges.map((item) => item.node); - }, - error() { - createFlash({ message: this.$options.i18n.fetchMilestonesError }); - }, - }, - }, - computed: { - ...mapGetters(['activeBoardItem']), - hasMilestone() { - return this.activeBoardItem.milestone !== null; - }, - groupFullPath() { - const { referencePath = '' } = this.activeBoardItem; - return referencePath.slice(0, referencePath.indexOf('/')); - }, - projectPath() { - const { referencePath = '' } = this.activeBoardItem; - return referencePath.slice(0, referencePath.indexOf('#')); - }, - dropdownText() { - return this.activeBoardItem.milestone?.title ?? this.$options.i18n.noMilestone; - }, - }, - methods: { - ...mapActions(['setActiveIssueMilestone']), - handleOpen() { - this.edit = true; - this.$refs.dropdown.show(); - }, - handleClose() { - this.edit = false; - this.$refs.sidebarItem.collapse(); - }, - async setMilestone(milestoneId) { - this.loading = true; - this.searchTitle = ''; - this.handleClose(); - - try { - const input = { milestoneId, projectPath: this.projectPath }; - await this.setActiveIssueMilestone(input); - } catch (e) { - createFlash({ message: this.$options.i18n.updateMilestoneError }); - } finally { - this.loading = false; - } - }, - }, - i18n: { - milestone: __('Milestone'), - noMilestone: __('No milestone'), - assignMilestone: __('Assign milestone'), - noMilestonesFound: s__('Milestones|No milestones found'), - fetchMilestonesError: __('There was a problem fetching milestones.'), - updateMilestoneError: __('An error occurred while updating the milestone.'), - }, -}; -</script> - -<template> - <board-editable-item - ref="sidebarItem" - :title="$options.i18n.milestone" - :loading="loading" - data-testid="sidebar-milestones" - @open="handleOpen" - @close="handleClose" - > - <template v-if="hasMilestone" #collapsed> - <strong class="gl-text-gray-900">{{ activeBoardItem.milestone.title }}</strong> - </template> - <gl-dropdown - ref="dropdown" - :text="dropdownText" - :header-text="$options.i18n.assignMilestone" - block - @hide="handleClose" - > - <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> - <gl-dropdown-item - data-testid="no-milestone-item" - :is-check-item="true" - :is-checked="!activeBoardItem.milestone" - @click="setMilestone(null)" - > - {{ $options.i18n.noMilestone }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> - <template v-else-if="milestones.length > 0"> - <gl-dropdown-item - v-for="milestone in milestones" - :key="milestone.id" - :is-check-item="true" - :is-checked="activeBoardItem.milestone && milestone.id === activeBoardItem.milestone.id" - data-testid="milestone-item" - @click="setMilestone(milestone.id)" - > - {{ milestone.title }} - </gl-dropdown-item> - </template> - <gl-dropdown-text v-else data-testid="no-milestones-found"> - {{ $options.i18n.noMilestonesFound }} - </gl-dropdown-text> - </gl-dropdown> - </board-editable-item> -</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue index 376985f7cb6..4f5c55d0c5d 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -1,7 +1,6 @@ <script> import { GlToggle } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; -import createFlash from '~/flash'; import { __, s__ } from '~/locale'; export default { @@ -39,17 +38,16 @@ export default { }, }, methods: { - ...mapActions(['setActiveItemSubscribed']), + ...mapActions(['setActiveItemSubscribed', 'setError']), async handleToggleSubscription() { this.loading = true; - try { await this.setActiveItemSubscribed({ subscribed: !this.activeBoardItem.subscribed, projectPath: this.projectPathForActiveIssue, }); } catch (error) { - createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage }); + this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue index 96d444980a8..5d61f7b2887 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -9,17 +9,29 @@ export default { inject: ['timeTrackingLimitToHours'], computed: { ...mapGetters(['activeBoardItem']), + initialTimeTracking() { + const { + timeEstimate, + totalTimeSpent, + humanTimeEstimate, + humanTotalTimeSpent, + } = this.activeBoardItem; + return { + timeEstimate, + totalTimeSpent, + humanTimeEstimate, + humanTotalTimeSpent, + }; + }, }, }; </script> <template> <issuable-time-tracker - :time-estimate="activeBoardItem.timeEstimate" - :time-spent="activeBoardItem.totalTimeSpent" - :human-time-estimate="activeBoardItem.humanTimeEstimate" - :human-time-spent="activeBoardItem.humanTotalTimeSpent" + :issuable-iid="activeBoardItem.iid.toString()" :limit-to-hours="timeTrackingLimitToHours" + :initial-time-tracking="initialTimeTracking" :show-collapsed="false" /> </template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index b8d3107c377..e77aadfa50e 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -2,7 +2,6 @@ import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -53,7 +52,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveItemTitle']), + ...mapActions(['setActiveItemTitle', 'setError']), getPendingChangesKey(item) { if (!item) { return ''; @@ -97,7 +96,7 @@ export default { this.showChangesAlert = false; } catch (e) { this.title = this.item.title; - createFlash({ message: this.$options.i18n.updateTitleError }); + this.setError({ error: e, message: this.$options.i18n.updateTitleError }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index d88774d11c1..80a8fc99895 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -9,17 +9,6 @@ import updateBoardListMutation from './graphql/board_list_update.mutation.graphq import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; -export const SupportedFilters = [ - 'assigneeUsername', - 'authorUsername', - 'labelName', - 'milestoneTitle', - 'releaseTag', - 'search', - 'myReactionEmoji', - 'assigneeId', -]; - /* eslint-disable-next-line @gitlab/require-i18n-strings */ export const AssigneeIdParamValues = ['Any', 'None']; @@ -47,6 +36,7 @@ export const ListTypeTitles = { milestone: __('Milestone'), iteration: __('Iteration'), label: __('Label'), + backlog: __('Open'), }; export const formType = { @@ -60,8 +50,6 @@ export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; -export const NOT_FILTER = 'not['; - export const flashAnimationDuration = 2000; export const listsQuery = { @@ -106,6 +94,19 @@ export const subscriptionQueries = { }, }; +export const FilterFields = { + [issuableTypes.issue]: [ + 'assigneeUsername', + 'assigneeWildcardId', + 'authorUsername', + 'labelName', + 'milestoneTitle', + 'myReactionEmoji', + 'releaseTag', + 'search', + ], +}; + export default { BoardType, ListType, diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql new file mode 100644 index 00000000000..3b8c5389725 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +query GroupBoardMembers($fullPath: ID!, $search: String) { + workspace: group(fullPath: $fullPath) { + __typename + assignees: groupMembers(search: $search) { + __typename + nodes { + id + user { + ...User + } + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 47ecb55c72b..0ff70703e1a 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -14,10 +14,6 @@ fragment IssueNode on Issue { confidential webUrl relativePosition - milestone { - id - title - } assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql deleted file mode 100644 index bbea248cf85..00000000000 --- a/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation issueSetDueDate($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { - dueDate - } - errors - } -} diff --git a/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql deleted file mode 100644 index 5dc78a03a06..00000000000 --- a/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql +++ /dev/null @@ -1,12 +0,0 @@ -mutation issueSetMilestone($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { - milestone { - id - title - description - } - } - errors - } -} diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 43af7d2b2f1..d1cb1ecf834 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -12,11 +12,11 @@ query ListIssues( ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { - lists(id: $id) { + lists(id: $id, issueFilters: $filters) { nodes { id + issuesCount issues(first: $first, filters: $filters, after: $after) { - count edges { node { ...IssueNode @@ -33,11 +33,11 @@ query ListIssues( } project(fullPath: $fullPath) @include(if: $isProject) { board(id: $boardId) { - lists(id: $id) { + lists(id: $id, issueFilters: $filters) { nodes { id + issuesCount issues(first: $first, filters: $filters, after: $after) { - count edges { node { ...IssueNode diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql new file mode 100644 index 00000000000..fc6cc6b832c --- /dev/null +++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +query ProjectBoardMembers($fullPath: ID!, $search: String) { + workspace: project(fullPath: $fullPath) { + __typename + assignees: projectMembers(search: $search) { + __typename + nodes { + id + user { + ...User + } + } + } + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 1888645ef78..fb347ce852d 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -27,7 +27,6 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import toggleFocusMode from '~/boards/toggle_focus'; -import { deprecatedCreateFlash as Flash } from '~/flash'; import createDefaultClient from '~/lib/graphql'; import { NavigationType, @@ -196,7 +195,7 @@ export default () => { } }, methods: { - ...mapActions(['setInitialBoardData', 'performSearch']), + ...mapActions(['setInitialBoardData', 'performSearch', 'setError']), initialBoardLoad() { boardsStore .all() @@ -205,8 +204,11 @@ export default () => { lists.forEach((list) => boardsStore.addList(list)); this.loading = false; }) - .catch(() => { - Flash(__('An error occurred while fetching the board lists. Please try again.')); + .catch((error) => { + this.setError({ + error, + message: __('An error occurred while fetching the board lists. Please try again.'), + }); }); }, updateTokens() { @@ -250,7 +252,7 @@ export default () => { .catch(() => { newIssue.setFetchingState('subscriptions', false); setWeightFetchingState(newIssue, false); - Flash(__('An error occurred while fetching sidebar data')); + this.setError({ message: __('An error occurred while fetching sidebar data') }); }); } @@ -287,7 +289,9 @@ export default () => { }) .catch(() => { issue.setFetchingState('subscriptions', false); - Flash(__('An error occurred when toggling the notification subscription')); + this.setError({ + message: __('An error occurred when toggling the notification subscription'), + }); }); } }, diff --git a/app/assets/javascripts/boards/models/project.js b/app/assets/javascripts/boards/models/project.js index a3d5c7af7ac..9468a02856e 100644 --- a/app/assets/javascripts/boards/models/project.js +++ b/app/assets/javascripts/boards/models/project.js @@ -2,5 +2,6 @@ export default class IssueProject { constructor(obj) { this.id = obj.id; this.path = obj.path; + this.fullPath = obj.path_with_namespace; } } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 5158e82c320..d4893f9eca7 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -7,11 +7,12 @@ import { ISSUABLE, titleQueries, subscriptionQueries, - SupportedFilters, deleteListQueries, listsQuery, updateListQueries, issuableTypes, + FilterFields, + ListTypeTitles, } from 'ee_else_ce/boards/constants'; import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; @@ -26,17 +27,15 @@ import { formatIssue, formatIssueInput, updateListPosition, - transformNotFilters, moveItemListHelper, getMoveData, - getSupportedParams, + FiltersInfo, + filterVariables, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; -import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import * as types from './mutation_types'; @@ -60,13 +59,16 @@ export default { dispatch('setActiveId', { id: inactiveId, sidebarType: '' }); }, - setFilters: ({ commit }, filters) => { - const filterParams = { - ...getSupportedParams(filters, SupportedFilters), - not: transformNotFilters(filters), - }; - - commit(types.SET_FILTERS, filterParams); + setFilters: ({ commit, state: { issuableType } }, filters) => { + commit( + types.SET_FILTERS, + filterVariables({ + filters, + issuableType, + filterInfo: FiltersInfo, + filterFields: FilterFields, + }), + ); }, performSearch({ dispatch }) { @@ -166,8 +168,11 @@ export default { }); }, - addList: ({ commit }, list) => { + addList: ({ commit, dispatch, getters }, list) => { commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); + dispatch('fetchItemsForList', { + listId: getters.getListByTitle(ListTypeTitles.backlog).id, + }); }, fetchLabels: ({ state, commit, getters }, searchTerm) => { @@ -258,7 +263,7 @@ export default { commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); }, - removeList: ({ state: { issuableType, boardLists }, commit }, listId) => { + removeList: ({ state: { issuableType, boardLists }, commit, dispatch, getters }, listId) => { const listsBackup = { ...boardLists }; commit(types.REMOVE_LIST, listId); @@ -278,6 +283,10 @@ export default { }) => { if (errors.length > 0) { commit(types.REMOVE_LIST_FAILURE, listsBackup); + } else { + dispatch('fetchItemsForList', { + listId: getters.getListByTitle(ListTypeTitles.backlog).id, + }); } }, ) @@ -287,6 +296,9 @@ export default { }, fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + if (!fetchNext) { + commit(types.RESET_ITEMS_FOR_LIST, listId); + } commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, fullBoardId, boardType, filterParams } = state; @@ -298,7 +310,7 @@ export default { filters: filterParams, isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, - first: 20, + first: 10, after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; @@ -465,32 +477,13 @@ export default { }); }, - setActiveIssueMilestone: async ({ commit, getters }, input) => { - const { activeBoardItem } = getters; - const { data } = await gqlClient.mutate({ - mutation: issueSetMilestoneMutation, - variables: { - input: { - iid: String(activeBoardItem.iid), - milestoneId: getIdFromGraphQLId(input.milestoneId), - projectPath: input.projectPath, - }, - }, - }); - - if (data.updateIssue.errors?.length > 0) { - throw new Error(data.updateIssue.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'milestone', - value: data.updateIssue.issue.milestone, + addListItem: ({ commit }, { list, item, position, inProgress = false }) => { + commit(types.ADD_BOARD_ITEM_TO_LIST, { + listId: list.id, + itemId: item.id, + atIndex: position, + inProgress, }); - }, - - addListItem: ({ commit }, { list, item, position }) => { - commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, atIndex: position }); commit(types.UPDATE_BOARD_ITEM, item); }, @@ -509,8 +502,8 @@ export default { input.projectPath = fullPath; } - const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId }); - dispatch('addListItem', { list, item: placeholderIssue, position: 0 }); + const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId, isLoading: true }); + dispatch('addListItem', { list, item: placeholderIssue, position: 0, inProgress: true }); gqlClient .mutate({ @@ -565,30 +558,6 @@ export default { }); }, - setActiveIssueDueDate: async ({ commit, getters }, input) => { - const { activeBoardItem } = getters; - const { data } = await gqlClient.mutate({ - mutation: issueSetDueDateMutation, - variables: { - input: { - iid: String(activeBoardItem.iid), - projectPath: input.projectPath, - dueDate: input.dueDate, - }, - }, - }); - - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'dueDate', - value: data.updateIssue.issue.dueDate, - }); - }, - setActiveItemSubscribed: async ({ commit, getters, state }, input) => { const { activeBoardItem, isEpicBoard } = getters; const { fullPath, issuableType } = state; @@ -721,7 +690,7 @@ export default { } }, - setError: ({ commit }, { message, error, captureError = false }) => { + setError: ({ commit }, { message, error, captureError = true }) => { commit(types.SET_ERROR, message); if (captureError) { diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index ccea2917c2c..38c54bc8c5d 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -15,6 +15,7 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; +export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 667628b2998..6cd0a62657e 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -117,6 +117,11 @@ export default { state.boardLists = listsBackup; }, + [mutationTypes.RESET_ITEMS_FOR_LIST]: (state, listId) => { + Vue.set(state, 'backupItemsList', state.boardItemsByListId[listId]); + Vue.set(state.boardItemsByListId, listId, []); + }, + [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, @@ -138,6 +143,7 @@ export default { 'Boards|An error occurred while fetching the board issues. Please reload the page.', ); Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); + Vue.set(state.boardItemsByListId, listId, state.backupItemsList); }, [mutationTypes.RESET_ISSUES]: (state) => { @@ -166,8 +172,9 @@ export default { [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( state, - { itemId, listId, moveBeforeId, moveAfterId, atIndex }, + { itemId, listId, moveBeforeId, moveAfterId, atIndex, inProgress = false }, ) => { + Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress }); addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }); }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 19ba2a5df83..7be5ae8b583 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -11,6 +11,7 @@ export default () => ({ boardLists: {}, listsFlags: {}, boardItemsByListId: {}, + backupItemsList: [], isSettingAssignees: false, pageInfoByListId: {}, boardItems: {}, diff --git a/app/assets/javascripts/branches/components/delete_branch_button.vue b/app/assets/javascripts/branches/components/delete_branch_button.vue new file mode 100644 index 00000000000..5a5f49e25e7 --- /dev/null +++ b/app/assets/javascripts/branches/components/delete_branch_button.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + name: 'DeleteBranchButton', + components: { GlButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + branchName: { + type: String, + required: false, + default: '', + }, + defaultBranchName: { + type: String, + required: false, + default: '', + }, + deletePath: { + type: String, + required: false, + default: '', + }, + tooltip: { + type: String, + required: false, + default: s__('Branches|Delete branch'), + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + isProtectedBranch: { + type: Boolean, + required: false, + default: false, + }, + merged: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + variant() { + if (this.disabled) { + return 'default'; + } + return 'danger'; + }, + title() { + if (this.isProtectedBranch && this.disabled) { + return s__('Branches|Only a project maintainer or owner can delete a protected branch'); + } else if (this.isProtectedBranch) { + return s__('Branches|Delete protected branch'); + } + return this.tooltip; + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { + branchName: this.branchName, + defaultBranchName: this.defaultBranchName, + deletePath: this.deletePath, + isProtectedBranch: this.isProtectedBranch, + merged: this.merged, + }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover + icon="remove" + class="js-delete-branch-button" + data-qa-selector="delete_branch_button" + :disabled="disabled" + :variant="variant" + :title="title" + :aria-label="title" + @click="openModal" + /> +</template> diff --git a/app/assets/javascripts/branches/components/delete_branch_modal.vue b/app/assets/javascripts/branches/components/delete_branch_modal.vue new file mode 100644 index 00000000000..14c2badeb3f --- /dev/null +++ b/app/assets/javascripts/branches/components/delete_branch_modal.vue @@ -0,0 +1,193 @@ +<script> +import { GlButton, GlFormInput, GlModal, GlSprintf, GlAlert } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { sprintf, s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + csrf, + components: { + GlModal, + GlButton, + GlFormInput, + GlSprintf, + GlAlert, + }, + data() { + return { + isProtectedBranch: false, + branchName: '', + defaultBranchName: '', + deletePath: '', + merged: false, + enteredBranchName: '', + modalId: 'delete-branch-modal', + }; + }, + computed: { + title() { + const modalTitle = this.isProtectedBranch + ? this.$options.i18n.modalTitleProtectedBranch + : this.$options.i18n.modalTitle; + + return sprintf(modalTitle, { branchName: this.branchName }); + }, + message() { + const modalMessage = this.isProtectedBranch + ? this.$options.i18n.modalMessageProtectedBranch + : this.$options.i18n.modalMessage; + + return sprintf(modalMessage, { branchName: this.branchName }); + }, + unmergedWarning() { + return sprintf(this.$options.i18n.unmergedWarning, { + defaultBranchName: this.defaultBranchName, + }); + }, + undoneWarning() { + return sprintf(this.$options.i18n.undoneWarning, { + buttonText: this.buttonText, + }); + }, + confirmationText() { + return sprintf(this.$options.i18n.confirmationText, { + branchName: this.branchName, + }); + }, + buttonText() { + return this.isProtectedBranch + ? this.$options.i18n.deleteButtonTextProtectedBranch + : this.$options.i18n.deleteButtonText; + }, + branchNameConfirmed() { + return this.enteredBranchName === this.branchName; + }, + deleteButtonDisabled() { + return this.isProtectedBranch && !this.branchNameConfirmed; + }, + }, + mounted() { + eventHub.$on('openModal', this.openModal); + }, + destroyed() { + eventHub.$off('openModal', this.openModal); + }, + methods: { + openModal({ isProtectedBranch, branchName, defaultBranchName, deletePath, merged }) { + this.isProtectedBranch = isProtectedBranch; + this.branchName = branchName; + this.defaultBranchName = defaultBranchName; + this.deletePath = deletePath; + this.merged = merged; + + this.$refs.modal.show(); + }, + submitForm() { + this.$refs.form.submit(); + }, + closeModal() { + this.$refs.modal.hide(); + }, + }, + i18n: { + modalTitle: s__('Branches|Delete branch. Are you ABSOLUTELY SURE?'), + modalTitleProtectedBranch: s__('Branches|Delete protected branch. Are you ABSOLUTELY SURE?'), + modalMessage: s__( + "Branches|You're about to permanently delete the branch %{strongStart}%{branchName}.%{strongEnd}", + ), + modalMessageProtectedBranch: s__( + "Branches|You're about to permanently delete the protected branch %{strongStart}%{branchName}.%{strongEnd}", + ), + unmergedWarning: s__( + 'Branches|This branch hasn’t been merged into %{defaultBranchName}. To avoid data loss, consider merging this branch before deleting it.', + ), + undoneWarning: s__( + 'Branches|Once you confirm and press %{strongStart}%{buttonText},%{strongEnd} it cannot be undone or recovered.', + ), + cancelButtonText: s__('Branches|Cancel, keep branch'), + confirmationText: s__( + 'Branches|Deleting the %{strongStart}%{branchName}%{strongEnd} branch cannot be undone. Are you sure?', + ), + confirmationTextProtectedBranch: s__('Branches|Please type the following to confirm:'), + deleteButtonText: s__('Branches|Yes, delete branch'), + deleteButtonTextProtectedBranch: s__('Branches|Yes, delete protected branch'), + }, +}; +</script> + +<template> + <gl-modal ref="modal" size="sm" :modal-id="modalId" :title="title"> + <gl-alert class="gl-mb-5" variant="danger" :dismissible="false"> + <div data-testid="modal-message"> + <gl-sprintf :message="message"> + <template #strong="{ content }"> + <strong> {{ content }} </strong> + </template> + </gl-sprintf> + <p v-if="!merged" class="gl-mb-0 gl-mt-4"> + {{ unmergedWarning }} + </p> + </div> + </gl-alert> + + <form ref="form" :action="deletePath" method="post"> + <div v-if="isProtectedBranch" class="gl-mt-4"> + <p> + <gl-sprintf :message="undoneWarning"> + <template #strong="{ content }"> + <strong> {{ content }} </strong> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.i18n.confirmationTextProtectedBranch"> + <template #strong="{ content }"> + {{ content }} + </template> + </gl-sprintf> + <code class="gl-white-space-pre-wrap"> {{ branchName }} </code> + <gl-form-input + v-model="enteredBranchName" + name="delete_branch_input" + type="text" + class="gl-mt-4" + aria-labelledby="input-label" + autocomplete="off" + /> + </p> + </div> + <div v-else> + <p class="gl-mt-4"> + <gl-sprintf :message="confirmationText"> + <template #strong="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </p> + </div> + + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </form> + + <template #modal-footer> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> + <gl-button data-testid="delete-branch-cancel-button" @click="closeModal"> + {{ $options.i18n.cancelButtonText }} + </gl-button> + <div class="gl-mr-3"></div> + <gl-button + ref="deleteBranchButton" + :disabled="deleteButtonDisabled" + variant="danger" + data-qa-selector="delete_branch_confirmation_button" + data-testid="delete-branch-confirmation-button" + @click="submitForm" + >{{ buttonText }}</gl-button + > + </div> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index ddb4c5c0015..5f782b5e652 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -62,17 +62,18 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-pr-4"> + <div class="gl-display-flex"> <gl-search-box-by-click v-model="searchTerm" :placeholder="$options.i18n.searchPlaceholder" - class="gl-pr-4" + class="gl-mr-3" data-testid="branch-search" @submit="visitUrlFromOption(selectedKey)" /> <gl-dropdown v-if="shouldShowDropdown" :text="selectedSortMethodName" + class="gl-mr-3" data-testid="branches-dropdown" > <gl-dropdown-item diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index 66e8d982113..b88c056b00f 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as createFlash } from '../flash'; +import createFlash from '../flash'; import axios from '../lib/utils/axios_utils'; import { __ } from '../locale'; import DivergenceGraph from './components/divergence_graph.vue'; @@ -51,6 +51,8 @@ export default (endpoint, defaultBranch) => { }); }) .catch(() => - createFlash(__('Error fetching diverging counts for branches. Please try again.')), + createFlash({ + message: __('Error fetching diverging counts for branches. Please try again.'), + }), ); }; diff --git a/app/assets/javascripts/branches/event_hub.js b/app/assets/javascripts/branches/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/branches/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/branches/init_delete_branch_button.js b/app/assets/javascripts/branches/init_delete_branch_button.js new file mode 100644 index 00000000000..43df5d993a4 --- /dev/null +++ b/app/assets/javascripts/branches/init_delete_branch_button.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import DeleteBranchButton from '~/branches/components/delete_branch_button.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default function initDeleteBranchButton(el) { + if (!el) { + return false; + } + + const { + branchName, + defaultBranchName, + deletePath, + tooltip, + disabled, + isProtectedBranch, + merged, + } = el.dataset; + + return new Vue({ + el, + render: (createElement) => + createElement(DeleteBranchButton, { + props: { + branchName, + defaultBranchName, + deletePath, + tooltip, + disabled: parseBoolean(disabled), + isProtectedBranch: parseBoolean(isProtectedBranch), + merged: parseBoolean(merged), + }, + }), + }); +} diff --git a/app/assets/javascripts/branches/init_delete_branch_modal.js b/app/assets/javascripts/branches/init_delete_branch_modal.js new file mode 100644 index 00000000000..f3a95d03c5a --- /dev/null +++ b/app/assets/javascripts/branches/init_delete_branch_modal.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import DeleteBranchModal from '~/branches/components/delete_branch_modal.vue'; + +export default function initDeleteBranchModal() { + const el = document.querySelector('.js-delete-branch-modal'); + if (!el) { + return false; + } + + return new Vue({ + el, + render(createComponent) { + return createComponent(DeleteBranchModal); + }, + }); +} diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index bc1e401d373..77ec1f1af47 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -93,7 +93,7 @@ export default { placement="top" class="trigger-description gl-display-flex" > - <div class="gl-flex-fill-1 gl-text-truncate">{{ item.description }}</div> + <div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div> </tooltip-on-truncate> </template> <template #cell(owner)="{ item }"> diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index 8569cecc6a7..8a182737e7b 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -48,7 +48,9 @@ export const addVariable = ({ state, dispatch }) => { dispatch('fetchVariables'); }) .catch((error) => { - createFlash(error.response.data[0]); + createFlash({ + message: error.response.data[0], + }); dispatch('receiveAddVariableError', error); }); }; @@ -78,7 +80,9 @@ export const updateVariable = ({ state, dispatch }) => { dispatch('fetchVariables'); }) .catch((error) => { - createFlash(error.response.data[0]); + createFlash({ + message: error.response.data[0], + }); dispatch('receiveUpdateVariableError', error); }); }; @@ -105,7 +109,9 @@ export const fetchVariables = ({ dispatch, state }) => { dispatch('receiveVariablesSuccess', prepareDataForDisplay(data.variables)); }) .catch(() => { - createFlash(__('There was an error fetching the variables.')); + createFlash({ + message: __('There was an error fetching the variables.'), + }); }); }; @@ -133,7 +139,9 @@ export const deleteVariable = ({ dispatch, state }) => { dispatch('fetchVariables'); }) .catch((error) => { - createFlash(error.response.data[0]); + createFlash({ + message: error.response.data[0], + }); dispatch('receiveDeleteVariableError', error); }); }; @@ -154,7 +162,9 @@ export const fetchEnvironments = ({ dispatch, state }) => { dispatch('receiveEnvironmentsSuccess', prepareEnvironments(res.data)); }) .catch(() => { - createFlash(__('There was an error fetching the environments information.')); + createFlash({ + message: __('There was an error fetching the environments information.'), + }); }); }; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 5cb3d913210..762b37a8216 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -11,7 +11,7 @@ import PersistentUserCallout from '../persistent_user_callout'; import initSettingsPanels from '../settings_panels'; import Applications from './components/applications.vue'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; -import { APPLICATION_STATUS, CROSSPLANE, KNATIVE, FLUENTD } from './constants'; +import { APPLICATION_STATUS, CROSSPLANE, KNATIVE } from './constants'; import eventHub from './event_hub'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; @@ -42,7 +42,6 @@ export default class Clusters { installElasticStackPath, installCrossplanePath, installPrometheusPath, - installFluentdPath, managePrometheusPath, clusterEnvironmentsPath, hasRbac, @@ -55,7 +54,6 @@ export default class Clusters { helmHelpPath, ingressHelpPath, ingressDnsHelpPath, - ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -74,7 +72,6 @@ export default class Clusters { helmHelpPath, ingressHelpPath, ingressDnsHelpPath, - ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -100,7 +97,6 @@ export default class Clusters { updateKnativeEndpoint: updateKnativePath, installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, - installFluentdEndpoint: installFluentdPath, }); this.installApplication = this.installApplication.bind(this); @@ -168,7 +164,6 @@ export default class Clusters { ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, ingressDnsHelpPath: this.state.ingressDnsHelpPath, - ingressModSecurityHelpPath: this.state.ingressModSecurityHelpPath, cloudRunHelpPath: this.state.cloudRunHelpPath, providerType: this.state.providerType, preInstalledKnative: this.state.preInstalledKnative, @@ -253,10 +248,6 @@ export default class Clusters { eventHub.$on('setKnativeDomain', (data) => this.setKnativeDomain(data)); eventHub.$on('uninstallApplication', (data) => this.uninstallApplication(data)); eventHub.$on('setCrossplaneProviderStack', (data) => this.setCrossplaneProviderStack(data)); - eventHub.$on('setIngressModSecurityEnabled', (data) => this.setIngressModSecurityEnabled(data)); - eventHub.$on('setIngressModSecurityMode', (data) => this.setIngressModSecurityMode(data)); - eventHub.$on('resetIngressModSecurityChanges', (id) => this.resetIngressModSecurityChanges(id)); - eventHub.$on('setFluentdSettings', (data) => this.setFluentdSettings(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -273,14 +264,6 @@ export default class Clusters { eventHub.$off('setCrossplaneProviderStack'); // eslint-disable-next-line @gitlab/no-global-event-off eventHub.$off('uninstallApplication'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('setIngressModSecurityEnabled'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('setIngressModSecurityMode'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('resetIngressModSecurityChanges'); - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('setFluentdSettings'); } initPolling(method, successCallback, errorCallback) { @@ -492,12 +475,6 @@ export default class Clusters { }); } - setFluentdSettings(settings = {}) { - Object.entries(settings).forEach(([key, value]) => { - this.store.updateAppProperty(FLUENTD, key, value); - }); - } - saveKnativeDomain(data) { const appId = data.id; this.store.updateApplication(appId); @@ -519,21 +496,6 @@ export default class Clusters { this.store.updateAppProperty(appId, 'validationError', null); } - setIngressModSecurityEnabled({ id, modSecurityEnabled }) { - this.store.updateAppProperty(id, 'isEditingModSecurityEnabled', true); - this.store.updateAppProperty(id, 'modsecurity_enabled', modSecurityEnabled); - } - - setIngressModSecurityMode({ id, modSecurityMode }) { - this.store.updateAppProperty(id, 'isEditingModSecurityMode', true); - this.store.updateAppProperty(id, 'modsecurity_mode', modSecurityMode); - } - - resetIngressModSecurityChanges(id) { - this.store.updateAppProperty(id, 'isEditingModSecurityEnabled', false); - this.store.updateAppProperty(id, 'isEditingModSecurityMode', false); - } - destroy() { this.destroyed = true; diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 8ef3169dc65..ddee1711975 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -3,7 +3,6 @@ import { GlLoadingIcon, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; import crossplaneLogo from 'images/cluster_app_logos/crossplane.png'; import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; -import fluentdLogo from 'images/cluster_app_logos/fluentd.png'; import gitlabLogo from 'images/cluster_app_logos/gitlab.png'; import helmLogo from 'images/cluster_app_logos/helm.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; @@ -15,8 +14,6 @@ import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; import applicationRow from './application_row.vue'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; -import FluentdOutputSettings from './fluentd_output_settings.vue'; -import IngressModsecuritySettings from './ingress_modsecurity_settings.vue'; import KnativeDomainEditor from './knative_domain_editor.vue'; export default { @@ -28,8 +25,6 @@ export default { GlLink, KnativeDomainEditor, CrossplaneProviderStack, - IngressModsecuritySettings, - FluentdOutputSettings, GlAlert, }, props: { @@ -63,11 +58,7 @@ export default { required: false, default: '', }, - ingressModSecurityHelpPath: { - type: String, - required: false, - default: '', - }, + cloudRunHelpPath: { type: String, required: false, @@ -165,7 +156,6 @@ export default { knativeLogo, prometheusLogo, elasticStackLogo, - fluentdLogo, }, }; </script> @@ -219,10 +209,6 @@ export default { :request-reason="applications.ingress.requestReason" :installed="applications.ingress.installed" :install-failed="applications.ingress.installFailed" - :install-application-request-params="{ - modsecurity_enabled: applications.ingress.modsecurity_enabled, - modsecurity_mode: applications.ingress.modsecurity_mode, - }" :uninstallable="applications.ingress.uninstallable" :uninstall-successful="applications.ingress.uninstallSuccessful" :uninstall-failed="applications.ingress.uninstallFailed" @@ -238,11 +224,6 @@ export default { }} </p> - <ingress-modsecurity-settings - :ingress="ingress" - :ingress-mod-security-help-path="ingressModSecurityHelpPath" - /> - <template v-if="ingressInstalled"> <div class="form-group"> <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> @@ -644,50 +625,6 @@ export default { </template> </application-row> - <application-row - id="fluentd" - :logo-url="$options.logos.fluentdLogo" - :title="applications.fluentd.title" - :status="applications.fluentd.status" - :status-reason="applications.fluentd.statusReason" - :request-status="applications.fluentd.requestStatus" - :request-reason="applications.fluentd.requestReason" - :installed="applications.fluentd.installed" - :install-failed="applications.fluentd.installFailed" - :install-application-request-params="{ - host: applications.fluentd.host, - port: applications.fluentd.port, - protocol: applications.fluentd.protocol, - waf_log_enabled: applications.fluentd.wafLogEnabled, - cilium_log_enabled: applications.fluentd.ciliumLogEnabled, - }" - :uninstallable="applications.fluentd.uninstallable" - :uninstall-successful="applications.fluentd.uninstallSuccessful" - :uninstall-failed="applications.fluentd.uninstallFailed" - :updateable="false" - title-link="https://github.com/helm/charts/tree/master/stable/fluentd" - > - <template #description> - <p> - {{ - s__( - `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. It requires at least one of the following logs to be successfully installed.`, - ) - }} - </p> - - <fluentd-output-settings - :port="applications.fluentd.port" - :protocol="applications.fluentd.protocol" - :host="applications.fluentd.host" - :waf-log-enabled="applications.fluentd.wafLogEnabled" - :cilium-log-enabled="applications.fluentd.ciliumLogEnabled" - :status="applications.fluentd.status" - :update-failed="applications.fluentd.updateFailed" - /> - </template> - </application-row> - <div class="gl-mt-7 gl-border-1 gl-border-t-solid gl-border-gray-100"> <!-- This empty div serves as a separator. The applications below can be externally installed using a cluster-management project. --> </div> diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue deleted file mode 100644 index aaad0009ef3..00000000000 --- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue +++ /dev/null @@ -1,238 +0,0 @@ -<script> -import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlFormCheckbox } from '@gitlab/ui'; -import { mapValues } from 'lodash'; -import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; -import { __ } from '~/locale'; - -const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; - -export default { - components: { - GlAlert, - GlButton, - GlDropdown, - GlDropdownItem, - GlFormCheckbox, - }, - props: { - protocols: { - type: Array, - required: false, - default: () => ['TCP', 'UDP'], - }, - status: { - type: String, - required: false, - default: '', - }, - updateFailed: { - type: Boolean, - required: false, - }, - protocol: { - type: String, - required: false, - default: () => __('Protocol'), - }, - port: { - type: Number, - required: false, - default: 514, - }, - host: { - type: String, - required: false, - default: '', - }, - wafLogEnabled: { - type: Boolean, - required: false, - }, - ciliumLogEnabled: { - type: Boolean, - required: false, - }, - }, - data() { - return { - currentServerSideSettings: { - host: null, - port: null, - protocol: null, - wafLogEnabled: null, - ciliumLogEnabled: null, - }, - }; - }, - computed: { - isSaving() { - return [UPDATING].includes(this.status); - }, - saveButtonDisabled() { - return [UNINSTALLING, UPDATING, INSTALLING].includes(this.status); - }, - saveButtonLabel() { - return this.isSaving ? __('Saving') : __('Save changes'); - }, - /** - * Returns true either when: - * - The application is getting updated. - * - The user has changed some of the settings for an application which is - * neither getting installed nor updated. - */ - showButtons() { - return this.isSaving || (this.changedByUser && [INSTALLED, UPDATED].includes(this.status)); - }, - protocolName() { - if (this.protocol) { - return this.protocol.toUpperCase(); - } - return __('Protocol'); - }, - changedByUser() { - return Object.entries(this.currentServerSideSettings).some(([key, value]) => { - return value !== null && value !== this[key]; - }); - }, - }, - watch: { - status() { - this.resetCurrentServerSideSettings(); - }, - }, - methods: { - updateApplication() { - eventHub.$emit('updateApplication', { - id: FLUENTD, - params: { - port: this.port, - protocol: this.protocol, - host: this.host, - waf_log_enabled: this.wafLogEnabled, - cilium_log_enabled: this.ciliumLogEnabled, - }, - }); - }, - resetCurrentServerSideSettings() { - this.currentServerSideSettings = mapValues(this.currentServerSideSettings, () => { - return null; - }); - }, - resetStatus() { - const newSettings = mapValues(this.currentServerSideSettings, (value, key) => { - return value === null ? this[key] : value; - }); - eventHub.$emit('setFluentdSettings', { - ...newSettings, - isEditingSettings: false, - }); - }, - updateCurrentServerSideSettings(settings) { - Object.keys(settings).forEach((key) => { - if (this.currentServerSideSettings[key] === null) { - this.currentServerSideSettings[key] = this[key]; - } - }); - }, - setFluentdSettings(settings) { - this.updateCurrentServerSideSettings(settings); - eventHub.$emit('setFluentdSettings', { - ...settings, - isEditingSettings: true, - }); - }, - selectProtocol(protocol) { - this.setFluentdSettings({ protocol }); - }, - hostChanged(host) { - this.setFluentdSettings({ host }); - }, - portChanged(port) { - this.setFluentdSettings({ port: Number(port) }); - }, - wafLogChanged(wafLogEnabled) { - this.setFluentdSettings({ wafLogEnabled }); - }, - ciliumLogChanged(ciliumLogEnabled) { - this.setFluentdSettings({ ciliumLogEnabled }); - }, - }, -}; -</script> - -<template> - <div> - <gl-alert v-if="updateFailed" class="mb-3" variant="danger" :dismissible="false"> - {{ - s__( - 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.', - ) - }} - </gl-alert> - <div class="form-horizontal"> - <div class="form-group"> - <label for="fluentd-host"> - <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong> - </label> - <input - id="fluentd-host" - :value="host" - type="text" - class="form-control" - @input="hostChanged($event.target.value)" - /> - </div> - <div class="form-group"> - <label for="fluentd-port"> - <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong> - </label> - <input - id="fluentd-port" - :value="port" - type="number" - class="form-control" - @input="portChanged($event.target.value)" - /> - </div> - <div class="form-group"> - <label for="fluentd-protocol"> - <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> - </label> - <gl-dropdown :text="protocolName" class="w-100"> - <gl-dropdown-item - v-for="(value, index) in protocols" - :key="index" - @click="selectProtocol(value.toLowerCase())" - > - {{ value }} - </gl-dropdown-item> - </gl-dropdown> - </div> - <div class="form-group flex flex-wrap"> - <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged"> - <strong>{{ s__('ClusterIntegration|Send Web Application Firewall Logs') }}</strong> - </gl-form-checkbox> - <gl-form-checkbox :checked="ciliumLogEnabled" @input="ciliumLogChanged"> - <strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong> - </gl-form-checkbox> - </div> - <div v-if="showButtons" class="gl-mt-5 gl-display-flex"> - <gl-button - ref="saveBtn" - class="gl-mr-3" - variant="success" - category="primary" - :loading="isSaving" - :disabled="saveButtonDisabled" - @click="updateApplication" - > - {{ saveButtonLabel }} - </gl-button> - <gl-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> - {{ __('Cancel') }} - </gl-button> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue deleted file mode 100644 index 1ba28660e5c..00000000000 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ /dev/null @@ -1,266 +0,0 @@ -<script> -import { - GlAlert, - GlSprintf, - GlLink, - GlToggle, - GlButton, - GlDropdown, - GlDropdownItem, - GlIcon, -} from '@gitlab/ui'; -import { escape } from 'lodash'; -import modSecurityLogo from 'images/cluster_app_logos/gitlab.png'; -import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; -import { s__, __ } from '../../locale'; - -const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; - -export default { - i18n: { - modSecurityEnabled: s__('ClusterIntegration|ModSecurity enabled'), - }, - title: __('Web Application Firewall'), - modsecurityUrl: 'https://modsecurity.org/about.html', - components: { - GlAlert, - GlSprintf, - GlLink, - GlToggle, - GlButton, - GlDropdown, - GlDropdownItem, - GlIcon, - }, - props: { - ingress: { - type: Object, - required: true, - }, - ingressModSecurityHelpPath: { - type: String, - required: false, - default: '', - }, - modes: { - type: Object, - required: false, - default: () => ({ - [LOGGING_MODE]: { - name: s__('ClusterIntegration|Logging mode'), - }, - [BLOCKING_MODE]: { - name: s__('ClusterIntegration|Blocking mode'), - }, - }), - }, - }, - data() { - return { - modSecurityLogo, - initialValue: null, - initialMode: null, - }; - }, - computed: { - modSecurityEnabled: { - get() { - return this.ingress.modsecurity_enabled; - }, - set(isEnabled) { - if (this.initialValue === null) { - this.initialValue = this.ingress.modsecurity_enabled; - } - eventHub.$emit('setIngressModSecurityEnabled', { - id: INGRESS, - modSecurityEnabled: isEnabled, - }); - }, - }, - hasValueChanged() { - return this.modSecurityEnabledChanged || this.modSecurityModeChanged; - }, - modSecurityEnabledChanged() { - return this.initialValue !== null && this.initialValue !== this.ingress.modsecurity_enabled; - }, - modSecurityModeChanged() { - return ( - this.ingress.modsecurity_enabled && - this.initialMode !== null && - this.initialMode !== this.ingress.modsecurity_mode - ); - }, - ingressModSecurityDescription() { - return escape(this.ingressModSecurityHelpPath); - }, - saving() { - return [UPDATING].includes(this.ingress.status); - }, - saveButtonDisabled() { - return ( - [UNINSTALLING, UPDATING, INSTALLING].includes(this.ingress.status) || - this.ingress.updateAvailable - ); - }, - saveButtonLabel() { - return this.saving ? __('Saving') : __('Save changes'); - }, - /** - * Returns true either when: - * - The application is getting updated. - * - The user has changed some of the settings for an application which is - * neither getting installed nor updated. - */ - showButtons() { - return this.saving || this.valuesChangedByUser; - }, - modSecurityModeName() { - return this.modes[this.ingress.modsecurity_mode].name; - }, - valuesChangedByUser() { - return this.hasValueChanged && [INSTALLED, UPDATED].includes(this.ingress.status); - }, - }, - methods: { - updateApplication() { - eventHub.$emit('updateApplication', { - id: INGRESS, - params: { - modsecurity_enabled: this.ingress.modsecurity_enabled, - modsecurity_mode: this.ingress.modsecurity_mode, - }, - }); - this.resetStatus(); - }, - resetStatus() { - if (this.initialMode !== null) { - // eslint-disable-next-line vue/no-mutating-props - this.ingress.modsecurity_mode = this.initialMode; - } - if (this.initialValue !== null) { - // eslint-disable-next-line vue/no-mutating-props - this.ingress.modsecurity_enabled = this.initialValue; - } - this.initialValue = null; - this.initialMode = null; - eventHub.$emit('resetIngressModSecurityChanges', INGRESS); - }, - selectMode(modeKey) { - if (this.initialMode === null) { - this.initialMode = this.ingress.modsecurity_mode; - } - eventHub.$emit('setIngressModSecurityMode', { - id: INGRESS, - modSecurityMode: modeKey, - }); - }, - }, -}; -</script> - -<template> - <div> - <gl-alert - v-if="ingress.updateFailed" - class="mb-3" - variant="danger" - :dismissible="false" - @dismiss="alert = null" - > - {{ - s__( - 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.', - ) - }} - </gl-alert> - <div class="gl-responsive-table-row-layout" role="row"> - <div class="table-section gl-mr-3 section-align-top" role="gridcell"> - <img - :src="modSecurityLogo" - :alt="`${$options.title} logo`" - class="cluster-application-logo avatar s40" - /> - </div> - <div class="table-section section-wrap" role="gridcell"> - <strong> - <gl-link :href="$options.modsecurityUrl" target="_blank">{{ $options.title }} </gl-link> - </strong> - <div class="form-group"> - <p class="form-text text-muted"> - <strong> - <gl-sprintf - :message=" - s__( - 'ClusterIntegration|Real-time web application monitoring, logging and access control. %{linkStart}More information%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link :href="ingressModSecurityDescription" target="_blank" - >{{ content }} - </gl-link> - </template> - </gl-sprintf> - </strong> - </p> - <div class="form-check form-check-inline mt-3"> - <gl-toggle - v-model="modSecurityEnabled" - :disabled="saveButtonDisabled" - :label="$options.i18n.modSecurityEnabled" - label-position="hidden" - /> - </div> - <div - v-if="ingress.modsecurity_enabled" - class="gl-responsive-table-row-layout mt-3" - role="row" - > - <div class="table-section section-wrap" role="gridcell"> - <strong> - {{ s__('ClusterIntegration|Global default') }} - <gl-icon name="earth" class="align-text-bottom" /> - </strong> - <div class="form-group"> - <p class="form-text text-muted"> - <strong> - {{ - s__( - 'ClusterIntegration|Set the global mode for the WAF in this cluster. This can be overridden at the environmental level.', - ) - }} - </strong> - </p> - </div> - <gl-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> - <gl-dropdown-item v-for="(mode, key) in modes" :key="key" @click="selectMode(key)"> - {{ mode.name }} - </gl-dropdown-item> - </gl-dropdown> - </div> - </div> - <div v-if="showButtons" class="gl-mt-5 gl-display-flex"> - <gl-button - variant="success" - category="primary" - data-qa-selector="save_ingress_modsecurity_settings" - :loading="saving" - :disabled="saveButtonDisabled" - @click="updateApplication" - > - {{ saveButtonLabel }} - </gl-button> - <gl-button - data-qa-selector="cancel_ingress_modsecurity_settings" - :disabled="saveButtonDisabled" - @click="resetStatus" - > - {{ __('Cancel') }} - </gl-button> - </div> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index 5cd9baf2c2b..b9c55409330 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { GlModal, GlButton, GlFormInput } from '@gitlab/ui'; import { escape } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { s__, sprintf } from '~/locale'; @@ -30,7 +30,6 @@ export default { GlModal, GlButton, GlFormInput, - GlSprintf, }, props: { clusterPath: { @@ -135,17 +134,6 @@ export default { <div v-if="confirmCleanup"> {{ s__('ClusterIntegration|This will permanently delete the following resources:') }} <ul> - <li> - {{ s__('ClusterIntegration|All installed applications and related resources') }} - </li> - <li> - <gl-sprintf :message="s__('ClusterIntegration|The %{gitlabNamespace} namespace')"> - <template #gitlabNamespace> - <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> - <code>{{ 'gitlab-managed-apps' }}</code> - </template> - </gl-sprintf> - </li> <li>{{ s__('ClusterIntegration|Any project namespaces') }}</li> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <li><code>clusterroles</code></li> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 90ec3f2377c..846e5950b8b 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -55,7 +55,6 @@ export const CERT_MANAGER = 'cert_manager'; export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; export const ELASTIC_STACK = 'elastic_stack'; -export const FLUENTD = 'fluentd'; export const APPLICATIONS = [ HELM, @@ -66,7 +65,6 @@ export const APPLICATIONS = [ CERT_MANAGER, PROMETHEUS, ELASTIC_STACK, - FLUENTD, ]; export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue index a344f9578fd..3f61a1b18a7 100644 --- a/app/assets/javascripts/clusters/forms/components/integration_form.vue +++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue @@ -41,17 +41,10 @@ export default { toggleEnabled: true, envScope: '*', baseDomainField: '', - externalIp: '', }; }, computed: { - ...mapState([ - 'enabled', - 'editable', - 'environmentScope', - 'baseDomain', - 'applicationIngressExternalIp', - ]), + ...mapState(['enabled', 'editable', 'environmentScope', 'baseDomain']), canSubmit() { return ( this.enabled !== this.toggleEnabled || @@ -64,7 +57,6 @@ export default { this.toggleEnabled = this.enabled; this.envScope = this.environmentScope; this.baseDomainField = this.baseDomain; - this.externalIp = this.applicationIngressExternalIp; }, }; </script> @@ -135,13 +127,6 @@ export default { <gl-link :href="autoDevopsHelpPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> - <div v-if="applicationIngressExternalIp" class="js-ingress-domain-help-text inline"> - {{ s__('ClusterIntegration|Alternatively, ') }} - <gl-sprintf :message="s__('ClusterIntegration|%{externalIp}.nip.io')"> - <template #externalIp>{{ externalIp }}</template> - </gl-sprintf> - {{ s__('ClusterIntegration|can be used instead of a custom domain. ') }} - </div> <gl-sprintf class="inline" :message="s__('ClusterIntegration|%{linkStart}More information%{linkEnd}')" diff --git a/app/assets/javascripts/clusters/forms/stores/state.js b/app/assets/javascripts/clusters/forms/stores/state.js index 2a96590b5e7..74a00b97603 100644 --- a/app/assets/javascripts/clusters/forms/stores/state.js +++ b/app/assets/javascripts/clusters/forms/stores/state.js @@ -6,7 +6,6 @@ export default (initialState = {}) => { editable: parseBoolean(initialState.editable), environmentScope: initialState.environmentScope, baseDomain: initialState.baseDomain, - applicationIngressExternalIp: initialState.applicationIngressExternalIp, autoDevopsHelpPath: initialState.autoDevopsHelpPath, externalEndpointHelpPath: initialState.externalEndpointHelpPath, }; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 2a6c6965dab..333fb293a15 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -13,7 +13,6 @@ export default class ClusterService { jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, elastic_stack: this.options.installElasticStackEndpoint, - fluentd: this.options.installFluentdEndpoint, }; this.appUpdateEndpointMap = { knative: this.options.updateKnativeEndpoint, diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index d45696c98c2..50689a6142f 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -13,7 +13,6 @@ import { UPDATE_EVENT, UNINSTALL_EVENT, ELASTIC_STACK, - FLUENTD, } from '../constants'; import transitionApplicationState from '../services/application_state_machine'; @@ -55,12 +54,8 @@ export default class ClusterStore { ingress: { ...applicationInitialState, title: s__('ClusterIntegration|Ingress'), - modsecurity_enabled: false, - modsecurity_mode: null, externalIp: null, externalHostname: null, - isEditingModSecurityEnabled: false, - isEditingModSecurityMode: false, updateFailed: false, updateAvailable: false, }, @@ -106,16 +101,6 @@ export default class ClusterStore { ...applicationInitialState, title: s__('ClusterIntegration|Elastic Stack'), }, - fluentd: { - ...applicationInitialState, - title: s__('ClusterIntegration|Fluentd'), - host: null, - port: null, - protocol: null, - wafLogEnabled: null, - ciliumLogEnabled: null, - isEditingSettings: false, - }, cilium: { ...applicationInitialState, title: s__('ClusterIntegration|GitLab Container Network Policies'), @@ -219,12 +204,6 @@ export default class ClusterStore { this.state.applications.ingress.externalIp = serverAppEntry.external_ip; this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname; this.state.applications.ingress.updateAvailable = updateAvailable; - if (!this.state.applications.ingress.isEditingModSecurityEnabled) { - this.state.applications.ingress.modsecurity_enabled = serverAppEntry.modsecurity_enabled; - } - if (!this.state.applications.ingress.isEditingModSecurityMode) { - this.state.applications.ingress.modsecurity_mode = serverAppEntry.modsecurity_mode; - } } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; @@ -257,14 +236,6 @@ export default class ClusterStore { } else if (appId === ELASTIC_STACK) { this.state.applications.elastic_stack.version = version; this.state.applications.elastic_stack.updateAvailable = updateAvailable; - } else if (appId === FLUENTD) { - if (!this.state.applications.fluentd.isEditingSettings) { - this.state.applications.fluentd.port = serverAppEntry.port; - this.state.applications.fluentd.host = serverAppEntry.host; - this.state.applications.fluentd.protocol = serverAppEntry.protocol; - this.state.applications.fluentd.wafLogEnabled = serverAppEntry.waf_log_enabled; - this.state.applications.fluentd.ciliumLogEnabled = serverAppEntry.cilium_log_enabled; - } } }); } diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 2f39bbacc7d..6b07b7e3772 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import Api from '../../api'; -import { deprecatedCreateFlash as createFlash } from '../../flash'; +import createFlash from '../../flash'; import { __ } from '../../locale'; import state from '../state'; import Dropdown from './dropdown.vue'; @@ -79,7 +79,9 @@ export default { this.selectProject(this.projects[0]); }) .catch((e) => { - createFlash(__('Error fetching forked projects. Please try again.')); + createFlash({ + message: __('Error fetching forked projects. Please try again.'), + }); throw e; }); }, diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 7896268acf0..c6ab2e189ef 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -17,8 +17,12 @@ export default { }; </script> <template> - <div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"> + <div + data-testid="content-editor" + class="md-area" + :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" + > <top-toolbar class="gl-mb-4" :content-editor="contentEditor" /> - <tiptap-editor-content :editor="contentEditor.tiptapEditor" /> + <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> </div> </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 new file mode 100644 index 00000000000..f706080eaa1 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue @@ -0,0 +1,96 @@ +<script> +import { + GlDropdown, + GlDropdownForm, + GlButton, + GlFormInputGroup, + GlDropdownDivider, + GlDropdownItem, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import { hasSelection } from '../services/utils'; + +export const linkContentType = 'link'; + +export default { + components: { + GlDropdown, + GlDropdownForm, + GlFormInputGroup, + GlDropdownDivider, + GlDropdownItem, + GlButton, + }, + directives: { + GlTooltip, + }, + props: { + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + }, + data() { + return { + linkHref: '', + }; + }, + computed: { + isActive() { + return this.tiptapEditor.isActive(linkContentType); + }, + }, + mounted() { + this.tiptapEditor.on('selectionUpdate', ({ editor }) => { + const { href } = editor.getAttributes(linkContentType); + + this.linkHref = href; + }); + }, + methods: { + updateLink() { + this.tiptapEditor.chain().focus().unsetLink().setLink({ href: this.linkHref }).run(); + + this.$emit('execute', { contentType: linkContentType }); + }, + selectLink() { + const { tiptapEditor } = this; + + // a selection has already been made by the user, so do nothing + if (!hasSelection(tiptapEditor)) { + tiptapEditor.chain().focus().extendMarkRange(linkContentType).run(); + } + }, + removeLink() { + this.tiptapEditor.chain().focus().unsetLink().run(); + + this.$emit('execute', { contentType: linkContentType }); + }, + }, +}; +</script> +<template> + <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 v-if="isActive" /> + <gl-dropdown-item v-if="isActive" @click="removeLink()"> + {{ __('Remove link') }} + </gl-dropdown-item> + </gl-dropdown> +</template> 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 new file mode 100644 index 00000000000..473fc472c1b --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue @@ -0,0 +1,75 @@ +<script> +import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import { __ } from '~/locale'; +import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip, + }, + props: { + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + }, + computed: { + activeItem() { + return TEXT_STYLE_DROPDOWN_ITEMS.find((item) => + this.tiptapEditor.isActive(item.contentType, item.commandParams), + ); + }, + activeItemLabel() { + const { activeItem } = this; + + return activeItem ? activeItem.label : this.$options.i18n.placeholder; + }, + }, + methods: { + execute(item) { + const { editorCommand, contentType, commandParams } = item; + const value = commandParams?.level; + + if (editorCommand) { + this.tiptapEditor + .chain() + .focus() + [editorCommand](commandParams || {}) + .run(); + } + + this.$emit('execute', { contentType, value }); + }, + isActive(item) { + return this.tiptapEditor.isActive(item.contentType, item.commandParams); + }, + }, + items: TEXT_STYLE_DROPDOWN_ITEMS, + i18n: { + placeholder: __('Text style'), + }, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip="$options.i18n.placeholder" + size="small" + :disabled="!activeItem" + :text="activeItemLabel" + > + <gl-dropdown-item + v-for="(item, index) in $options.items" + :key="index" + is-check-item + :is-checked="isActive(item)" + @click="execute(item)" + > + {{ item.label }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index b18649d4e57..07fdd3147e2 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -4,6 +4,8 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from ' import { ContentEditor } from '../services/content_editor'; import Divider from './divider.vue'; import ToolbarButton from './toolbar_button.vue'; +import ToolbarLinkButton from './toolbar_link_button.vue'; +import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; const trackingMixin = Tracking.mixin({ label: CONTENT_EDITOR_TRACKING_LABEL, @@ -12,6 +14,8 @@ const trackingMixin = Tracking.mixin({ export default { components: { ToolbarButton, + ToolbarTextStyleDropdown, + ToolbarLinkButton, Divider, }, mixins: [trackingMixin], @@ -35,6 +39,12 @@ export default { <div class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > + <toolbar-text-style-dropdown + data-testid="text-styles" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <divider /> <toolbar-button data-testid="bold" content-type="bold" @@ -62,6 +72,11 @@ export default { :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> + <toolbar-link-button + data-testid="link" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> <divider /> <toolbar-button data-testid="blockquote" @@ -73,6 +88,15 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-button + data-testid="code-block" + content-type="codeBlock" + icon-name="doc-code" + editor-command="toggleCodeBlock" + :label="__('Insert a code block')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-button data-testid="bullet-list" content-type="bulletList" icon-name="list-bulleted" diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index 45ebd87dac9..7a5f1d3ed1f 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__( 'ContentEditor|You have to provide a renderMarkdown function or a custom serializer', @@ -8,3 +8,35 @@ export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor'; export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control'; export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut'; export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule'; + +export const TEXT_STYLE_DROPDOWN_ITEMS = [ + { + contentType: 'heading', + commandParams: { level: 1 }, + editorCommand: 'setHeading', + label: __('Heading 1'), + }, + { + contentType: 'heading', + editorCommand: 'setHeading', + commandParams: { level: 2 }, + label: __('Heading 2'), + }, + { + contentType: 'heading', + editorCommand: 'setHeading', + commandParams: { level: 3 }, + label: __('Heading 3'), + }, + { + contentType: 'heading', + editorCommand: 'setHeading', + commandParams: { level: 4 }, + label: __('Heading 4'), + }, + { + contentType: 'paragraph', + editorCommand: 'setParagraph', + label: __('Normal text'), + }, +]; diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index ce8bd57c7e3..50d72f4089a 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,12 +1,20 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; +import * as lowlight from 'lowlight'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang'); +const extractLanguage = (element) => element.getAttribute('lang'); const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({ addAttributes() { return { - ...this.parent(), + language: { + default: null, + parseHTML: (element) => { + return { + language: extractLanguage(element), + }; + }, + }, /* `params` is the name of the attribute that prosemirror-markdown uses to extract the language of a codeblock. @@ -19,8 +27,16 @@ const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({ }; }, }, + class: { + default: 'code highlight js-syntax-highlight', + }, }; }, + renderHTML({ HTMLAttributes }) { + return ['pre', HTMLAttributes, ['code', {}, 0]]; + }, +}).configure({ + lowlight, }); export const tiptapExtension = ExtendedCodeBlockLowlight; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 4f0109fd751..287216e68d5 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -2,8 +2,49 @@ import { Image } from '@tiptap/extension-image'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; const ExtendedImage = Image.extend({ - defaultOptions: { inline: true }, -}); + addAttributes() { + return { + ...this.parent?.(), + src: { + default: null, + /* + * GitLab Flavored Markdown provides lazy loading for rendering images. As + * as result, the src attribute of the image may contain an embedded resource + * instead of the actual image URL. The image URL is moved to the data-src + * attribute. + */ + parseHTML: (element) => { + const img = element.querySelector('img'); + + return { + src: img.dataset.src || img.getAttribute('src'), + }; + }, + }, + alt: { + default: null, + parseHTML: (element) => { + const img = element.querySelector('img'); + + return { + alt: img.getAttribute('alt'), + }; + }, + }, + }; + }, + parseHTML() { + return [ + { + priority: 100, + tag: 'a.no-attachment-icon', + }, + { + tag: 'img[src]', + }, + ]; + }, +}).configure({ inline: true }); export const tiptapExtension = ExtendedImage; export const serializer = defaultMarkdownSerializer.nodes.image; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 9a2fa7a5c98..6f5f81cbf93 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -1,5 +1,36 @@ +import { markInputRule } from '@tiptap/core'; import { Link } from '@tiptap/extension-link'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -export const tiptapExtension = Link; +export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; + +export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; + +const extractHrefFromMatch = (match) => { + return { href: match.groups.href }; +}; + +export const extractHrefFromMarkdownLink = (match) => { + /** + * Removes the last capture group from the match to satisfy + * tiptap markInputRule expectation of having the content as + * the last capture group in the match. + * + * https://github.com/ueberdosis/tiptap/blob/%40tiptap/core%402.0.0-beta.75/packages/core/src/inputRules/markInputRule.ts#L11 + */ + match.pop(); + return extractHrefFromMatch(match); +}; + +export const tiptapExtension = Link.extend({ + addInputRules() { + return [ + markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink), + markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch), + ]; + }, +}).configure({ + openOnClick: false, +}); + export const serializer = defaultMarkdownSerializer.marks.link; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index e2188f5aa69..29553f4c2ca 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -9,6 +9,13 @@ export class ContentEditor { return this._tiptapEditor; } + get empty() { + const doc = this.tiptapEditor?.state.doc; + + // Makes sure the document has more than one empty paragraph + return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0); + } + async setSerializedContent(serializedContent) { const { _tiptapEditor: editor, _serializer: serializer } = this; diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js index 860e5372bc2..d26f32a7e7a 100644 --- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js +++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js @@ -1,4 +1,4 @@ -import { mapValues, omit } from 'lodash'; +import { mapValues } from 'lodash'; import { InputRule } from 'prosemirror-inputrules'; import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; import Tracking from '~/tracking'; @@ -36,15 +36,16 @@ const trackInputRulesAndShortcuts = (tiptapExtension) => { addKeyboardShortcuts() { const shortcuts = this.parent?.() || {}; const { name } = this; - /** * We don’t want to track keyboard shortcuts * that are not deliberately executed to create * new types of content */ - const withoutEnterShortcut = omit(shortcuts, [ENTER_KEY, BACKSPACE_KEY]); - const decorated = mapValues(withoutEnterShortcut, (commandFn, shortcut) => - trackKeyboardShortcut(name, commandFn, shortcut), + const dotNotTrackKeys = [ENTER_KEY, BACKSPACE_KEY]; + const decorated = mapValues(shortcuts, (commandFn, shortcut) => + dotNotTrackKeys.includes(shortcut) + ? commandFn + : trackKeyboardShortcut(name, commandFn, shortcut), ); return decorated; diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js new file mode 100644 index 00000000000..cf5234bbff8 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -0,0 +1,5 @@ +export const hasSelection = (tiptapEditor) => { + const { from, to } = tiptapEditor.state.selection; + + return from < to; +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 8b7c93ad880..cd8212a40f9 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { DEFAULT_REGION } from '../constants'; @@ -102,7 +102,9 @@ export const createClusterSuccess = (_, location) => { export const createClusterError = ({ commit }, error) => { commit(types.CREATE_CLUSTER_ERROR, error); - createFlash(getErrorMessage(error)); + createFlash({ + message: getErrorMessage(error), + }); }; export const setRegion = ({ commit }, payload) => { diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 11a263015e4..8492f0b73e1 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -1,7 +1,8 @@ <script> import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import { __ } from '~/locale'; import banner from './banner.vue'; import stageCodeComponent from './stage_code_component.vue'; @@ -29,6 +30,7 @@ export default { 'stage-staging-component': stageStagingComponent, 'stage-production-component': stageComponent, 'stage-nav-item': stageNavItem, + PathNavigation, }, props: { noDataSvgPath: { @@ -52,21 +54,33 @@ export default { 'isEmptyStage', 'selectedStage', 'selectedStageEvents', + 'selectedStageError', 'stages', 'summary', 'startDate', + 'permissions', ]), + ...mapGetters(['pathNavigationData']), displayStageEvents() { const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; }, displayNotEnoughData() { - const { selectedStage, isEmptyStage, isLoadingStage } = this; - return selectedStage && isEmptyStage && !isLoadingStage; + return this.selectedStageReady && this.isEmptyStage; }, displayNoAccess() { - const { selectedStage } = this; - return selectedStage && !selectedStage.isUserAllowed; + return this.selectedStageReady && !this.isUserAllowed(this.selectedStage.id); + }, + selectedStageReady() { + return !this.isLoadingStage && this.selectedStage; + }, + emptyStageTitle() { + return this.selectedStageError + ? this.selectedStageError + : __("We don't have enough data to show this stage."); + }, + emptyStageText() { + return !this.selectedStageError ? this.selectedStage.emptyStageText : ''; }, }, methods: { @@ -78,25 +92,18 @@ export default { ]), handleDateSelect(startDate) { this.setDateRange({ startDate }); - this.fetchCycleAnalyticsData(); }, - isActiveStage(stage) { - return stage.slug === this.selectedStage.slug; - }, - selectStage(stage) { - if (this.selectedStage === stage) return; - + onSelectStage(stage) { this.setSelectedStage(stage); - if (!stage.isUserAllowed) { - return; - } - - this.fetchStageData(); }, dismissOverviewDialog() { this.isOverviewDialogDismissed = true; Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); }, + isUserAllowed(id) { + const { permissions } = this; + return Boolean(permissions?.[id]); + }, }, dayRangeOptions: [7, 30, 90], i18n: { @@ -106,9 +113,23 @@ export default { </script> <template> <div class="cycle-analytics"> + <path-navigation + v-if="selectedStageReady" + class="js-path-navigation gl-w-full gl-pb-2" + :loading="isLoading" + :stages="pathNavigationData" + :selected-stage="selectedStage" + :with-stage-counts="false" + @selected="onSelectStage" + /> <gl-loading-icon v-if="isLoading" size="lg" /> <div v-else class="wrapper"> - <div class="card"> + <!-- + We wont have access to the stage counts until we move to a default value stream + For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts + Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705 + --> + <div class="card" data-testid="vsa-stage-overview-metrics"> <div class="card-header">{{ __('Recent Project Activity') }}</div> <div class="d-flex justify-content-between"> <div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center"> @@ -139,40 +160,12 @@ export default { </div> </div> </div> - <div class="stage-panel-container"> - <div class="card stage-panel"> + <div class="stage-panel-container" data-testid="vsa-stage-table"> + <div class="card stage-panel gl-px-5"> <div class="card-header border-bottom-0"> <nav class="col-headers"> - <ul> - <li class="stage-header pl-5"> - <span class="stage-name font-weight-bold">{{ - s__('ProjectLifecycle|Stage') - }}</span> - <span - class="has-tooltip" - data-placement="top" - :title="__('The phase of the development lifecycle.')" - aria-hidden="true" - > - <gl-icon name="question-o" class="gl-text-gray-500" /> - </span> - </li> - <li class="median-header"> - <span class="stage-name font-weight-bold">{{ __('Median') }}</span> - <span - class="has-tooltip" - data-placement="top" - :title=" - __( - 'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.', - ) - " - aria-hidden="true" - > - <gl-icon name="question-o" class="gl-text-gray-500" /> - </span> - </li> - <li class="event-header pl-3"> + <ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none"> + <li> <span v-if="selectedStage" class="stage-name font-weight-bold">{{ selectedStage.legend ? __(selectedStage.legend) : __('Related Issues') }}</span> @@ -187,7 +180,7 @@ export default { <gl-icon name="question-o" class="gl-text-gray-500" /> </span> </li> - <li class="total-time-header pr-5 text-right"> + <li> <span class="stage-name font-weight-bold">{{ __('Time') }}</span> <span class="has-tooltip" @@ -201,45 +194,31 @@ export default { </ul> </nav> </div> - <div class="stage-panel-body"> - <nav class="stage-nav"> - <ul> - <stage-nav-item - v-for="stage in stages" - :key="stage.title" - :title="stage.title" - :is-user-allowed="stage.isUserAllowed" - :value="stage.value" - :is-active="isActiveStage(stage)" - @select="selectStage(stage)" - /> - </ul> - </nav> - <section class="stage-events overflow-auto"> - <gl-loading-icon v-show="isLoadingStage" size="lg" /> - <template v-if="displayNoAccess"> + <section class="stage-events gl-overflow-auto gl-w-full"> + <gl-loading-icon v-if="isLoadingStage" size="lg" /> + <template v-else> <gl-empty-state + v-if="displayNoAccess" class="js-empty-state" :title="__('You need permission.')" :svg-path="noAccessSvgPath" :description="__('Want to see the data? Please ask an administrator for access.')" /> - </template> - <template v-else> - <template v-if="displayNotEnoughData"> + <template v-else> <gl-empty-state + v-if="displayNotEnoughData" class="js-empty-state" - :description="selectedStage.emptyStageText" + :description="emptyStageText" :svg-path="noDataSvgPath" - :title="__('We don\'t have enough data to show this stage.')" + :title="emptyStageTitle" /> - </template> - <template v-if="displayStageEvents"> <component :is="selectedStage.component" + v-if="displayStageEvents" :stage="selectedStage" :items="selectedStageEvents" + data-testid="stage-table-events" /> </template> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue new file mode 100644 index 00000000000..c1e33f73b13 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue @@ -0,0 +1,127 @@ +<script> +import { + GlPath, + GlPopover, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { OVERVIEW_STAGE_ID } from '../constants'; + +export default { + name: 'PathNavigation', + components: { + GlPath, + GlSkeletonLoading, + GlPopover, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + stages: { + type: Array, + required: true, + }, + selectedStage: { + type: Object, + required: false, + default: () => {}, + }, + withStageCounts: { + type: Boolean, + required: false, + default: true, + }, + }, + methods: { + showPopover({ id }) { + return id && id !== OVERVIEW_STAGE_ID; + }, + hasStageCount({ stageCount = null }) { + return stageCount !== null; + }, + onSelectStage($event) { + this.$emit('selected', $event); + this.track('click_path_navigation', { + extra: { + stage_id: $event.id, + }, + }); + }, + }, + popoverOptions: { + triggers: 'hover', + placement: 'bottom', + }, +}; +</script> +<template> + <gl-skeleton-loading v-if="loading" :lines="2" class="h-auto pt-2 pb-1" /> + <gl-path v-else :key="selectedStage.id" :items="stages" @selected="onSelectStage"> + <template #default="{ pathItem, pathId }"> + <gl-popover + v-if="showPopover(pathItem)" + v-bind="$options.popoverOptions" + :target="pathId" + :css-classes="['stage-item-popover']" + data-testid="stage-item-popover" + > + <template #title>{{ pathItem.title }}</template> + <div class="gl-px-4"> + <div class="gl-display-flex gl-justify-content-space-between"> + <div class="gl-pr-4 gl-pb-4"> + {{ s__('ValueStreamEvent|Stage time (median)') }} + </div> + <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div> + </div> + </div> + <div v-if="withStageCounts" class="gl-px-4"> + <div class="gl-display-flex gl-justify-content-space-between"> + <div class="gl-pr-4 gl-pb-4"> + {{ s__('ValueStreamEvent|Items in stage') }} + </div> + <div class="gl-pb-4 gl-font-weight-bold"> + <template v-if="hasStageCount(pathItem)">{{ + n__('%d item', '%d items', pathItem.stageCount) + }}</template> + <template v-else>-</template> + </div> + </div> + </div> + <div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"> + <div + v-if="pathItem.startEventHtmlDescription" + class="gl-display-flex gl-flex-direction-row" + > + <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label"> + {{ s__('ValueStreamEvent|Start') }} + </div> + <div + v-safe-html="pathItem.startEventHtmlDescription" + class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description" + ></div> + </div> + <div + v-if="pathItem.endEventHtmlDescription" + class="gl-display-flex gl-flex-direction-row" + > + <div class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label"> + {{ s__('ValueStreamEvent|Stop') }} + </div> + <div + v-safe-html="pathItem.endEventHtmlDescription" + class="gl-display-flex gl-flex-direction-column stage-event-description" + ></div> + </div> + </div> + </gl-popover> + </template> + </gl-path> +</template> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index d79de207afe..96c89049e90 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -1 +1,8 @@ export const DEFAULT_DAYS_TO_DISPLAY = 30; +export const OVERVIEW_STAGE_ID = 'overview'; + +export const DEFAULT_VALUE_STREAM = { + id: 'default', + slug: 'default', + name: 'default', +}; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 00192cc61f8..57cb220d9c9 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -8,10 +8,11 @@ Vue.use(Translate); export default () => { const store = createStore(); const el = document.querySelector('#js-cycle-analytics'); - const { noAccessSvgPath, noDataSvgPath, requestPath } = el.dataset; + const { noAccessSvgPath, noDataSvgPath, requestPath, fullPath } = el.dataset; store.dispatch('initializeVsa', { requestPath, + fullPath, }); // eslint-disable-next-line no-new @@ -24,6 +25,7 @@ export default () => { props: { noDataSvgPath, noAccessSvgPath, + fullPath, }, }), }); diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index fe3c6d6b3ba..faf1c37d86a 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -1,27 +1,60 @@ +import { + getProjectValueStreamStages, + getProjectValueStreams, + getProjectValueStreamStageData, + getProjectValueStreamMetrics, +} from '~/api/analytics_api'; import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; +import { DEFAULT_DAYS_TO_DISPLAY, DEFAULT_VALUE_STREAM } from '../constants'; import * as types from './mutation_types'; -export const fetchCycleAnalyticsData = ({ - state: { requestPath, startDate }, - dispatch, - commit, -}) => { +export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { + commit(types.SET_SELECTED_VALUE_STREAM, valueStream); + return dispatch('fetchValueStreamStages'); +}; + +export const fetchValueStreamStages = ({ commit, state }) => { + const { fullPath, selectedValueStream } = state; + commit(types.REQUEST_VALUE_STREAM_STAGES); + + return getProjectValueStreamStages(fullPath, selectedValueStream.id) + .then(({ data }) => commit(types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS, data)) + .catch(({ response: { status } }) => { + commit(types.RECEIVE_VALUE_STREAM_STAGES_ERROR, status); + }); +}; + +export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { + commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data); + if (data.length) { + const [firstStream] = data; + return dispatch('setSelectedValueStream', firstStream); + } + return dispatch('setSelectedValueStream', DEFAULT_VALUE_STREAM); +}; + +export const fetchValueStreams = ({ commit, dispatch, state }) => { + const { fullPath } = state; + commit(types.REQUEST_VALUE_STREAMS); + + return getProjectValueStreams(fullPath) + .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) + .then(() => dispatch('setSelectedStage')) + .catch(({ response: { status } }) => { + commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); + }); +}; + +export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, commit }) => { commit(types.REQUEST_CYCLE_ANALYTICS_DATA); - return axios - .get(requestPath, { - params: { 'cycle_analytics[start_date]': startDate }, - }) + return getProjectValueStreamMetrics(requestPath, { 'cycle_analytics[start_date]': startDate }) .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) - .then(() => dispatch('setSelectedStage')) - .then(() => dispatch('fetchStageData')) .catch(() => { commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); createFlash({ - message: __('There was an error while fetching value stream analytics data.'), + message: __('There was an error while fetching value stream summary data.'), }); }); }; @@ -29,23 +62,42 @@ export const fetchCycleAnalyticsData = ({ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => { commit(types.REQUEST_STAGE_DATA); - return axios - .get(`${requestPath}/events/${selectedStage.name}.json`, { - params: { 'cycle_analytics[start_date]': startDate }, + return getProjectValueStreamStageData({ + requestPath, + stageId: selectedStage.id, + params: { 'cycle_analytics[start_date]': startDate }, + }) + .then(({ data }) => { + // when there's a query timeout, the request succeeds but the error is encoded in the response data + if (data?.error) { + commit(types.RECEIVE_STAGE_DATA_ERROR, data.error); + } else { + commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); + } }) - .then(({ data }) => commit(types.RECEIVE_STAGE_DATA_SUCCESS, data)) .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); }; -export const setSelectedStage = ({ commit, state: { stages } }, selectedStage = null) => { +export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => { const stage = selectedStage || stages[0]; commit(types.SET_SELECTED_STAGE, stage); + return dispatch('fetchStageData'); +}; + +const refetchData = (dispatch, commit) => { + commit(types.SET_LOADING, true); + return Promise.resolve() + .then(() => dispatch('fetchValueStreams')) + .then(() => dispatch('fetchCycleAnalyticsData')) + .finally(() => commit(types.SET_LOADING, false)); }; -export const setDateRange = ({ commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => +export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => { commit(types.SET_DATE_RANGE, { startDate }); + return refetchData(dispatch, commit); +}; export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { commit(types.INITIALIZE_VSA, initialData); - return dispatch('fetchCycleAnalyticsData'); + return refetchData(dispatch, commit); }; diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js new file mode 100644 index 00000000000..c60a70ef147 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -0,0 +1,10 @@ +import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; + +export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { + return transformStagesForPathNavigation({ + stages: filterStagesByHiddenStatus(stages, false), + medians, + stageCounts, + selectedStage, + }); +}; diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/cycle_analytics/store/index.js index ab47538dcf5..c6ca88ea492 100644 --- a/app/assets/javascripts/cycle_analytics/store/index.js +++ b/app/assets/javascripts/cycle_analytics/store/index.js @@ -8,6 +8,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import state from './state'; @@ -16,6 +17,7 @@ Vue.use(Vuex); export default () => new Vuex.Store({ actions, + getters, mutations, state, }); diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 00aae49ae9f..4f3d430ec9f 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -1,8 +1,18 @@ export const INITIALIZE_VSA = 'INITIALIZE_VSA'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const SET_DATE_RANGE = 'SET_DATE_RANGE'; +export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS'; +export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS'; +export const RECEIVE_VALUE_STREAMS_ERROR = 'RECEIVE_VALUE_STREAMS_ERROR'; + +export const REQUEST_VALUE_STREAM_STAGES = 'REQUEST_VALUE_STREAM_STAGES'; +export const RECEIVE_VALUE_STREAM_STAGES_SUCCESS = 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS'; +export const RECEIVE_VALUE_STREAM_STAGES_ERROR = 'RECEIVE_VALUE_STREAM_STAGES_ERROR'; + export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA'; export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS'; export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 8fd5c78339a..0ae80116cd2 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,33 +1,61 @@ -import { decorateData, decorateEvents } from '../utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { decorateData, decorateEvents, formatMedianValues } from '../utils'; import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { requestPath }) { + [types.INITIALIZE_VSA](state, { requestPath, fullPath }) { state.requestPath = requestPath; + state.fullPath = fullPath; + }, + [types.SET_LOADING](state, loadingState) { + state.isLoading = loadingState; + }, + [types.SET_SELECTED_VALUE_STREAM](state, selectedValueStream = {}) { + state.selectedValueStream = convertObjectPropsToCamelCase(selectedValueStream, { deep: true }); }, [types.SET_SELECTED_STAGE](state, stage) { - state.isLoadingStage = true; state.selectedStage = stage; - state.isLoadingStage = false; }, [types.SET_DATE_RANGE](state, { startDate }) { state.startDate = startDate; }, + [types.REQUEST_VALUE_STREAMS](state) { + state.valueStreams = []; + }, + [types.RECEIVE_VALUE_STREAMS_SUCCESS](state, valueStreams = []) { + state.valueStreams = valueStreams; + }, + [types.RECEIVE_VALUE_STREAMS_ERROR](state) { + state.valueStreams = []; + }, + [types.REQUEST_VALUE_STREAM_STAGES](state) { + state.stages = []; + }, + [types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS](state, { stages = [] }) { + state.stages = stages.map((s) => ({ + ...convertObjectPropsToCamelCase(s, { deep: true }), + // NOTE: we set the component type here to match the current behaviour + // this can be removed when we migrate to the update stage table + // https://gitlab.com/gitlab-org/gitlab/-/issues/326704 + component: `stage-${s.id}-component`, + })); + }, + [types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) { + state.stages = []; + }, [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { state.isLoading = true; - state.stages = []; state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { - state.isLoading = false; - const { stages, summary } = decorateData(data); - state.stages = stages; + const { summary, medians } = decorateData(data); + state.permissions = data.permissions; state.summary = summary; + state.medians = formatMedianValues(medians); state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { state.isLoading = false; - state.stages = []; state.hasError = true; }, [types.REQUEST_STAGE_DATA](state) { @@ -43,10 +71,11 @@ export default { state.selectedStageEvents = decorateEvents(events, selectedStage); state.hasError = false; }, - [types.RECEIVE_STAGE_DATA_ERROR](state) { + [types.RECEIVE_STAGE_DATA_ERROR](state, error) { state.isLoadingStage = false; state.isEmptyStage = true; state.selectedStageEvents = []; state.hasError = true; + state.selectedStageError = error; }, }; diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 5db4e1878a9..02f953d9517 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -2,16 +2,21 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; export default () => ({ requestPath: '', + fullPath: '', startDate: DEFAULT_DAYS_TO_DISPLAY, stages: [], summary: [], analytics: [], stats: [], + valueStreams: [], + selectedValueStream: {}, selectedStage: {}, selectedStageEvents: [], + selectedStageError: '', medians: {}, hasError: false, isLoading: false, isLoadingStage: false, isEmptyStage: false, + permissions: {}, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 3afe4b021be..40ad7d8b2fc 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,29 +1,10 @@ -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { dasherize } from '~/lib/utils/text_utility'; -import { __ } from '../locale'; +import { unescape } from 'lodash'; +import { sanitize } from '~/lib/dompurify'; +import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { parseSeconds } from '~/lib/utils/datetime_utility'; +import { s__, sprintf } from '../locale'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; -const EMPTY_STAGE_TEXTS = { - issue: __( - 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - ), - plan: __( - 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - ), - code: __( - 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - ), - test: __( - 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - ), - review: __( - 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - ), - staging: __( - 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - ), -}; - /** * These `decorate` methods will be removed when me migrate to the * new table layout https://gitlab.com/gitlab-org/gitlab/-/issues/326704 @@ -40,24 +21,97 @@ const mapToEvent = (event, stage) => { export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage)); -const mapToStage = (permissions, item) => { - const slug = dasherize(item.name.toLowerCase()); - return { - ...item, - slug, - active: false, - isUserAllowed: permissions[slug], - emptyStageText: EMPTY_STAGE_TEXTS[slug], - component: `stage-${slug}-component`, - }; -}; - const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); +const mapToMedians = ({ name: id, value }) => ({ id, value }); export const decorateData = (data = {}) => { - const { permissions, stats, summary } = data; + const { stats: stages, summary } = data; return { - stages: stats?.map((item) => mapToStage(permissions, item)) || [], summary: summary?.map((item) => mapToSummary(item)) || [], + medians: stages?.map((item) => mapToMedians(item)) || [], }; }; + +/** + * Takes the stages and median data, combined with the selected stage, to build an + * array which is formatted to proivde the data required for the path navigation. + * + * @param {Array} stages - The stages available to the group / project + * @param {Object} medians - The median values for the stages available to the group / project + * @param {Object} stageCounts - The total item count for the stages available + * @param {Object} selectedStage - The currently selected stage + * @returns {Array} An array of stages formatted with data required for the path navigation + */ +export const transformStagesForPathNavigation = ({ + stages, + medians, + stageCounts = {}, + selectedStage, +}) => { + const formattedStages = stages.map((stage) => { + return { + metric: medians[stage?.id], + selected: stage?.id === selectedStage?.id, // Also could null === null cause an issue here? + stageCount: stageCounts && stageCounts[stage?.id], + icon: null, + ...stage, + }; + }); + + return formattedStages; +}; + +export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => { + if (months) { + return sprintf(s__('ValueStreamAnalytics|%{value}M'), { + value: roundToNearestHalf(months), + }); + } else if (weeks) { + return sprintf(s__('ValueStreamAnalytics|%{value}w'), { + value: roundToNearestHalf(weeks), + }); + } else if (days) { + return sprintf(s__('ValueStreamAnalytics|%{value}d'), { + value: roundToNearestHalf(days), + }); + } else if (hours) { + return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours }); + } else if (minutes) { + return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes }); + } else if (seconds) { + return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] })); + } + return '-'; +}; + +/** + * Takes a raw median value in seconds and converts it to a string representation + * ie. converts 172800 => 2d (2 days) + * + * @param {Number} Median - The number of seconds for the median calculation + * @returns {String} String representation ie 2w + */ +export const medianTimeToParsedSeconds = (value) => + timeSummaryForPathNavigation({ + ...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }), + seconds: value, + }); + +/** + * Takes the raw median value arrays and converts them into a useful object + * containing the string for display in the path navigation + * ie. converts [{ id: 'test', value: 172800 }] => { 'test': '2d' } + * + * @param {Array} Medians - Array of stage median objects, each contains a `id`, `value` and `error` + * @returns {Object} Returns key value pair with the stage name and its display median value + */ +export const formatMedianValues = (medians = []) => + medians.reduce((acc, { id, value = 0 }) => { + return { + ...acc, + [id]: value ? medianTimeToParsedSeconds(value) : '-', + }; + }, {}); + +export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => + stages.filter(({ hidden = false }) => hidden === isHidden); diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js index 56e45595dc5..fed80b46eda 100644 --- a/app/assets/javascripts/deploy_freeze/store/actions.js +++ b/app/assets/javascripts/deploy_freeze/store/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -26,7 +26,9 @@ const receiveFreezePeriod = (store, request) => { dispatch('fetchFreezePeriods'); }) .catch((error) => { - createFlash(__('Error: Unable to create deploy freeze')); + createFlash({ + message: __('Error: Unable to create deploy freeze'), + }); dispatch('receiveFreezePeriodError', error); }); }; @@ -58,7 +60,9 @@ export const fetchFreezePeriods = ({ commit, state }) => { commit(types.RECEIVE_FREEZE_PERIODS_SUCCESS, data); }) .catch(() => { - createFlash(__('There was an error fetching the deploy freezes.')); + createFlash({ + message: __('There was an error fetching the deploy freezes.'), + }); }); }; diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index ecca8606f89..7815a57ce18 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -70,6 +70,13 @@ export default { ? this.getNotePositionStyle(this.movingNoteNewPosition) : this.getNotePositionStyle(this.currentCommentForm); }, + visibleNotes() { + if (this.resolvedDiscussionsExpanded) { + return this.notes; + } + + return this.notes.filter((note) => !note.resolved); + }, }, methods: { setNewNoteCoordinates({ x, y }) { @@ -272,8 +279,7 @@ export default { ></button> <design-note-pin - v-for="note in notes" - v-if="resolvedDiscussionsExpanded || !note.resolved" + v-for="note in visibleNotes" :key="note.id" :label="note.index" :position=" diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 6a3f5993a22..61946d345e3 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -12,7 +12,7 @@ import { MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT, } from '~/behaviors/shortcuts/keybindings'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -181,7 +181,6 @@ export default { plainDiffPath: (state) => state.diffs.plainDiffPath, emailPatchPath: (state) => state.diffs.emailPatchPath, retrievingBatches: (state) => state.diffs.retrievingBatches, - codequalityDiff: (state) => state.diffs.codequalityDiff, }), ...mapState('diffs', [ 'showTreeList', @@ -425,7 +424,9 @@ export default { if (toggleTree) this.setTreeDisplay(); }) .catch(() => { - createFlash(__('Something went wrong on our end. Please try again!')); + createFlash({ + message: __('Something went wrong on our end. Please try again!'), + }); }); this.fetchDiffFilesBatch() @@ -438,7 +439,9 @@ export default { this.setDiscussions(); }) .catch(() => { - createFlash(__('Something went wrong on our end. Please try again!')); + createFlash({ + message: __('Something went wrong on our end. Please try again!'), + }); }); if (this.endpointCoverage) { diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 283dbc6031c..cb74c7dc7cd 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapInline, mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { diffViewerModes } from '~/ide/constants'; @@ -15,7 +16,6 @@ import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_ import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; import DiffDiscussions from './diff_discussions.vue'; -import { mapInline, mapParallel } from './diff_row_utils'; import DiffView from './diff_view.vue'; import ImageDiffOverlay from './image_diff_overlay.vue'; import InlineDiffView from './inline_diff_view.vue'; @@ -55,6 +55,7 @@ export default { 'isParallelView', 'getCommentFormForDiffFile', 'diffLines', + 'fileLineCodequality', ]), ...mapGetters(['getNoteableData', 'noteableType', 'getUserData']), diffMode() { diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 67900af8789..edff2e67b20 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,7 +1,7 @@ <script> import { GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY } from '../constants'; @@ -95,7 +95,9 @@ export default { this.isRequesting = false; }) .catch(() => { - createFlash(s__('Diffs|Something went wrong while fetching diff lines.')); + createFlash({ + message: s__('Diffs|Something went wrong while fetching diff lines.'), + }); this.isRequesting = false; }); }, diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index ce867dbb9e0..ed8455f0c1c 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -2,7 +2,7 @@ import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; import { scrollToElement } from '~/lib/utils/common_utils'; @@ -270,7 +270,9 @@ export default { }) .catch(() => { this.isLoadingCollapsedDiff = false; - createFlash(this.$options.i18n.genericError); + createFlash({ + message: this.$options.i18n.genericError, + }); }); }, showForkMessage() { diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 676c9a3c7bc..45c7fe35f03 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -202,6 +202,9 @@ export default { externalUrlLabel() { return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url }); }, + showCodequalityBadge() { + return this.codequalityDiff?.length > 0 && !this.glFeatures.codequalityMrDiffAnnotations; + }, }, methods: { ...mapActions('diffs', [ @@ -334,7 +337,7 @@ export default { /> <code-quality-badge - v-if="codequalityDiff.length" + v-if="showCodequalityBadge" :file-name="filePath" :codequality-diff="codequalityDiff" class="gl-mr-2" @@ -351,7 +354,11 @@ export default { v-if="!diffFile.submodule && addMergeRequestButtons" class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start" > - <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> + <diff-stats + :diff-file="diffFile" + :added-lines="diffFile.added_lines" + :removed-lines="diffFile.removed_lines" + /> <gl-form-checkbox v-if="isReviewable && showLocalFileReviews" v-gl-tooltip.hover diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index d4a1a9e0e46..37dd7941b2e 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -24,6 +24,8 @@ import * as utils from './diff_row_utils'; export default { components: { DiffGutterAvatars, + CodeQualityGutterIcon: () => + import('ee_component/diffs/components/code_quality_gutter_icon.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -89,6 +91,20 @@ export default { if (!this.line.right) return {}; return this.fileLineCoverage(this.filePath, this.line.right.new_line); }, + showCodequalityLeft() { + return ( + this.glFeatures.codequalityMrDiffAnnotations && + this.inline && + this.line.left?.codequality?.length > 0 + ); + }, + showCodequalityRight() { + return ( + this.glFeatures.codequalityMrDiffAnnotations && + !this.inline && + this.line.right?.codequality?.length > 0 + ); + }, classNameMapCellLeft() { return utils.classNameMapCell({ line: this.line.left, @@ -269,6 +285,13 @@ export default { :class="[...parallelViewLeftLineType, coverageStateLeft.class]" class="diff-td line-coverage left-side" ></div> + <div class="diff-td line-codequality left-side" :class="[...parallelViewLeftLineType]"> + <code-quality-gutter-icon + v-if="showCodequalityLeft" + :file-path="filePath" + :codequality="line.left.codequality" + /> + </div> <div :id="line.left.line_code" :key="line.left.line_code" @@ -299,6 +322,11 @@ export default { :class="emptyCellLeftClassMap" ></div> <div + v-if="inline" + class="diff-td line-codequality left-side empty-cell" + :class="emptyCellLeftClassMap" + ></div> + <div class="diff-td line_content with-coverage left-side empty-cell" :class="[emptyCellLeftClassMap, { parallel: !inline }]" ></div> @@ -371,6 +399,16 @@ export default { class="diff-td line-coverage right-side" ></div> <div + class="diff-td line-codequality right-side" + :class="[line.right.type, { hll: isHighlighted, hll: isCommented }]" + > + <code-quality-gutter-icon + v-if="showCodequalityRight" + :file-path="filePath" + :codequality="line.right.codequality" + /> + </div> + <div :id="line.right.line_code" :key="line.right.rich_text" :class="[ @@ -406,6 +444,10 @@ export default { :class="emptyCellRightClassMap" ></div> <div + class="diff-td line-codequality right-side empty-cell" + :class="emptyCellRightClassMap" + ></div> + <div class="diff-td line_content with-coverage right-side empty-cell" :class="[emptyCellRightClassMap, { parallel: !inline }]" ></div> diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 0303700f42a..05d4fbe7c20 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -2,10 +2,16 @@ import { GlIcon } from '@gitlab/ui'; import { isNumber } from 'lodash'; import { n__ } from '~/locale'; +import { isNotDiffable, stats } from '../utils/diff_file'; export default { components: { GlIcon }, props: { + diffFile: { + type: Object, + required: false, + default: () => null, + }, addedLines: { type: Number, required: true, @@ -33,6 +39,12 @@ export default { hasDiffFiles() { return isNumber(this.diffFilesLength) && this.diffFilesLength >= 0; }, + notDiffable() { + return isNotDiffable(this.diffFile); + }, + fileStats() { + return stats(this.diffFile); + }, }, }; </script> @@ -41,27 +53,32 @@ export default { <div class="diff-stats" :class="{ - 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, - 'd-none d-sm-inline-flex': !isCompareVersionsHeader, + 'is-compare-versions-header gl-display-none gl-lg-display-inline-flex': isCompareVersionsHeader, + 'gl-display-none gl-sm-display-inline-flex': !isCompareVersionsHeader, }" > - <div v-if="hasDiffFiles" class="diff-stats-group"> - <gl-icon name="doc-code" class="diff-stats-icon text-secondary" /> - <span class="text-secondary bold">{{ diffFilesCountText }} {{ filesText }}</span> - </div> - <div - class="diff-stats-group cgreen d-flex align-items-center" - :class="{ bold: isCompareVersionsHeader }" - > - <span>+</span> - <span class="js-file-addition-line">{{ addedLines }}</span> + <div v-if="notDiffable" :class="fileStats.classes"> + {{ fileStats.text }} </div> - <div - class="diff-stats-group cred d-flex align-items-center" - :class="{ bold: isCompareVersionsHeader }" - > - <span>-</span> - <span class="js-file-deletion-line">{{ removedLines }}</span> + <div v-else class="diff-stats-contents"> + <div v-if="hasDiffFiles" class="diff-stats-group"> + <gl-icon name="doc-code" class="diff-stats-icon text-secondary" /> + <span class="text-secondary bold">{{ diffFilesCountText }} {{ filesText }}</span> + </div> + <div + class="diff-stats-group gl-text-green-600 gl-display-flex gl-align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>+</span> + <span data-testid="js-file-addition-line">{{ addedLines }}</span> + </div> + <div + class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>-</span> + <span data-testid="js-file-deletion-line">{{ removedLines }}</span> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 43cfa22073f..a2a6ebaeedf 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -3,6 +3,7 @@ import { mapGetters, mapState, mapActions } from 'vuex'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DiffCommentCell from './diff_comment_cell.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import DiffRow from './diff_row.vue'; @@ -14,7 +15,7 @@ export default { DiffCommentCell, DraftNote, }, - mixins: [draftCommentsMixin], + mixins: [draftCommentsMixin, glFeatureFlagsMixin()], props: { diffFile: { type: Object, @@ -43,6 +44,7 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId']), + ...mapState('diffs', ['codequalityDiff']), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -56,6 +58,12 @@ export default { this.diffLines, ); }, + hasCodequalityChanges() { + return ( + this.glFeatures.codequalityMrDiffAnnotations && + this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0 + ); + }, }, methods: { ...mapActions(['setSelectedCommentPosition']), @@ -98,7 +106,7 @@ export default { <template> <div - :class="[$options.userColorScheme, { inline }]" + :class="[$options.userColorScheme, { inline, 'with-codequality': hasCodequalityChanges }]" :data-commit-id="commitId" class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file" > diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 879922f86a2..178f93b651e 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -1,10 +1,19 @@ <script> -import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; +import { + GlButtonGroup, + GlButton, + GlDropdown, + GlFormCheckbox, + GlTooltipDirective, +} from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { SETTINGS_DROPDOWN } from '../i18n'; export default { i18n: SETTINGS_DROPDOWN, + directives: { + GlTooltip: GlTooltipDirective, + }, components: { GlButtonGroup, GlButton, @@ -27,7 +36,7 @@ export default { this.setFileByFile({ fileByFile: !this.viewDiffsFileByFile }); }, toggleWhitespace(updatedSetting) { - this.setShowWhitespace({ showWhitespace: updatedSetting, pushState: true }); + this.setShowWhitespace({ showWhitespace: updatedSetting }); }, }, }; @@ -35,9 +44,13 @@ export default { <template> <gl-dropdown + v-gl-tooltip icon="settings" - :text="__('Diff view settings')" + :title="$options.i18n.preferences" + :text="$options.i18n.preferences" :text-sr-only="true" + :aria-label="$options.i18n.preferences" + :header-text="$options.i18n.preferences" toggle-class="js-show-diff-settings" right > diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index f0e15983336..d1e02fbc598 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -1,7 +1,3 @@ -// The backend actually uses "hide_whitespace" while the frontend -// uses "show whitspace" so these values are opposite what you might expect -export const NO_SHOW_WHITESPACE = '1'; -export const SHOW_WHITESPACE = '0'; export const INLINE_DIFF_VIEW_TYPE = 'inline'; export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; export const MATCH_LINE_TYPE = 'match'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index b2354af1eec..a45fd92d0a9 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -23,4 +23,5 @@ export const DIFF_FILE = { export const SETTINGS_DROPDOWN = { whitespace: __('Show whitespace changes'), fileByFile: __('Show one file at a time'), + preferences: __('Preferences'), }; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 5a8862c2b70..0ab72749760 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -98,10 +98,23 @@ export default function initDiffsApp(store) { this.setRenderTreeList(renderTreeList); - // Set whitespace default as per user preferences unless cookie is already set - if (!Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) { - const hideWhitespace = this.showWhitespaceDefault ? '0' : '1'; - this.setShowWhitespace({ showWhitespace: hideWhitespace !== '1' }); + // NOTE: A "true" or "checked" value for `showWhitespace` is '0' not '1'. + // Check for cookie and save that setting for future use. + // Then delete the cookie as we are phasing it out and using the database as SSOT. + // NOTE: This can/should be removed later + if (Cookies.get(DIFF_WHITESPACE_COOKIE_NAME)) { + const hideWhitespace = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME); + this.setShowWhitespace({ + url: this.endpointUpdateUser, + showWhitespace: hideWhitespace !== '1', + }); + Cookies.remove(DIFF_WHITESPACE_COOKIE_NAME); + } else { + // This is only to set the the user preference in Vuex for use later + this.setShowWhitespace({ + showWhitespace: this.showWhitespaceDefault, + updateDatabase: false, + }); } }, methods: { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index d0730e18228..2e94f147086 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -1,7 +1,7 @@ import Cookies from 'js-cookie'; import Vue from 'vue'; import api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; @@ -26,9 +26,6 @@ import { START_RENDERING_INDEX, INLINE_DIFF_LINES_KEY, DIFFS_PER_PAGE, - DIFF_WHITESPACE_COOKIE_NAME, - SHOW_WHITESPACE, - NO_SHOW_WHITESPACE, DIFF_FILE_MANUAL_COLLAPSE, DIFF_FILE_AUTOMATIC_COLLAPSE, EVT_PERF_MARK_FILE_TREE_START, @@ -240,7 +237,10 @@ export const fetchCoverageFiles = ({ commit, state }) => { coveragePoll.stop(); } }, - errorCallback: () => createFlash(__('Something went wrong on our end. Please try again!')), + errorCallback: () => + createFlash({ + message: __('Something went wrong on our end. Please try again!'), + }), }); coveragePoll.makeRequest(); @@ -504,7 +504,11 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => { .then((discussion) => dispatch('assignDiscussionsToDiff', [discussion])) .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true })) .then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash)) - .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); + .catch(() => + createFlash({ + message: s__('MergeRequests|Saving the comment failed'), + }), + ); }; export const toggleTreeOpen = ({ commit }, path) => { @@ -562,16 +566,15 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => { } }; -export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => { - commit(types.SET_SHOW_WHITESPACE, showWhitespace); - const w = showWhitespace ? SHOW_WHITESPACE : NO_SHOW_WHITESPACE; - - Cookies.set(DIFF_WHITESPACE_COOKIE_NAME, w); - - if (pushState) { - historyPushState(mergeUrlParams({ w }, window.location.href)); +export const setShowWhitespace = async ( + { state, commit }, + { url, showWhitespace, updateDatabase = true }, +) => { + if (updateDatabase) { + await axios.put(url || state.endpointUpdateUser, { show_whitespace_in_diffs: showWhitespace }); } + commit(types.SET_SHOW_WHITESPACE, showWhitespace); notesEventHub.$emit('refetchDiffData'); if (window.gon?.features?.diffSettingsUsageData) { @@ -595,7 +598,9 @@ export const cacheTreeListWidth = (_, size) => { export const receiveFullDiffError = ({ commit }, filePath) => { commit(types.RECEIVE_FULL_DIFF_ERROR, filePath); - createFlash(s__('MergeRequest|Error loading full diff. Please try again.')); + createFlash({ + message: s__('MergeRequest|Error loading full diff. Please try again.'), + }); }; export const setExpandedDiffLines = ({ commit }, { file, data }) => { @@ -727,7 +732,9 @@ export const setSuggestPopoverDismissed = ({ commit, state }) => commit(types.SET_SHOW_SUGGEST_POPOVER); }) .catch(() => { - createFlash(s__('MergeRequest|Error dismissing suggestion popover. Please try again.')); + createFlash({ + message: s__('MergeRequest|Error dismissing suggestion popover. Please try again.'), + }); }); export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) { diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 0a9623c13a3..a536db5c417 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,3 +1,4 @@ +import { getParameterValues } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import { PARALLEL_DIFF_VIEW_TYPE, @@ -135,6 +136,11 @@ export const fileLineCoverage = (state) => (file, line) => { return {}; }; +// This function is overwritten for the inline codequality feature in EE +export const fileLineCodequality = () => () => { + return null; +}; + /** * Returns index of a currently selected diff in diffFiles * @returns {number} @@ -172,4 +178,6 @@ export function suggestionCommitMessage(state, _, rootState) { } export const isVirtualScrollingEnabled = (state) => - !state.viewDiffsFileByFile && window.gon?.features?.diffsVirtualScrolling; + !state.viewDiffsFileByFile && + (window.gon?.features?.diffsVirtualScrolling || + getParameterValues('virtual_scrolling')[0] === 'true'); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 1674d3d3b5a..348dd452698 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,20 +1,13 @@ import Cookies from 'js-cookie'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { - INLINE_DIFF_VIEW_TYPE, - DIFF_VIEW_COOKIE_NAME, - DIFF_WHITESPACE_COOKIE_NAME, -} from '../../constants'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; import { fileByFile } from '../../utils/preferences'; -import { getDefaultWhitespace } from '../utils'; const getViewTypeFromQueryString = () => getParameterValues('view')[0]; const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; -const whiteSpaceFromQueryString = getParameterValues('w')[0]; -const whiteSpaceFromCookie = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME); export default () => ({ isLoading: true, @@ -42,7 +35,7 @@ export default () => ({ commentForms: [], highlightedRow: null, renderTreeList: true, - showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie), + showWhitespace: true, viewDiffsFileByFile: fileByFile(), fileFinderVisible: false, dismissEndpoint: '', diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js index 03d11e60745..169502a957b 100644 --- a/app/assets/javascripts/diffs/store/modules/index.js +++ b/app/assets/javascripts/diffs/store/modules/index.js @@ -1,7 +1,7 @@ import * as actions from 'ee_else_ce/diffs/store/actions'; +import * as getters from 'ee_else_ce/diffs/store/getters'; import createState from 'ee_else_ce/diffs/store/modules/diff_state'; import mutations from 'ee_else_ce/diffs/store/mutations'; -import * as getters from '../getters'; export default () => ({ namespaced: true, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 7fa51b9ddea..75d2cf43b94 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -1,6 +1,5 @@ import { property, isEqual } from 'lodash'; import { diffModes, diffViewerModes } from '~/ide/constants'; -import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; import { LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -11,10 +10,7 @@ import { OLD_LINE_TYPE, MATCH_LINE_TYPE, LINES_TO_BE_RENDERED_DIRECTLY, - TREE_TYPE, INLINE_DIFF_LINES_KEY, - SHOW_WHITESPACE, - NO_SHOW_WHITESPACE, CONFLICT_OUR, CONFLICT_THEIR, CONFLICT_MARKER, @@ -485,111 +481,6 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD return latestDiff && discussion.active && line_code === discussion.line_code; } -export const getLowestSingleFolder = (folder) => { - const getFolder = (blob, start = []) => - blob.tree.reduce( - (acc, file) => { - const shouldGetFolder = file.tree.length === 1 && file.tree[0].type === TREE_TYPE; - const currentFileTypeTree = file.type === TREE_TYPE; - const path = shouldGetFolder || currentFileTypeTree ? acc.path.concat(file.name) : acc.path; - const tree = shouldGetFolder || currentFileTypeTree ? acc.tree.concat(file) : acc.tree; - - if (shouldGetFolder) { - const firstFolder = getFolder(file); - - path.push(...firstFolder.path); - tree.push(...firstFolder.tree); - } - - return { - ...acc, - path, - tree, - }; - }, - { path: start, tree: [] }, - ); - const { path, tree } = getFolder(folder, [folder.name]); - - return { - path: truncatePathMiddleToLength(path.join('/'), 40), - treeAcc: tree.length ? tree[tree.length - 1].tree : null, - }; -}; - -export const flattenTree = (tree) => { - const flatten = (blobTree) => - blobTree.reduce((acc, file) => { - const blob = file; - let treeToFlatten = blob.tree; - - if (file.type === TREE_TYPE && file.tree.length === 1) { - const { treeAcc, path } = getLowestSingleFolder(file); - - if (treeAcc) { - blob.name = path; - treeToFlatten = flatten(treeAcc); - } - } - - blob.tree = flatten(treeToFlatten); - - return acc.concat(blob); - }, []); - - return flatten(tree); -}; - -export const generateTreeList = (files) => { - const { treeEntries, tree } = files.reduce( - (acc, file) => { - const split = file.new_path.split('/'); - - split.forEach((name, i) => { - const parent = acc.treeEntries[split.slice(0, i).join('/')]; - const path = `${parent ? `${parent.path}/` : ''}${name}`; - - if (!acc.treeEntries[path]) { - const type = path === file.new_path ? 'blob' : 'tree'; - acc.treeEntries[path] = { - key: path, - path, - name, - type, - tree: [], - }; - - const entry = acc.treeEntries[path]; - - if (type === 'blob') { - Object.assign(entry, { - changed: true, - tempFile: file.new_file, - deleted: file.deleted_file, - fileHash: file.file_hash, - addedLines: file.added_lines, - removedLines: file.removed_lines, - parentPath: parent ? `${parent.path}/` : '/', - submodule: file.submodule, - }); - } else { - Object.assign(entry, { - opened: true, - }); - } - - (parent ? parent.tree : acc.tree).push(entry); - } - }); - - return acc; - }, - { treeEntries: {}, tree: [] }, - ); - - return { treeEntries, tree: flattenTree(tree) }; -}; - export const getDiffMode = (diffFile) => { const diffModeKey = Object.keys(diffModes).find((key) => diffFile[`${key}_file`]); return ( @@ -666,10 +557,3 @@ export const allDiscussionWrappersExpanded = (diff) => { return discussionsExpanded; }; - -export const getDefaultWhitespace = (queryString, cookie) => { - // Querystring should override stored cookie value - if (queryString) return queryString === SHOW_WHITESPACE; - if (cookie === NO_SHOW_WHITESPACE) return false; - return true; -}; diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index a96c1207a04..54dcf70c491 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -1,3 +1,5 @@ +import { diffViewerModes as viewerModes } from '~/ide/constants'; +import { changeInPercent, numberToHumanSize } from '~/lib/utils/number_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { uuids } from '~/lib/utils/uuids'; @@ -46,6 +48,8 @@ function identifier(file) { })[0]; } +export const isNotDiffable = (file) => file?.viewer?.name === viewerModes.not_diffable; + export function prepareRawDiffFile({ file, allFiles, meta = false }) { const additionalProperties = { brokenSymlink: fileSymlinkInformation(file, allFiles), @@ -84,3 +88,35 @@ export function isCollapsed(file) { export function getShortShaFromFile(file) { return file.content_sha ? truncateSha(String(file.content_sha)) : null; } + +export function stats(file) { + let valid = false; + let classes = ''; + let sign = ''; + let text = ''; + let percent = 0; + let diff = 0; + + if (file) { + percent = changeInPercent(file.old_size, file.new_size); + diff = file.new_size - file.old_size; + sign = diff >= 0 ? '+' : ''; + text = `${sign}${numberToHumanSize(diff)} (${sign}${percent}%)`; + valid = true; + + if (diff > 0) { + classes = 'gl-text-green-600'; + } else if (diff < 0) { + classes = 'gl-text-red-500'; + } + } + + return { + changed: diff, + text, + percent, + classes, + sign, + valid, + }; +} diff --git a/app/assets/javascripts/diffs/utils/workers.js b/app/assets/javascripts/diffs/utils/workers.js new file mode 100644 index 00000000000..985e75d1a17 --- /dev/null +++ b/app/assets/javascripts/diffs/utils/workers.js @@ -0,0 +1,107 @@ +import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; +import { TREE_TYPE } from '../constants'; + +export const getLowestSingleFolder = (folder) => { + const getFolder = (blob, start = []) => + blob.tree.reduce( + (acc, file) => { + const shouldGetFolder = file.tree.length === 1 && file.tree[0].type === TREE_TYPE; + const currentFileTypeTree = file.type === TREE_TYPE; + const path = shouldGetFolder || currentFileTypeTree ? acc.path.concat(file.name) : acc.path; + const tree = shouldGetFolder || currentFileTypeTree ? acc.tree.concat(file) : acc.tree; + + if (shouldGetFolder) { + const firstFolder = getFolder(file); + + path.push(...firstFolder.path); + tree.push(...firstFolder.tree); + } + + return { + ...acc, + path, + tree, + }; + }, + { path: start, tree: [] }, + ); + const { path, tree } = getFolder(folder, [folder.name]); + + return { + path: truncatePathMiddleToLength(path.join('/'), 40), + treeAcc: tree.length ? tree[tree.length - 1].tree : null, + }; +}; + +export const flattenTree = (tree) => { + const flatten = (blobTree) => + blobTree.reduce((acc, file) => { + const blob = file; + let treeToFlatten = blob.tree; + + if (file.type === TREE_TYPE && file.tree.length === 1) { + const { treeAcc, path } = getLowestSingleFolder(file); + + if (treeAcc) { + blob.name = path; + treeToFlatten = flatten(treeAcc); + } + } + + blob.tree = flatten(treeToFlatten); + + return acc.concat(blob); + }, []); + + return flatten(tree); +}; + +export const generateTreeList = (files) => { + const { treeEntries, tree } = files.reduce( + (acc, file) => { + const split = file.new_path.split('/'); + + split.forEach((name, i) => { + const parent = acc.treeEntries[split.slice(0, i).join('/')]; + const path = `${parent ? `${parent.path}/` : ''}${name}`; + + if (!acc.treeEntries[path]) { + const type = path === file.new_path ? 'blob' : 'tree'; + acc.treeEntries[path] = { + key: path, + path, + name, + type, + tree: [], + }; + + const entry = acc.treeEntries[path]; + + if (type === 'blob') { + Object.assign(entry, { + changed: true, + tempFile: file.new_file, + deleted: file.deleted_file, + fileHash: file.file_hash, + addedLines: file.added_lines, + removedLines: file.removed_lines, + parentPath: parent ? `${parent.path}/` : '/', + submodule: file.submodule, + }); + } else { + Object.assign(entry, { + opened: true, + }); + } + + (parent ? parent.tree : acc.tree).push(entry); + } + }); + + return acc; + }, + { treeEntries: {}, tree: [] }, + ); + + return { treeEntries, tree: flattenTree(tree) }; +}; diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js index 2fa1934439e..6d1bc78ba1c 100644 --- a/app/assets/javascripts/diffs/workers/tree_worker.js +++ b/app/assets/javascripts/diffs/workers/tree_worker.js @@ -1,5 +1,5 @@ import { sortTree } from '~/ide/stores/utils'; -import { generateTreeList } from '../store/utils'; +import { generateTreeList } from '../utils/workers'; // eslint-disable-next-line no-restricted-globals self.addEventListener('message', (e) => { diff --git a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js index ae6c89d3942..c5ee61ec86e 100644 --- a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js @@ -14,9 +14,9 @@ export class CiSchemaExtension extends EditorLiteExtension { * @param {Object} opts * @param {String} opts.projectNamespace * @param {String} opts.projectPath - * @param {String?} opts.ref - Current ref. Defaults to master + * @param {String?} opts.ref - Current ref. Defaults to main */ - registerCiSchema({ projectNamespace, projectPath, ref = 'master' } = {}) { + registerCiSchema({ projectNamespace, projectPath, ref } = {}) { const ciSchemaPath = Api.buildUrl(Api.projectFileSchemaPath) .replace(':namespace_path', projectNamespace) .replace(':project_path', projectPath) diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js index 16268910f49..1a084d37762 100644 --- a/app/assets/javascripts/emoji/awards_app/index.js +++ b/app/assets/javascripts/emoji/awards_app/index.js @@ -32,7 +32,7 @@ export default (el) => { canAwardEmoji: this.canAwardEmoji, currentUserId: this.currentUserId, defaultAwards: ['thumbsup', 'thumbsdown'], - selectedClass: 'gl-bg-blue-50! is-active', + selectedClass: 'selected', }, on: { award: this.toggleAward, diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js index 482acc5a3a9..f0340209248 100644 --- a/app/assets/javascripts/emoji/awards_app/store/actions.js +++ b/app/assets/javascripts/emoji/awards_app/store/actions.js @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import showToast from '~/vue_shared/plugins/global_toast'; import { @@ -13,8 +14,12 @@ import { export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data); export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => { + if (!window.gon?.current_user_id) return; + try { - const { data, headers } = await axios.get(state.path, { params: { per_page: 100, page } }); + const { data, headers } = await axios.get(joinPaths(gon.relative_url_root || '', state.path), { + params: { per_page: 100, page }, + }); const normalizedHeaders = normalizeHeaders(headers); const nextPage = normalizedHeaders['X-NEXT-PAGE']; @@ -33,13 +38,15 @@ export const toggleAward = async ({ commit, state }, name) => { try { if (award) { - await axios.delete(`${state.path}/${award.id}`); + await axios.delete(joinPaths(gon.relative_url_root || '', `${state.path}/${award.id}`)); commit(REMOVE_AWARD, award.id); showToast(__('Award removed')); } else { - const { data } = await axios.post(state.path, { name }); + const { data } = await axios.post(joinPaths(gon.relative_url_root || '', state.path), { + name, + }); commit(ADD_NEW_AWARD, data); diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index e08d294b8c5..dc3eac0cd0c 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -23,6 +23,11 @@ export default { required: false, default: () => [], }, + dropdownClass: { + type: [Array, String, Object], + required: false, + default: () => [], + }, }, data() { return { @@ -78,8 +83,9 @@ export default { ref="dropdown" :toggle-class="toggleClass" :boundary="getBoundaryElement()" + :class="dropdownClass" menu-class="dropdown-extended-height" - category="tertiary" + category="secondary" no-flip right lazy @@ -105,7 +111,7 @@ export default { 'gl-text-body! emoji-picker-category-active': index === currentCategory, }" type="button" - class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab" + class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-grow-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab" :aria-label="category.name" @click="scrollToCategory(category.name)" > diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 8911380290a..542b8c9219d 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -89,7 +89,7 @@ export default { data-testid="manual-action-link" @click="onClickAction(action)" > - <span class="gl-flex-fill-1">{{ action.name }}</span> + <span class="gl-flex-grow-1">{{ action.name }}</span> <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right"> <gl-icon name="clock" /> {{ remainingTime(action) }} diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 602639f09a6..8bd71db957c 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -121,7 +121,7 @@ export default { variant="info" category="secondary" type="button" - class="gl-mb-3 gl-flex-fill-1" + class="gl-mb-3 gl-flex-grow-1" >{{ $options.i18n.reviewAppButtonLabel }}</gl-button > <gl-button diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 828a7098b36..7a9233048a9 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -58,7 +58,7 @@ export default { <span v-gl-tooltip :title="environment.name" - class="gl-text-truncate gl-ml-2 gl-mr-2 gl-flex-fill" + class="gl-text-truncate gl-ml-2 gl-mr-2 gl-flex-grow-1" > {{ environment.name }}? </span> diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index f05f0cb7c6d..0a15cb56447 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -13,7 +13,7 @@ import { GlIcon, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __, sprintf, n__ } from '~/locale'; import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -88,7 +88,10 @@ export default { }, pollInterval: 2000, update: (data) => data.project.sentryErrors.detailedError, - error: () => createFlash(__('Failed to load error details from Sentry.')), + error: () => + createFlash({ + message: __('Failed to load error details from Sentry.'), + }), result(res) { if (res.data.project?.sentryErrors?.detailedError) { this.$apollo.queries.error.stopPolling(); @@ -225,7 +228,10 @@ export default { if (Date.now() > this.errorPollTimeout) { this.$apollo.queries.error.stopPolling(); this.errorLoading = false; - createFlash(__('Could not connect to Sentry. Refresh the page to try again.'), 'warning'); + createFlash({ + message: __('Could not connect to Sentry. Refresh the page to try again.'), + type: 'warning', + }); } }, trackPageViews() { diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 0f564fc3c60..2e27f51b71f 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -260,7 +260,7 @@ export default { </template> <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> </gl-dropdown> - <div class="filtered-search-input-container gl-flex-fill-1"> + <div class="filtered-search-input-container gl-flex-grow-1"> <gl-form-input v-model="errorSearchQuery" class="pl-2 filtered-search" diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index a27ebd16956..fbfcd6ce2df 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import service from '../services'; @@ -17,7 +17,11 @@ export const updateStatus = ({ commit }, { endpoint, redirectUrl, status }) => return resp.data.result; }) - .catch(() => createFlash(__('Failed to update issue status'))); + .catch(() => + createFlash({ + message: __('Failed to update issue status'), + }), + ); export const updateResolveStatus = ({ commit, dispatch }, params) => { commit(types.SET_UPDATING_RESOLVE_STATUS, true); diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js index 7319d45bbd2..09fa650f64b 100644 --- a/app/assets/javascripts/error_tracking/store/details/actions.js +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import service from '../../services'; @@ -26,7 +26,9 @@ export function startPollingStacktrace({ commit }, endpoint) { }, errorCallback: () => { commit(types.SET_LOADING_STACKTRACE, false); - createFlash(__('Failed to load stacktrace.')); + createFlash({ + message: __('Failed to load stacktrace.'), + }); }, }); diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index f07e546241a..418056314f6 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import Service from '../../services'; @@ -33,7 +33,9 @@ export function startPolling({ state, commit, dispatch }) { }, errorCallback: () => { commit(types.SET_LOADING, false); - createFlash(__('Failed to load errors from Sentry.')); + createFlash({ + message: __('Failed to load errors from Sentry.'), + }); }, }); diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 7eb684fb52c..c945a9e2316 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -46,7 +46,10 @@ export const requestSettings = ({ commit }) => { export const receiveSettingsError = ({ commit }, { response = {} }) => { const message = response.data && response.data.message ? response.data.message : ''; - createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); + createFlash({ + message: `${__('There was an error saving your changes.')} ${message}`, + type: 'alert', + }); commit(types.UPDATE_SETTINGS_LOADING, false); }; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/empty_state.vue index d0df00e446b..a6de4972bb1 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue +++ b/app/assets/javascripts/feature_flags/components/empty_state.vue @@ -1,14 +1,10 @@ <script> -import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui'; export default { - components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab }, + components: { GlAlert, GlEmptyState, GlLink, GlLoadingIcon }, inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'], props: { - title: { - required: true, - type: String, - }, count: { required: false, type: Number, @@ -56,18 +52,11 @@ export default { clearAlert(index) { this.$emit('dismissAlert', index); }, - onClick(event) { - return this.$emit('changeTab', event); - }, }, }; </script> <template> - <gl-tab @click="onClick"> - <template #title> - <span data-testid="feature-flags-tab-title">{{ title }}</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge> - </template> + <div> <gl-alert v-for="(message, index) in alerts" :key="index" @@ -83,7 +72,7 @@ export default { <gl-empty-state v-else-if="errorState" :title="errorTitle" - :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)" + :description="s__('FeatureFlags|Try again in a few moments or contact your support team.')" :svg-path="errorStateSvgPath" data-testid="error-state" /> @@ -101,6 +90,6 @@ export default { </gl-link> </template> </gl-empty-state> - <slot> </slot> - </gl-tab> + <slot v-else> </slot> + </div> </template> diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue index 7f316d20f9c..70b60b4b113 100644 --- a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlSearchBoxByType } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -87,7 +87,9 @@ export default { .catch(() => { this.isLoading = false; this.closeSuggestions(); - createFlash(__('Something went wrong on our end. Please try again.')); + createFlash({ + message: __('Something went wrong on our end. Please try again.'), + }); }); }, 250), /** diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 9aa1accb0f2..d08e8d2b3a1 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlModalDirective, GlSprintf, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapState, mapActions } from 'vuex'; @@ -9,50 +9,40 @@ import { historyPushState, } from '~/lib/utils/common_utils'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants'; import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue'; -import FeatureFlagsTab from './feature_flags_tab.vue'; +import EmptyState from './empty_state.vue'; import FeatureFlagsTable from './feature_flags_table.vue'; -import UserListsTable from './user_lists_table.vue'; - -const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE }; export default { components: { ConfigureFeatureFlagsModal, - FeatureFlagsTab, + EmptyState, FeatureFlagsTable, GlAlert, + GlBadge, GlButton, GlSprintf, - GlTabs, TablePagination, - UserListsTable, }, directives: { GlModal: GlModalDirective, }, inject: { - newUserListPath: { default: '' }, + userListPath: { default: '' }, newFeatureFlagPath: { default: '' }, canUserConfigure: {}, featureFlagsLimitExceeded: {}, featureFlagsLimit: {}, }, data() { - const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE; return { - scope, page: getParameterByName('page') || '1', - isUserListAlertDismissed: false, shouldShowFeatureFlagsLimitWarning: this.featureFlagsLimitExceeded, - selectedTab: Object.values(SCOPES).indexOf(scope), }; }, computed: { ...mapState([ - FEATURE_FLAG_SCOPE, - USER_LIST_SCOPE, + 'featureFlags', 'alerts', 'count', 'pageInfo', @@ -69,64 +59,41 @@ export default { canUserRotateToken() { return this.rotateInstanceIdPath !== ''; }, - currentlyDisplayedData() { - return this.dataForScope(this.scope); - }, shouldRenderPagination() { return ( !this.isLoading && !this.hasError && - this.currentlyDisplayedData.length > 0 && - this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage + this.featureFlags.length > 0 && + this.pageInfo.total > this.pageInfo.perPage ); }, shouldShowEmptyState() { - return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0; + return !this.isLoading && !this.hasError && this.featureFlags.length === 0; }, shouldRenderErrorState() { return this.hasError && !this.isLoading; }, shouldRenderFeatureFlags() { - return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE); - }, - shouldRenderUserLists() { - return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE); + return !this.isLoading && this.featureFlags.length > 0 && !this.hasError; }, hasNewPath() { return !isEmpty(this.newFeatureFlagPath); }, }, created() { - this.setFeatureFlagsOptions({ scope: this.scope, page: this.page }); + this.setFeatureFlagsOptions({ page: this.page }); this.fetchFeatureFlags(); - this.fetchUserLists(); }, methods: { ...mapActions([ 'setFeatureFlagsOptions', 'fetchFeatureFlags', - 'fetchUserLists', 'rotateInstanceId', 'toggleFeatureFlag', - 'deleteUserList', 'clearAlert', ]), - onChangeTab(scope) { - this.scope = scope; - this.updateFeatureFlagOptions({ - scope, - page: '1', - }); - }, - onFeatureFlagsTab() { - this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE); - }, - onUserListsTab() { - this.onChangeTab(SCOPES.USER_LIST_SCOPE); - }, onChangePage(page) { this.updateFeatureFlagOptions({ - scope: this.scope, /* URLS parameters are strings, we need to parse to match types */ page: Number(page).toString(), }); @@ -141,22 +108,7 @@ export default { historyPushState(buildUrlWithCurrentLocation(`?${queryString}`)); this.setFeatureFlagsOptions(parameters); - if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) { - this.fetchFeatureFlags(); - } else { - this.fetchUserLists(); - } - }, - shouldRenderTable(scope) { - return ( - !this.isLoading && - this.dataForScope(scope).length > 0 && - !this.hasError && - this.scope === scope - ); - }, - dataForScope(scope) { - return this[scope]; + this.fetchFeatureFlags(); }, onDismissFeatureFlagsLimitWarning() { this.shouldShowFeatureFlagsLimitWarning = false; @@ -200,6 +152,16 @@ export default { <div :class="topAreaBaseClasses"> <div class="gl-display-flex gl-flex-direction-column gl-md-display-none!"> <gl-button + v-if="userListPath" + :href="userListPath" + variant="confirm" + category="tertiary" + class="gl-mb-3" + data-testid="ff-new-list-button" + > + {{ s__('FeatureFlags|View user lists') }} + </gl-button> + <gl-button v-if="canUserConfigure" v-gl-modal="'configure-feature-flags'" variant="info" @@ -212,17 +174,6 @@ export default { </gl-button> <gl-button - v-if="newUserListPath" - :href="newUserListPath" - variant="confirm" - category="secondary" - class="gl-mb-3" - data-testid="ff-new-list-button" - > - {{ s__('FeatureFlags|New user list') }} - </gl-button> - - <gl-button v-if="hasNewPath" :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" variant="confirm" @@ -232,101 +183,70 @@ export default { {{ s__('FeatureFlags|New feature flag') }} </gl-button> </div> - <gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full"> - <feature-flags-tab - :title="s__('FeatureFlags|Feature Flags')" - :count="count.featureFlags" - :alerts="alerts" - :is-loading="isLoading" - :loading-label="s__('FeatureFlags|Loading feature flags')" - :error-state="shouldRenderErrorState" - :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)" - :empty-state="shouldShowEmptyState" - :empty-title="s__('FeatureFlags|Get started with feature flags')" - :empty-description=" - s__( - 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', - ) - " - data-testid="feature-flags-tab" - @dismissAlert="clearAlert" - @changeTab="onFeatureFlagsTab" - > - <feature-flags-table - v-if="shouldRenderFeatureFlags" - :feature-flags="featureFlags" - @toggle-flag="toggleFeatureFlag" - /> - </feature-flags-tab> - <feature-flags-tab - :title="s__('FeatureFlags|User Lists')" - :count="count.userLists" - :alerts="alerts" - :is-loading="isLoading" - :loading-label="s__('FeatureFlags|Loading user lists')" - :error-state="shouldRenderErrorState" - :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)" - :empty-state="shouldShowEmptyState" - :empty-title="s__('FeatureFlags|Get started with user lists')" - :empty-description=" - s__( - 'FeatureFlags|User lists allow you to define a set of users to use with Feature Flags.', - ) - " - data-testid="user-lists-tab" - @dismissAlert="clearAlert" - @changeTab="onUserListsTab" + <div + class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + > + <div class="gl-display-flex gl-align-items-center"> + <h2 data-testid="feature-flags-tab-title" class="gl-font-size-h2 gl-my-0"> + {{ s__('FeatureFlags|Feature Flags') }} + </h2> + <gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge> + </div> + <div + class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end" > - <user-lists-table - v-if="shouldRenderUserLists" - :user-lists="userLists" - @delete="deleteUserList" - /> - </feature-flags-tab> - <template #tabs-end> - <li - class="gl-display-none gl-md-display-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end" + <gl-button + v-if="userListPath" + :href="userListPath" + variant="confirm" + category="tertiary" + class="gl-mb-0 gl-mr-4" + data-testid="ff-user-list-button" > - <gl-button - v-if="canUserConfigure" - v-gl-modal="'configure-feature-flags'" - variant="info" - category="secondary" - data-qa-selector="configure_feature_flags_button" - data-testid="ff-configure-button" - class="gl-mb-0 gl-mr-4" - > - {{ s__('FeatureFlags|Configure') }} - </gl-button> - - <gl-button - v-if="newUserListPath" - :href="newUserListPath" - variant="confirm" - category="secondary" - class="gl-mb-0 gl-mr-4" - data-testid="ff-new-list-button" - > - {{ s__('FeatureFlags|New user list') }} - </gl-button> + {{ s__('FeatureFlags|View user lists') }} + </gl-button> + <gl-button + v-if="canUserConfigure" + v-gl-modal="'configure-feature-flags'" + variant="info" + category="secondary" + data-qa-selector="configure_feature_flags_button" + data-testid="ff-configure-button" + class="gl-mb-0 gl-mr-4" + > + {{ s__('FeatureFlags|Configure') }} + </gl-button> - <gl-button - v-if="hasNewPath" - :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" - variant="confirm" - data-testid="ff-new-button" - @click="onNewFeatureFlagCLick" - > - {{ s__('FeatureFlags|New feature flag') }} - </gl-button> - </li> - </template> - </gl-tabs> + <gl-button + v-if="hasNewPath" + :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" + variant="confirm" + data-testid="ff-new-button" + @click="onNewFeatureFlagCLick" + > + {{ s__('FeatureFlags|New feature flag') }} + </gl-button> + </div> + </div> + <empty-state + :alerts="alerts" + :is-loading="isLoading" + :loading-label="s__('FeatureFlags|Loading feature flags')" + :error-state="shouldRenderErrorState" + :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)" + :empty-state="shouldShowEmptyState" + :empty-title="s__('FeatureFlags|Get started with feature flags')" + :empty-description=" + s__( + 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ) + " + data-testid="feature-flags-tab" + @dismissAlert="clearAlert" + > + <feature-flags-table :feature-flags="featureFlags" @toggle-flag="toggleFeatureFlag" /> + </empty-state> </div> - <table-pagination - v-if="shouldRenderPagination" - :change="onChangePage" - :page-info="pageInfo[scope]" - /> + <table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" /> </div> </template> diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue index efe4ff71a9e..c59e3178b09 100644 --- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue +++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue @@ -8,7 +8,7 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; @@ -52,7 +52,9 @@ export default { this.results = data || []; }) .catch(() => { - createFlash(__('Something went wrong on our end. Please try again.')); + createFlash({ + message: __('Something went wrong on our end. Please try again.'), + }); }) .finally(() => { this.isLoading = false; diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js index 658984456a5..f697f203cf5 100644 --- a/app/assets/javascripts/feature_flags/constants.js +++ b/app/assets/javascripts/feature_flags/constants.js @@ -21,9 +21,6 @@ export const fetchUserIdParams = property(['parameters', 'userIds']); export const NEW_VERSION_FLAG = 'new_version_flag'; export const LEGACY_FLAG = 'legacy_flag'; -export const FEATURE_FLAG_SCOPE = 'featureFlags'; -export const USER_LIST_SCOPE = 'userLists'; - export const EMPTY_PARAMETERS = { parameters: {}, userListId: undefined }; export const STRATEGY_SELECTIONS = [ diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js index d2371a2aa8b..5c0d9cb8624 100644 --- a/app/assets/javascripts/feature_flags/index.js +++ b/app/assets/javascripts/feature_flags/index.js @@ -22,7 +22,7 @@ export default () => { unleashApiUrl, canUserAdminFeatureFlag, newFeatureFlagPath, - newUserListPath, + userListPath, featureFlagsLimitExceeded, featureFlagsLimit, } = el.dataset; @@ -40,9 +40,9 @@ export default () => { csrfToken: csrf.token, canUserConfigure: canUserAdminFeatureFlag !== undefined, newFeatureFlagPath, - newUserListPath, featureFlagsLimitExceeded: featureFlagsLimitExceeded !== undefined, featureFlagsLimit, + userListPath, }, render(createElement) { return createElement(FeatureFlagsComponent); diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js index 72b17333832..54c7e8c4453 100644 --- a/app/assets/javascripts/feature_flags/store/edit/actions.js +++ b/app/assets/javascripts/feature_flags/store/edit/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -55,7 +55,9 @@ export const receiveFeatureFlagSuccess = ({ commit }, response) => commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response); export const receiveFeatureFlagError = ({ commit }) => { commit(types.RECEIVE_FEATURE_FLAG_ERROR); - createFlash(__('Something went wrong on our end. Please try again!')); + createFlash({ + message: __('Something went wrong on our end. Please try again!'), + }); }; export const toggleActive = ({ commit }, active) => commit(types.TOGGLE_ACTIVE, active); diff --git a/app/assets/javascripts/feature_flags/store/index/actions.js b/app/assets/javascripts/feature_flags/store/index/actions.js index 4372c280f39..751f627ca48 100644 --- a/app/assets/javascripts/feature_flags/store/index/actions.js +++ b/app/assets/javascripts/feature_flags/store/index/actions.js @@ -1,4 +1,3 @@ -import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import * as types from './mutation_types'; @@ -26,19 +25,6 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) => commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response); export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR); -export const fetchUserLists = ({ state, dispatch }) => { - dispatch('requestUserLists'); - - return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page) - .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers })) - .catch(() => dispatch('receiveUserListsError')); -}; - -export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS); -export const receiveUserListsSuccess = ({ commit }, response) => - commit(types.RECEIVE_USER_LISTS_SUCCESS, response); -export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR); - export const toggleFeatureFlag = ({ dispatch }, flag) => { dispatch('updateFeatureFlag', flag); @@ -57,26 +43,6 @@ export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) => export const receiveUpdateFeatureFlagError = ({ commit }, id) => commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id); -export const deleteUserList = ({ state, dispatch }, list) => { - dispatch('requestDeleteUserList', list); - - return Api.deleteFeatureFlagUserList(state.projectId, list.iid) - .then(() => dispatch('fetchUserLists')) - .catch((error) => - dispatch('receiveDeleteUserListError', { - list, - error: error?.response?.data ?? error, - }), - ); -}; - -export const requestDeleteUserList = ({ commit }, list) => - commit(types.REQUEST_DELETE_USER_LIST, list); - -export const receiveDeleteUserListError = ({ commit }, { error, list }) => { - commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list }); -}; - export const rotateInstanceId = ({ state, dispatch }) => { dispatch('requestRotateInstanceId'); diff --git a/app/assets/javascripts/feature_flags/store/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/index/mutation_types.js index 189c763782e..ed05294a6f3 100644 --- a/app/assets/javascripts/feature_flags/store/index/mutation_types.js +++ b/app/assets/javascripts/feature_flags/store/index/mutation_types.js @@ -4,13 +4,6 @@ export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS'; export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS'; export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR'; -export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS'; -export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS'; -export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR'; - -export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST'; -export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR'; - export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG'; export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS'; export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR'; diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js index 25eb7da1c72..54e48a4b80c 100644 --- a/app/assets/javascripts/feature_flags/store/index/mutations.js +++ b/app/assets/javascripts/feature_flags/store/index/mutations.js @@ -1,17 +1,16 @@ import Vue from 'vue'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants'; import { mapToScopesViewModel } from '../helpers'; import * as types from './mutation_types'; const mapFlag = (flag) => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) }); const updateFlag = (state, flag) => { - const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id); - Vue.set(state[FEATURE_FLAG_SCOPE], index, flag); + const index = state.featureFlags.findIndex(({ id }) => id === flag.id); + Vue.set(state.featureFlags, index, flag); }; -const createPaginationInfo = (state, headers) => { +const createPaginationInfo = (headers) => { let paginationInfo; if (Object.keys(headers).length) { const normalizedHeaders = normalizeHeaders(headers); @@ -32,44 +31,16 @@ export default { [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) { state.isLoading = false; state.hasError = false; - state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag); + state.featureFlags = (response.data.feature_flags || []).map(mapFlag); - const paginationInfo = createPaginationInfo(state, response.headers); - state.count = { - ...state.count, - [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length, - }; - state.pageInfo = { - ...state.pageInfo, - [FEATURE_FLAG_SCOPE]: paginationInfo, - }; + const paginationInfo = createPaginationInfo(response.headers); + state.count = paginationInfo?.total ?? state.featureFlags.length; + state.pageInfo = paginationInfo; }, [types.RECEIVE_FEATURE_FLAGS_ERROR](state) { state.isLoading = false; state.hasError = true; }, - [types.REQUEST_USER_LISTS](state) { - state.isLoading = true; - }, - [types.RECEIVE_USER_LISTS_SUCCESS](state, response) { - state.isLoading = false; - state.hasError = false; - state[USER_LIST_SCOPE] = response.data || []; - - const paginationInfo = createPaginationInfo(state, response.headers); - state.count = { - ...state.count, - [USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length, - }; - state.pageInfo = { - ...state.pageInfo, - [USER_LIST_SCOPE]: paginationInfo, - }; - }, - [types.RECEIVE_USER_LISTS_ERROR](state) { - state.isLoading = false; - state.hasError = true; - }, [types.REQUEST_ROTATE_INSTANCE_ID](state) { state.isRotating = true; state.hasRotateError = false; @@ -90,18 +61,9 @@ export default { updateFlag(state, mapFlag(data)); }, [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) { - const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id); + const flag = state.featureFlags.find(({ id }) => i === id); updateFlag(state, { ...flag, active: !flag.active }); }, - [types.REQUEST_DELETE_USER_LIST](state, list) { - state.userLists = state.userLists.filter((l) => l !== list); - }, - [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) { - state.isLoading = false; - state.hasError = false; - state.alerts = [].concat(error.message); - state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid); - }, [types.RECEIVE_CLEAR_ALERT](state, index) { state.alerts.splice(index, 1); }, diff --git a/app/assets/javascripts/feature_flags/store/index/state.js b/app/assets/javascripts/feature_flags/store/index/state.js index f8439b02639..488da265b28 100644 --- a/app/assets/javascripts/feature_flags/store/index/state.js +++ b/app/assets/javascripts/feature_flags/store/index/state.js @@ -1,11 +1,8 @@ -import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants'; - export default ({ endpoint, projectId, unleashApiInstanceId, rotateInstanceIdPath }) => ({ - [FEATURE_FLAG_SCOPE]: [], - [USER_LIST_SCOPE]: [], + featureFlags: [], alerts: [], - count: {}, - pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} }, + count: 0, + pageInfo: {}, isLoading: true, hasError: false, endpoint, diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index e317700b09b..35c79891458 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,6 +1,6 @@ import { __ } from '~/locale'; import AjaxFilter from '../droplab/plugins/ajax_filter'; -import { deprecatedCreateFlash as createFlash } from '../flash'; +import createFlash from '../flash'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; @@ -27,7 +27,9 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { searchValueFunction: this.getSearchInput.bind(this), loadingTemplate: this.loadingTemplate, onError() { - createFlash(__('An error occurred fetching the dropdown data.')); + createFlash({ + message: __('An error occurred fetching the dropdown data.'), + }); }, }; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index d0996c9200b..707205a6502 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -801,7 +801,7 @@ export default class FilteredSearchManager { paths.push(`search=${sanitized}`); } - let parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + let parameterizedUrl = `?scope=all&${paths.join('&')}`; if (this.anchor) { parameterizedUrl += `#${this.anchor}`; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 7a79f8f5bfc..2edb6e79d3b 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -60,7 +60,9 @@ const createFlashEl = (message, type) => ` `; const removeFlashClickListener = (flashEl, fadeTransition) => { - getCloseEl(flashEl).addEventListener('click', () => hideFlash(flashEl, fadeTransition)); + // There are some flash elements which do not have a closeEl. + // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml + getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; /* diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 5df0ac37812..893b74a9895 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,7 +1,9 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; +const isRefactoring = document.body.classList.contains('sidebar-refactoring'); const HIDE_INTERVAL_TIMEOUT = 300; +const COLLAPSED_PANEL_WIDTH = isRefactoring ? 48 : 50; const IS_OVER_CLASS = 'is-over'; const IS_ABOVE_CLASS = 'is-above'; const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; @@ -22,12 +24,7 @@ export const setOpenMenu = (menu = null) => { export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); -let headerHeight = 50; - -export const getHeaderHeight = () => headerHeight; -const setHeaderHeight = () => { - headerHeight = sidebar.offsetTop; -}; +export const getHeaderHeight = () => sidebar?.offsetTop || 0; export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains(SIDEBAR_COLLAPSED_CLASS); @@ -79,6 +76,7 @@ export const hideMenu = (el) => { el.style.display = ''; el.style.transform = ''; el.classList.remove(IS_ABOVE_CLASS); + el.classList.remove('fly-out-list'); parentEl.classList.remove(IS_OVER_CLASS); parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS); @@ -86,14 +84,20 @@ export const hideMenu = (el) => { }; export const moveSubItemsToPosition = (el, subItems) => { + const hasSubItems = subItems.parentNode.querySelector('.has-sub-items'); + const header = subItems.querySelector('.fly-out-top-item'); const boundingRect = el.getBoundingClientRect(); - const top = calculateTop(boundingRect, subItems.offsetHeight); - const left = sidebar ? sidebar.offsetWidth : 50; + const left = sidebar ? sidebar.offsetWidth : COLLAPSED_PANEL_WIDTH; + let top = calculateTop(boundingRect, subItems.offsetHeight); + if (isRefactoring && hasSubItems) { + top -= header.offsetHeight; + } else if (isRefactoring) { + top = boundingRect.top; + } const isAbove = top < boundingRect.top; subItems.classList.add('fly-out-list'); - subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - headerHeight}px, 0)`; // eslint-disable-line no-param-reassign - + subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - getHeaderHeight()}px, 0)`; // eslint-disable-line no-param-reassign const subItemsRect = subItems.getBoundingClientRect(); menuCornerLocs = [ @@ -187,8 +191,6 @@ export default () => { }); } - requestIdleCallback(setHeaderHeight); - items.forEach((el) => { const subItems = el.querySelector('.sidebar-sub-level-items'); diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index e103949b86a..dd405893e43 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -30,6 +30,11 @@ export default { type: Object, required: true, }, + searchClass: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapVuexModuleState((vm) => vm.vuexModule, [ @@ -115,7 +120,11 @@ export default { <template> <div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full"> - <frequent-items-search-input :namespace="namespace" data-testid="frequent-items-search-input" /> + <frequent-items-search-input + :namespace="namespace" + :class="searchClass" + data-testid="frequent-items-search-input" + /> <gl-loading-icon v-if="isLoadingItems" :label="translations.loadingMessage" 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 c2f77cc8bc4..d6fcdeb9e13 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 @@ -58,7 +58,7 @@ export default { <li class="frequent-items-list-item-container"> <a :href="webUrl" - class="clearfix" + class="clearfix dropdown-item" @click="track('click_link', { label: `${dropdownType}_dropdown_frequent_items_list_item` })" > <div diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue index fa14ee15cf3..4a1b7e57749 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -39,7 +39,7 @@ export default { </script> <template> - <div class="search-input-container d-none d-sm-block"> + <div class="search-input-container"> <gl-search-box-by-type v-model="searchQuery" :placeholder="translations.searchInputPlaceholder" diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index f1540ffac28..9de18ba092f 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -60,6 +60,7 @@ export default function initFrequentItemDropdowns() { namespace, currentUserName: this.currentUserName, currentItem: this.currentItem, + searchClass: 'gl-display-none gl-sm-display-block', }, }), ], diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index cde2cd6d6ab..fa6f07edfcf 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseQueryStringIntoObject } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -16,7 +16,10 @@ export default class GpgBadges { badges.html('<span class="gl-spinner gl-spinner-orange gl-spinner-sm"></span>'); badges.children().attr('aria-label', __('Loading')); - const displayError = () => createFlash(__('An error occurred while loading commit signatures')); + const displayError = () => + createFlash({ + message: __('An error occurred while loading commit signatures'), + }); const endpoint = tag.data('signaturesPath'); if (!endpoint) { diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index e941318dce0..3911201457f 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -1,5 +1,13 @@ <script> -import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon, GlLink } from '@gitlab/ui'; +import { + GlButton, + GlFormGroup, + GlFormInput, + GlFormCheckbox, + GlIcon, + GlLink, + GlSprintf, +} from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -11,6 +19,7 @@ export default { GlFormInput, GlIcon, GlLink, + GlSprintf, }, data() { return { @@ -78,13 +87,11 @@ export default { </div> <div class="settings-content"> <form> - <gl-form-checkbox - id="grafana-integration-enabled" - v-model="integrationEnabled" - class="mb-4" - > - {{ s__('GrafanaIntegration|Active') }} - </gl-form-checkbox> + <gl-form-group :label="__('Enable authentication')" label-for="grafana-integration-enabled"> + <gl-form-checkbox id="grafana-integration-enabled" v-model="integrationEnabled"> + {{ s__('GrafanaIntegration|Active') }} + </gl-form-checkbox> + </gl-form-group> <gl-form-group :label="s__('GrafanaIntegration|Grafana URL')" label-for="grafana-url" @@ -95,18 +102,27 @@ export default { <gl-form-group :label="s__('GrafanaIntegration|API token')" label-for="grafana-token"> <gl-form-input id="grafana-token" v-model="localGrafanaToken" /> <p class="form-text text-muted"> - {{ s__('GrafanaIntegration|Enter the Grafana API token.') }} - <a - href="https://grafana.com/docs/http_api/auth/#create-api-token" - target="_blank" - rel="noopener noreferrer" + <gl-sprintf + :message=" + s__('GrafanaIntegration|Enter the %{docLinkStart}Grafana API token%{docLinkEnd}.') + " > - {{ __('More information.') }} - <gl-icon name="external-link" class="vertical-align-middle" /> - </a> + <template #docLink="{ content }"> + <gl-link + href="https://grafana.com/docs/http_api/auth/#create-api-token" + target="_blank" + >{{ content }} <gl-icon name="external-link" class="gl-vertical-align-middle" + /></gl-link> + </template> + </gl-sprintf> </p> </gl-form-group> - <gl-button variant="success" category="primary" @click="updateGrafanaIntegration"> + <gl-button + variant="confirm" + category="primary" + data-testid="save-grafana-settings-button" + @click="updateGrafanaIntegration" + > {{ __('Save changes') }} </gl-button> </form> diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js index 7c5d4695731..77d2acd3393 100644 --- a/app/assets/javascripts/grafana_integration/store/actions.js +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -38,5 +38,8 @@ export const receiveGrafanaIntegrationUpdateError = (_, error) => { const { response } = error; const message = response.data && response.data.message ? response.data.message : ''; - createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); + createFlash({ + message: `${__('There was an error saving your changes.')} ${message}`, + type: 'alert', + }); }; diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 41e7ed98c78..2c771c32e16 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -11,12 +11,4 @@ fragment AlertListItem on AlertManagementAlert { title webUrl } - assignees { - nodes { - name - username - avatarUrl - webUrl - } - } } diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql index 9a9ae369519..9a9ae369519 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql index 5ee2cf7ca44..5ee2cf7ca44 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql diff --git a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql index 7a676e67f1b..095e4fe29df 100644 --- a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/alert.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" query getAlerts( $projectPath: ID! @@ -26,6 +27,11 @@ query getAlerts( ) { nodes { ...AlertListItem + assignees { + nodes { + ...User + } + } } pageInfo { hasNextPage diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql index 12b391e41ac..12b391e41ac 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index f2c608a8912..dbad2688451 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -181,7 +181,12 @@ export default { <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> </div> <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"> - <item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" /> + <item-actions + v-if="isGroup" + :group="group" + :parent-group="parentGroup" + :action="action" + /> <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center" diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 93fbbf07ae2..bd71c5ebc11 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -5,6 +5,17 @@ import Api from './api'; import { loadCSSFile } from './lib/utils/css_utils'; import { select2AxiosTransport } from './lib/utils/select2_utils'; +const groupsPath = (groupsFilter, parentGroupID) => { + switch (groupsFilter) { + case 'descendant_groups': + return Api.descendantGroupsPath.replace(':id', parentGroupID); + case 'subgroups': + return Api.subgroupsPath.replace(':id', parentGroupID); + default: + return Api.groupsPath; + } +}; + const groupsSelect = () => { loadCSSFile(gon.select2_css_path) .then(() => { @@ -16,9 +27,7 @@ const groupsSelect = () => { const allAvailable = $select.data('allAvailable'); const skipGroups = $select.data('skipGroups') || []; const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + const groupsFilter = $select.data('groupsFilter'); $select.select2({ placeholder: __('Search for a group'), @@ -26,7 +35,7 @@ const groupsSelect = () => { multiple: $select.hasClass('multiselect'), minimumInputLength: 0, ajax: { - url: Api.buildUrl(groupsPath), + url: Api.buildUrl(groupsPath(groupsFilter, parentGroupID)), dataType: 'json', quietMillis: 250, transport: select2AxiosTransport, diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 5e93b7c1bbb..ce39c796386 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -69,7 +69,7 @@ export default { class="form-control dropdown-input-field" @input="searchBranches" /> - <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes /> + <gl-icon name="search" class="gl-ml-5 gl-mt-1 input-icon" /> </label> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index cafb58b0e2c..f8dc10420d0 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal, GlButton } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __, sprintf, s__ } from '~/locale'; import { modalTypes } from '../../constants'; import { trimPathComponents, getPathParent } from '../../utils'; @@ -57,16 +57,16 @@ export default { if (this.modalType === modalTypes.rename) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { - flash( - sprintf(s__('The name "%{name}" is already taken in this directory.'), { + createFlash({ + message: sprintf(s__('The name "%{name}" is already taken in this directory.'), { name: this.entryName, }), - 'alert', - document, - null, - false, - true, - ); + type: 'alert', + parent: document, + actionConfig: null, + fadeTransition: false, + addBodyClass: true, + }); } else { let parentPath = this.entryName.split('/'); const name = parentPath.pop(); diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 6304423a3c0..4845b667b40 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -112,3 +112,5 @@ export const LIVE_PREVIEW_DEBOUNCE = 2000; // This is the maximum number of files to auto open when opening the Web IDE // from a merge request export const MAX_MR_FILES_AUTO_OPEN = 10; + +export const DEFAULT_BRANCH = 'main'; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index cb59cd7a8df..5f60bf0269d 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -16,8 +16,8 @@ Vue.use(IdeRouter); /** * Routes below /-/ide/: -/project/h5bp/html5-boilerplate/blob/master -/project/h5bp/html5-boilerplate/blob/master/app/js/test.js +/project/h5bp/html5-boilerplate/blob/main +/project/h5bp/html5-boilerplate/blob/main/app/js/test.js /project/h5bp/html5-boilerplate/mr/123 /project/h5bp/html5-boilerplate/mr/123/app/js/test.js @@ -39,7 +39,7 @@ const EmptyRouterComponent = { }, }; -export const createRouter = (store) => { +export const createRouter = (store, defaultBranch) => { const router = new IdeRouter({ mode: 'history', base: joinPaths(gon.relative_url_root || '', '/-/ide/'), @@ -58,7 +58,7 @@ export const createRouter = (store) => { }, { path: ':targetmode(edit|tree|blob)', - redirect: (to) => joinPaths(to.path, '/master/-/'), + redirect: (to) => joinPaths(to.path, `/${defaultBranch}/-/`), }, { path: 'merge_requests/:mrid', @@ -66,7 +66,7 @@ export const createRouter = (store) => { }, { path: '', - redirect: (to) => joinPaths(to.path, '/edit/master/-/'), + redirect: (to) => joinPaths(to.path, `/edit/${defaultBranch}/-/`), }, ], }, diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 7109c45a3fe..e8c726d6184 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -1,6 +1,7 @@ import { identity } from 'lodash'; import Vue from 'vue'; import { mapActions } from 'vuex'; +import { DEFAULT_BRANCH } from '~/ide/constants'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; import { parseBoolean } from '../lib/utils/common_utils'; @@ -38,7 +39,7 @@ export function initIde(el, options = {}) { const { rootComponent = ide, extendStore = identity } = options; const store = createStore(); - const router = createRouter(store); + const router = createRouter(store, el.dataset.defaultBranch || DEFAULT_BRANCH); return new Vue({ el, diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 74423cd7376..5e020f16104 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants'; import service from '../../services'; @@ -34,14 +34,14 @@ export const getMergeRequestsForBranch = ( } }) .catch((e) => { - flash( - __(`Error fetching merge requests for ${branchId}`), - 'alert', - document, - null, - false, - true, - ); + createFlash({ + message: __(`Error fetching merge requests for ${branchId}`), + type: 'alert', + parent: document, + actionConfig: null, + fadeTransition: false, + addBodyClass: true, + }); throw e; }); }; @@ -236,7 +236,7 @@ export const openMergeRequest = async ( await dispatch('openMergeRequestChanges', changes); } catch (e) { - flash(__('Error while loading the merge request. Please try again.')); + createFlash({ message: __('Error while loading the merge request. Please try again.') }); throw e; } }; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js index 6c9be6d10c9..82d9300d30b 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import * as terminalService from '../../../../services/terminals'; @@ -26,7 +26,7 @@ export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => { }; export const receiveStartSessionError = ({ dispatch }) => { - flash(messages.UNEXPECTED_ERROR_STARTING); + createFlash({ message: messages.UNEXPECTED_ERROR_STARTING }); dispatch('killSession'); }; @@ -59,7 +59,7 @@ export const receiveStopSessionSuccess = ({ dispatch }) => { }; export const receiveStopSessionError = ({ dispatch }) => { - flash(messages.UNEXPECTED_ERROR_STOPPING); + createFlash({ message: messages.UNEXPECTED_ERROR_STOPPING }); dispatch('killSession'); }; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js index da10894c2c6..7fe1a8cc2df 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as messages from '../messages'; import * as types from '../mutation_types'; @@ -42,7 +42,7 @@ export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => { }; export const receiveSessionStatusError = ({ dispatch }) => { - flash(messages.UNEXPECTED_ERROR_STATUS); + createFlash({ message: messages.UNEXPECTED_ERROR_STATUS }); dispatch('killSession'); }; diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 63c53737119..275fecc5a32 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -146,7 +146,7 @@ export function getFileEOL(content = '') { * hello.md -> hello-1.md * hello_2.md -> hello_3.md * hello_ -> hello_1 - * master-patch-22432 -> master-patch-22433 + * main-patch-22432 -> main-patch-22433 * patch_332 -> patch_333 * * @param {string} filename File name or branch name diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue index 60cd5bb0a96..63c18f4d78e 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -15,7 +15,7 @@ import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql'; import removeValidationErrorMutation from '../graphql/mutations/remove_validation_error.mutation.graphql'; -import groupQuery from '../graphql/queries/group.query.graphql'; +import groupAndProjectQuery from '../graphql/queries/groupAndProject.query.graphql'; const DEBOUNCE_INTERVAL = 300; @@ -47,21 +47,21 @@ export default { }, apollo: { - existingGroup: { - query: groupQuery, + existingGroupAndProject: { + query: groupAndProjectQuery, debounce: DEBOUNCE_INTERVAL, variables() { return { fullPath: this.fullPath, }; }, - update({ existingGroup }) { + update({ existingGroup, existingProject }) { const variables = { field: 'new_name', sourceGroupId: this.group.id, }; - if (!existingGroup) { + if (!existingGroup && !existingProject) { this.$apollo.mutate({ mutation: removeValidationErrorMutation, variables, @@ -71,7 +71,7 @@ export default { mutation: addValidationErrorMutation, variables: { ...variables, - message: s__('BulkImport|Name already exists.'), + message: this.$options.i18n.NAME_ALREADY_EXISTS, }, }); } @@ -115,6 +115,10 @@ export default { return joinPaths(gon.relative_url_root || '/', this.fullPath); }, }, + + i18n: { + NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), + }, }; </script> @@ -153,7 +157,7 @@ export default { :text="importTarget.target_namespace" :disabled="isAlreadyImported" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1" + class="import-entities-namespace-dropdown gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" > <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ @@ -180,7 +184,7 @@ export default { > / </div> - <div class="gl-flex-fill-1"> + <div class="gl-flex-grow-1"> <gl-form-input class="gl-rounded-top-left-none gl-rounded-bottom-left-none" :class="{ 'is-invalid': isInvalid && !isAlreadyImported }" diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql deleted file mode 100644 index 52df3581ac4..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query group($fullPath: ID!) { - existingGroup: group(fullPath: $fullPath) { - id - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql new file mode 100644 index 00000000000..d6124f84025 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql @@ -0,0 +1,9 @@ +query groupAndProject($fullPath: ID!) { + existingGroup: group(fullPath: $fullPath) { + id + } + + existingProject: project(fullPath: $fullPath) { + id + } +} diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index 33f8dbb8737..5cbc6e85bf3 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -75,19 +75,19 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) if (hasRedirectInError(e)) { redirectToUrlInError(e); } else if (tooManyRequests(e)) { - createFlash( - sprintf(s__('ImportProjects|%{provider} rate limit exceeded. Try again later'), { + createFlash({ + message: sprintf(s__('ImportProjects|%{provider} rate limit exceeded. Try again later'), { provider: capitalizeFirstCharacter(provider), }), - ); + }); commit(types.RECEIVE_REPOS_ERROR); } else { - createFlash( - sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { + createFlash({ + message: sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { provider, }), - ); + }); commit(types.RECEIVE_REPOS_ERROR); } @@ -126,7 +126,9 @@ const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, gett ) : s__('ImportProjects|Importing the project failed'); - createFlash(flashMessage); + createFlash({ + message: flashMessage, + }); commit(types.RECEIVE_IMPORT_ERROR, repoId); }); @@ -149,7 +151,9 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d if (hasRedirectInError(e)) { redirectToUrlInError(e); } else { - createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')); + createFlash({ + message: s__('ImportProjects|Update of imported projects with realtime changes failed'), + }); } }, }); @@ -175,7 +179,9 @@ const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) = commit(types.RECEIVE_NAMESPACES_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), ) .catch(() => { - createFlash(s__('ImportProjects|Requesting namespaces failed')); + createFlash({ + message: s__('ImportProjects|Requesting namespaces failed'), + }); commit(types.RECEIVE_NAMESPACES_ERROR); }); diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index af99341b11f..4d34daa43ba 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -10,6 +10,7 @@ import { GlIcon, GlEmptyState, } from '@gitlab/ui'; +import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; @@ -287,6 +288,7 @@ export default { errorAlertDismissed() { this.isErrorAlertDismissed = true; }, + isValidSlaDueAt, }, }; </script> @@ -367,7 +369,13 @@ export default { </template> <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> - <service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" /> + <service-level-agreement-cell + v-if="isValidSlaDueAt(item.slaDueAt)" + :issue-iid="item.iid" + :project-path="projectPath" + :sla-due-at="item.slaDueAt" + data-testid="incident-sla" + /> </template> <template #cell(assignees)="{ item }"> diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue index 0746725153d..af4905deef4 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -1,7 +1,6 @@ <script> import { GlButton, GlTabs, GlTab } from '@gitlab/ui'; import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants'; -import AlertsSettingsForm from './alerts_form.vue'; import PagerDutySettingsForm from './pagerduty_form.vue'; export default { @@ -9,11 +8,15 @@ export default { GlButton, GlTabs, GlTab, - AlertsSettingsForm, PagerDutySettingsForm, ServiceLevelAgreementForm: () => import('ee_component/incidents_settings/components/service_level_agreement_form.vue'), }, + computed: { + activeTabs() { + return this.$options.tabs.filter((tab) => tab.active); + }, + }, tabs: INTEGRATION_TABS_CONFIG, i18n: I18N_INTEGRATION_TABS, }; @@ -23,7 +26,7 @@ export default { <section id="incident-management-settings" data-qa-selector="incidents_settings_content" - class="settings no-animate qa-incident-management-settings" + class="settings no-animate" > <div class="settings-header"> <h4 @@ -42,15 +45,14 @@ export default { <div class="settings-content"> <gl-tabs> + <service-level-agreement-form /> <gl-tab - v-for="(tab, index) in $options.tabs" - v-if="tab.active" + v-for="(tab, index) in activeTabs" :key="`${tab.title}_${index}`" :title="tab.title" > <component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" /> </gl-tab> - <service-level-agreement-form /> </gl-tabs> </div> </section> diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue index b56dd66342a..866d2ff399e 100644 --- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -11,7 +11,6 @@ import { GlModal, GlModalDirective, } from '@gitlab/ui'; -import { isEqual } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants'; @@ -50,14 +49,8 @@ export default { pagerduty_active: this.active, }; }, - isFormUpdated() { - return isEqual(this.pagerDutySettings, { - active: this.active, - webhookUrl: this.webhookUrl, - }); - }, isSaveDisabled() { - return this.isFormUpdated || this.loading || this.resettingWebhook; + return this.loading || this.resettingWebhook; }, webhookUpdateAlertMsg() { return this.webhookUpdateFailed @@ -123,13 +116,15 @@ export default { </template> </gl-sprintf> </p> - <form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings"> + <form ref="settingsForm"> <gl-form-group class="col-8 col-md-9 gl-p-0"> <gl-toggle id="active" v-model="active" + :disabled="isSaveDisabled" :is-loading="loading" :label="$options.i18n.activeToggle.label" + @change="updatePagerDutyIntegrationSettings" /> </gl-form-group> @@ -166,15 +161,6 @@ export default { {{ $options.i18n.webhookUrl.restKeyInfo }} </gl-modal> </gl-form-group> - <gl-button - ref="submitBtn" - :disabled="isSaveDisabled" - variant="success" - type="submit" - class="js-no-auto-disable" - > - {{ $options.i18n.saveBtnLabel }} - </gl-button> </form> </div> </template> diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js index d479838b491..54e4bab6c10 100644 --- a/app/assets/javascripts/incidents_settings/constants.js +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -3,11 +3,6 @@ import { __, s__ } from '~/locale'; /* Integration tabs constants */ export const INTEGRATION_TABS_CONFIG = [ { - title: s__('IncidentSettings|Alert integration'), - component: 'AlertsSettingsForm', - active: true, - }, - { title: s__('IncidentSettings|PagerDuty integration'), component: 'PagerDutySettingsForm', active: true, @@ -23,38 +18,10 @@ export const I18N_INTEGRATION_TABS = { headerText: s__('IncidentSettings|Incidents'), expandBtnLabel: __('Expand'), subHeaderText: s__( - 'IncidentSettings|Set up integrations with external tools to help better manage incidents.', + 'IncidentSettings|Fine-tune incident settings and set up integrations with external tools to help better manage incidents.', ), }; -/* Alerts integration settings constants */ - -export const I18N_ALERT_SETTINGS_FORM = { - saveBtnLabel: __('Save changes'), - introText: __('Action to take when receiving an alert. %{docsLink}'), - introLinkText: __('More information.'), - createIncident: { - label: __('Create an incident. Incidents are created for each alert triggered.'), - }, - incidentTemplate: { - label: __('Incident template (optional)'), - }, - sendEmail: { - label: __('Send a single email notification to Owners and Maintainers for new alerts.'), - }, - autoCloseIncidents: { - label: __( - 'Automatically close associated incident when a recovery alert notification resolves an alert', - ), - }, -}; - -export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') }; -export const TAKING_INCIDENT_ACTION_DOCS_LINK = - '/help/operations/metrics/alerts#trigger-actions-from-alerts'; -export const ISSUE_TEMPLATES_DOCS_LINK = - '/help/user/project/description_templates#create-an-issue-template'; - /* PagerDuty integration settings constants */ export const I18N_PAGERDUTY_SETTINGS_FORM = { diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js index 82b94c08381..83fd29a058e 100644 --- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { ERROR_MSG } from './constants'; @@ -22,7 +22,10 @@ export default class IncidentsSettingsService { .catch(({ response }) => { const message = response?.data?.message || ''; - createFlash(`${ERROR_MSG} ${message}`, 'alert'); + createFlash({ + message: `${ERROR_MSG} ${message}`, + type: 'alert', + }); }); } diff --git a/app/assets/javascripts/incidents_settings/index.js b/app/assets/javascripts/incidents_settings/index.js index e9ba4294519..62c48a40026 100644 --- a/app/assets/javascripts/incidents_settings/index.js +++ b/app/assets/javascripts/incidents_settings/index.js @@ -13,14 +13,9 @@ export default () => { const { dataset: { operationsSettingsEndpoint, - templates, - createIssue, - issueTemplateKey, - sendEmail, pagerdutyActive, pagerdutyWebhookUrl, pagerdutyResetKeyPath, - autoCloseIncident, slaActive, slaMinutes, slaFeatureAvailable, @@ -32,13 +27,6 @@ export default () => { el, provide: { service, - alertSettings: { - templates: JSON.parse(templates), - createIssue: parseBoolean(createIssue), - issueTemplateKey, - sendEmail: parseBoolean(sendEmail), - autoCloseIncident: parseBoolean(autoCloseIncident), - }, pagerDutySettings: { active: parseBoolean(pagerdutyActive), webhookUrl: pagerdutyWebhookUrl, diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 9bc01cdd9fc..ec93980251b 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,5 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -70,6 +71,7 @@ export default { }; }, computed: { + ...mapGetters(['isInheriting']), validProjectKey() { return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; }, @@ -116,7 +118,7 @@ export default { </p> <template v-if="showJiraIssuesIntegration"> <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> - <gl-form-checkbox v-model="enableJiraIssues"> + <gl-form-checkbox v-model="enableJiraIssues" :disabled="isInheriting"> {{ s__('JiraService|Enable Jira issues') }} <template #help> {{ @@ -161,6 +163,7 @@ export default { :required="enableJiraIssues" :state="validProjectKey" :disabled="!enableJiraIssues" + :readonly="isInheriting" /> </gl-form-group> <p v-if="gitlabIssuesEnabled"> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 93d8bcc4c19..11e9b25f9a3 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -37,7 +37,7 @@ const issueTransitionOptions = [ help: s__( 'JiraService|Automatically transitions Jira issues to the "Done" category. %{linkStart}Learn more%{linkEnd}', ), - link: helpPagePath('user/project/integrations/jira.html', { + link: helpPagePath('integration/jira/index.html', { anchor: 'automatic-issue-transitions', }), }, @@ -47,7 +47,7 @@ const issueTransitionOptions = [ help: s__( 'JiraService|Set a custom final state by using transition IDs. %{linkStart}Learn about transition IDs%{linkEnd}', ), - link: helpPagePath('user/project/integrations/jira.html', { + link: helpPagePath('integration/jira/index.html', { anchor: 'custom-issue-transitions', }), }, diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 42bc9e4c8a1..433fe21ad76 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -10,7 +10,7 @@ const typeWithPlaceholder = { }; const placeholderForType = { - [typeWithPlaceholder.SLACK]: __('general, development'), + [typeWithPlaceholder.SLACK]: __('#general, #development'), [typeWithPlaceholder.MATTERMOST]: __('my-channel'), }; diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 4a72e97db8c..2d1e57a1177 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -1,13 +1,20 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlAvatarLabeled, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, +} from '@gitlab/ui'; import { debounce } from 'lodash'; -import Api from '~/api'; import { s__ } from '~/locale'; -import { SEARCH_DELAY } from '../constants'; +import { getGroups, getDescendentGroups } from '~/rest_api'; +import { SEARCH_DELAY, GROUP_FILTERS } from '../constants'; export default { name: 'GroupSelect', components: { + GlAvatarLabeled, GlDropdown, GlDropdownItem, GlDropdownText, @@ -16,6 +23,18 @@ export default { model: { prop: 'selectedGroup', }, + props: { + groupsFilter: { + type: String, + required: false, + default: GROUP_FILTERS.ALL, + }, + parentGroupId: { + type: Number, + required: false, + default: null, + }, + }, data() { return { isFetching: false, @@ -43,12 +62,13 @@ export default { methods: { retrieveGroups: debounce(function debouncedRetrieveGroups() { this.isFetching = true; - return Api.groups(this.searchTerm, this.$options.defaultFetchOptions) + return this.fetchGroups() .then((response) => { this.groups = response.map((group) => ({ id: group.id, name: group.full_name, path: group.path, + avatarUrl: group.avatar_url, })); this.isFetching = false; }) @@ -61,6 +81,18 @@ export default { this.$emit('input', this.selectedGroup); }, + fetchGroups() { + switch (this.groupsFilter) { + case GROUP_FILTERS.DESCENDANT_GROUPS: + return getDescendentGroups( + this.parentGroupId, + this.searchTerm, + this.$options.defaultFetchOptions, + ); + default: + return getGroups(this.searchTerm, this.$options.defaultFetchOptions); + } + }, }, i18n: { dropdownText: s__('GroupSelect|Select a group'), @@ -82,7 +114,7 @@ export default { menu-class="gl-w-full!" > <gl-search-box-by-type - v-model.trim="searchTerm" + v-model="searchTerm" :is-loading="isFetching" :placeholder="$options.i18n.searchPlaceholder" data-qa-selector="group_select_dropdown_search_field" @@ -93,7 +125,13 @@ export default { :name="group.name" @click="selectGroup(group)" > - {{ group.name }} + <gl-avatar-labeled + :label="group.name" + :src="group.avatarUrl" + :entity-id="group.id" + :entity-name="group.name" + :size="32" + /> </gl-dropdown-item> <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> 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 d00a0f1633b..84c8594c6b6 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -16,7 +16,7 @@ import GroupSelect from '~/invite_members/components/group_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; -import { INVITE_MEMBERS_IN_COMMENT } from '../constants'; +import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants'; import eventHub from '../event_hub'; export default { @@ -54,6 +54,16 @@ export default { type: Number, required: true, }, + groupSelectFilter: { + type: String, + required: false, + default: GROUP_FILTERS.ALL, + }, + groupSelectParentId: { + type: Number, + required: false, + default: null, + }, helpLink: { type: String, required: true, @@ -68,6 +78,7 @@ export default { newUsersToInvite: [], selectedDate: undefined, groupToBeSharedWith: {}, + source: 'unknown', }; }, computed: { @@ -195,6 +206,7 @@ export default { ...this.basePostData, email: usersToInviteByEmail, access_level: this.selectedAccessLevel, + invite_source: this.source, }; }, addByUserIdPostData(usersToAddById) { @@ -202,6 +214,7 @@ export default { ...this.basePostData, user_id: usersToAddById, access_level: this.selectedAccessLevel, + invite_source: this.source, }; }, shareWithGroupPostData(groupToBeSharedWith) { @@ -251,11 +264,11 @@ export default { ), }, }, - accessLevel: s__('InviteMembersModal|Choose a role permission'), + accessLevel: s__('InviteMembersModal|Select a role'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'), - readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), + readMoreText: s__(`InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles.`), inviteButtonText: s__('InviteMembersModal|Invite'), cancelButtonText: s__('InviteMembersModal|Cancel'), headerCloseLabel: s__('InviteMembersModal|Close invite team members'), @@ -290,7 +303,12 @@ export default { :aria-labelledby="$options.membersTokenSelectLabelId" :placeholder="$options.labels[inviteeType].placeHolder" /> - <group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" /> + <group-select + v-if="isInviteGroup" + v-model="groupToBeSharedWith" + :groups-filter="groupSelectFilter" + :parent-group-id="groupSelectParentId" + /> </div> <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label> diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index e297bb6c806..ec7d466336e 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -29,8 +29,7 @@ export default { }, triggerSource: { type: String, - required: false, - default: 'unknown', + required: true, }, trackExperiment: { type: String, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index a651b81c60e..0c5538d5b86 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,3 +1,8 @@ export const SEARCH_DELAY = 200; export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; + +export const GROUP_FILTERS = { + ALL: 'all', + DESCENDANT_GROUPS: 'descendant_groups', +}; 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 fc77bd53ba4..7501e9f4e6e 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -21,6 +21,8 @@ export default function initInviteMembersModal() { isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), + groupSelectFilter: el.dataset.groupsFilter, + groupSelectParentId: parseInt(el.dataset.parentId, 10), }, }), }); diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js index a7b95960995..935edb35349 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_trigger.js +++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js @@ -2,19 +2,21 @@ import Vue from 'vue'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; export default function initInviteMembersTrigger() { - const el = document.querySelector('.js-invite-members-trigger'); + const triggers = document.querySelectorAll('.js-invite-members-trigger'); - if (!el) { + if (!triggers) { return false; } - return new Vue({ - el, - render: (createElement) => - createElement(InviteMembersTrigger, { - props: { - ...el.dataset, - }, - }), + return triggers.forEach((el) => { + return new Vue({ + el, + render: (createElement) => + createElement(InviteMembersTrigger, { + props: { + ...el.dataset, + }, + }), + }); }); } diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue index f17440a4a14..5c880cbfad8 100644 --- a/app/assets/javascripts/issuable/components/csv_export_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -51,7 +51,6 @@ export default { </template> <div v-if="issuableCount > -1" - data-testid="issuable-count-note" class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50" > <gl-icon name="check" class="gl-color-green-400" /> diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index fb4d5aca2f5..4fdd094072c 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -72,9 +72,6 @@ export default { importModalId() { return `${this.issuableType}-import-modal`; }, - importButtonText() { - return this.showLabel ? this.$options.i18n.importIssuesText : null; - }, importButtonTooltipText() { return this.showLabel ? null : this.$options.i18n.importIssuesText; }, @@ -87,32 +84,28 @@ export default { <template> <div :class="containerClass"> - <gl-button-group> + <gl-button-group class="gl-w-full"> <gl-button v-if="showExportButton" - v-gl-tooltip.hover="$options.i18n.exportAsCsvButtonText" + v-gl-tooltip="$options.i18n.exportAsCsvButtonText" v-gl-modal="exportModalId" icon="export" :aria-label="$options.i18n.exportAsCsvButtonText" data-qa-selector="export_as_csv_button" - data-testid="export-csv-button" /> <gl-dropdown v-if="showImportButton" - v-gl-tooltip.hover="importButtonTooltipText" + v-gl-tooltip="importButtonTooltipText" data-qa-selector="import_issues_dropdown" - data-testid="import-csv-dropdown" - :text="importButtonText" + :text="$options.i18n.importIssuesText" + :text-sr-only="!showLabel" :icon="importButtonIcon" > - <gl-dropdown-item v-gl-modal="importModalId" data-testid="import-csv-link">{{ - __('Import CSV') - }}</gl-dropdown-item> + <gl-dropdown-item v-gl-modal="importModalId">{{ __('Import CSV') }}</gl-dropdown-item> <gl-dropdown-item v-if="canEdit" :href="projectImportJiraPath" data-qa-selector="import_from_jira_link" - data-testid="import-from-jira-link" >{{ __('Import from Jira') }}</gl-dropdown-item > </gl-dropdown> diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue index 77fc2f31583..c85efd60b8b 100644 --- a/app/assets/javascripts/issuable/components/csv_import_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue @@ -48,13 +48,7 @@ export default { <template> <gl-modal :modal-id="modalId" :title="__('Import issues')"> - <form - ref="form" - :action="importCsvIssuesPath" - enctype="multipart/form-data" - method="post" - data-testid="import-csv-form" - > + <form ref="form" :action="importCsvIssuesPath" enctype="multipart/form-data" method="post"> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <p> {{ diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index d0ce8c2c34b..c659dfef495 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -91,7 +91,7 @@ export default { <template> <div> - <gl-button v-gl-modal="$options.modalId" variant="link" data-testid="issuable-email-modal-btn" + <gl-button v-gl-modal="$options.modalId" variant="link" ><gl-sprintf :message="__('Email a new %{name} to this project')" ><template #name>{{ issuableName }}</template></gl-sprintf ></gl-button @@ -122,7 +122,6 @@ export default { :title="$options.i18n.sendEmail" :aria-label="$options.i18n.sendEmail" icon="mail" - data-testid="mail-to-btn" /> </template> </gl-form-input-group> @@ -146,22 +145,23 @@ export default { <gl-sprintf :message=" __( - 'This is a private email address %{helpIcon} generated just for you. Anyone who gets ahold of it can create issues or merge requests as if they were you. You should %{resetLinkStart}reset it%{resetLinkEnd} if that ever happens.', + 'This is a private email address %{helpIcon} generated just for you. Anyone who has it can create issues or merge requests as if they were you. If that happens, %{resetLinkStart}reset this token%{resetLinkEnd}.', ) " > <template #helpIcon> - <gl-link :href="emailsHelpPagePath" target="_blank" - ><gl-icon class="gl-text-blue-600" name="question-o" - /></gl-link> + <gl-link :href="emailsHelpPagePath" target="_blank"> + <gl-icon class="gl-text-blue-600" name="question-o" /> + </gl-link> </template> <template #resetLink="{ content }"> <gl-button variant="link" - data-testid="incoming-email-token-reset" + data-testid="reset_email_token_link" @click="resetIncomingEmailToken" - >{{ content }}</gl-button > + {{ content }} + </gl-button> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue index cb768f2bc5b..bd6fdc131cb 100644 --- a/app/assets/javascripts/issuable/components/status_box.vue +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -91,11 +91,7 @@ export default { <template> <div :class="statusBoxClass" class="issuable-status-box status-box"> - <gl-icon - :name="statusIconName" - class="gl-display-block gl-sm-display-none!" - data-testid="status-icon" - /> + <gl-icon :name="statusIconName" class="gl-display-block gl-sm-display-none!" /> <span class="gl-display-none gl-sm-display-block"> {{ statusHumanName }} </span> diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 9a1ab23e366..fd9e3d5c916 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -31,7 +31,7 @@ function organizeQuery(obj, isFallbackKey = false) { } function format(searchTerm, isFallbackKey = false) { - const queryObject = queryToObject(searchTerm); + const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true }); const organizeQueryObject = organizeQuery(queryObject, isFallbackKey); const formattedQuery = objectToQuery(organizeQueryObject); diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 7635536c54f..348dc054f57 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -183,8 +183,8 @@ export default { :title="__('Confidential')" :aria-label="__('Confidential')" /> - <gl-link :href="webUrl" v-bind="issuableTitleProps" - >{{ issuable.title + <gl-link :href="webUrl" v-bind="issuableTitleProps"> + {{ issuable.title }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /></gl-link> </span> diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index dfe158ae2b0..977d03e62be 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -36,7 +36,7 @@ export default { <template> <div class="top-area"> <gl-tabs - class="gl-display-flex gl-flex-fill-1 gl-p-0 gl-m-0 mobile-separator issuable-state-filters" + class="gl-display-flex gl-flex-grow-1 gl-p-0 gl-m-0 mobile-separator issuable-state-filters" nav-class="gl-border-b-0" > <gl-tab diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 01b4e81a11a..b7e24a8b17e 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,12 +1,20 @@ <script> import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; -import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants'; +import { + IssuableStatus, + IssuableStatusText, + IssuableType, + IssueTypePath, + IncidentTypePath, + IncidentType, +} from '../constants'; 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'; @@ -195,8 +203,14 @@ export default { showForm: false, templatesRequested: false, isStickyHeaderShowing: false, + issueState: {}, }; }, + apollo: { + issueState: { + query: getIssueStateQuery, + }, + }, computed: { issuableTemplates() { return this.store.formState.issuableTemplates; @@ -288,7 +302,7 @@ export default { methods: { handleBeforeUnloadEvent(e) { const event = e; - if (this.showForm && this.issueChanged) { + if (this.showForm && this.issueChanged && !this.issueState.isDirty) { event.returnValue = __('Are you sure you want to lose your issue information?'); } return undefined; @@ -302,7 +316,9 @@ export default { this.store.updateState(data); }) .catch(() => { - createFlash(this.defaultErrorMessage); + createFlash({ + message: this.defaultErrorMessage, + }); }); }, @@ -327,7 +343,9 @@ export default { this.updateAndShowForm(res.data); }) .catch(() => { - createFlash(this.defaultErrorMessage); + createFlash({ + message: this.defaultErrorMessage, + }); this.updateAndShowForm(); }); }, @@ -346,14 +364,32 @@ export default { }, updateIssuable() { + const { + store: { formState }, + issueState, + } = this; + const issuablePayload = issueState.isDirty + ? { ...formState, issue_type: issueState.issueType } + : formState; this.clearFlash(); return this.service - .updateIssuable(this.store.formState) + .updateIssuable(issuablePayload) .then((res) => res.data) .then((data) => { - if (!window.location.pathname.includes(data.web_url)) { + if ( + !window.location.pathname.includes(data.web_url) && + issueState.issueType !== IncidentType + ) { visitUrl(data.web_url); } + + if (issueState.isDirty) { + const URI = + issueState.issueType === IncidentType + ? data.web_url.replace(IssueTypePath, IncidentTypePath) + : data.web_url; + visitUrl(URI); + } }) .then(this.updateStoreState) .then(() => { @@ -374,7 +410,9 @@ export default { errMsg += `. ${message}`; } - this.flashContainer = createFlash(errMsg); + this.flashContainer = createFlash({ + message: errMsg, + }); }); }, @@ -389,9 +427,11 @@ export default { visitUrl(data.web_url); }) .catch(() => { - createFlash( - sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }), - ); + createFlash({ + message: sprintf(s__('Error deleting %{issuableType}'), { + issuableType: this.issuableType, + }), + }); }); }, diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 68bc6fe4c0e..0812392f804 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,7 +1,7 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { s__, sprintf } from '~/locale'; import TaskList from '../../task_list'; import animateMixin from '../mixins/animate'; @@ -92,8 +92,8 @@ export default { }, taskListUpdateError() { - createFlash( - sprintf( + createFlash({ + message: sprintf( s__( 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', ), @@ -101,7 +101,7 @@ export default { issueType: this.issuableType, }, ), - ); + }); this.$emit('taskListUpdateFailed'); }, diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index 7733e366c4f..5b7d232fde7 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -1,17 +1,24 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; import { __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import updateMixin from '../mixins/update'; +import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; const issuableTypes = { issue: __('Issue'), epic: __('Epic'), + incident: __('Incident'), }; export default { components: { GlButton, + GlModal, + }, + directives: { + GlModal: GlModalDirective, }, mixins: [updateMixin], props: { @@ -36,19 +43,56 @@ export default { data() { return { deleteLoading: false, + skipApollo: false, + issueState: {}, + modalId: uniqueId('delete-issuable-modal-'), }; }, + apollo: { + issueState: { + query: getIssueStateQuery, + skip() { + return this.skipApollo; + }, + result() { + this.skipApollo = true; + }, + }, + }, computed: { + deleteIssuableButtonText() { + return sprintf(__('Delete %{issuableType}'), { + issuableType: this.typeToShow.toLowerCase(), + }); + }, + deleteIssuableModalText() { + return this.issuableType === 'epic' + ? __('Delete this epic and all descendants?') + : sprintf(__('%{issuableType} will be removed! Are you sure?'), { + issuableType: this.typeToShow, + }); + }, isSubmitEnabled() { return this.formState.title.trim() !== ''; }, + modalActionProps() { + return { + primary: { + text: this.deleteIssuableButtonText, + attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }], + }, + cancel: { + text: __('Cancel'), + }, + }; + }, shouldShowDeleteButton() { return this.canDestroy && this.showDeleteButton; }, - deleteIssuableButtonText() { - return sprintf(__('Delete %{issuableType}'), { - issuableType: issuableTypes[this.issuableType].toLowerCase(), - }); + typeToShow() { + const { issueState, issuableType } = this; + const type = issueState.issueType ?? issuableType; + return issuableTypes[type]; }, }, methods: { @@ -56,49 +100,57 @@ export default { eventHub.$emit('close.form'); }, deleteIssuable() { - const confirmMessage = - this.issuableType === 'epic' - ? __('Delete this epic and all descendants?') - : sprintf(__('%{issuableType} will be removed! Are you sure?'), { - issuableType: issuableTypes[this.issuableType], - }); - // eslint-disable-next-line no-alert - if (window.confirm(confirmMessage)) { - this.deleteLoading = true; - - eventHub.$emit('delete.issuable', { destroy_confirm: true }); - } + this.deleteLoading = true; + eventHub.$emit('delete.issuable', { destroy_confirm: true }); }, }, }; </script> <template> - <div class="gl-mt-3 gl-mb-3 clearfix"> - <gl-button - :loading="formState.updateLoading" - :disabled="formState.updateLoading || !isSubmitEnabled" - category="primary" - variant="confirm" - class="float-left qa-save-button gl-mr-3" - type="submit" - @click.prevent="updateIssuable" - > - {{ __('Save changes') }} - </gl-button> - <gl-button @click="closeForm"> - {{ __('Cancel') }} - </gl-button> - <gl-button - v-if="shouldShowDeleteButton" - :loading="deleteLoading" - :disabled="deleteLoading" - category="secondary" - variant="danger" - class="float-right qa-delete-button" - @click="deleteIssuable" - > - {{ deleteIssuableButtonText }} - </gl-button> + <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="qa-save-button 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" + class="qa-delete-button" + data-testid="issuable-delete-button" + > + {{ deleteIssuableButtonText }} + </gl-button> + <gl-modal + ref="removeModal" + :modal-id="modalId" + size="sm" + :action-primary="modalActionProps.primary" + :action-cancel="modalActionProps.cancel" + @primary="deleteIssuable" + > + <template #modal-title>{{ deleteIssuableButtonText }}</template> + <div> + <p class="gl-mb-1">{{ deleteIssuableModalText }}</p> + </div> + </gl-modal> + </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 14df87e486b..9bfdbb41e23 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -54,14 +54,14 @@ export default { <template> <!-- eslint-disable @gitlab/vue-no-data-toggle --> - <div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues"> + <div class="dropdown js-issuable-selector-wrap gl-mb-0" data-issuable-type="issues"> <button ref="toggle" :data-namespace-path="projectNamespace" :data-project-path="projectPath" :data-project-id="projectId" :data-data="issuableTemplatesJson" - class="dropdown-menu-toggle js-issuable-selector" + class="dropdown-menu-toggle js-issuable-selector gl-button" type="button" data-field-name="issuable_template" data-selected="null" diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index d331fb47077..a73926575d0 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -20,7 +20,7 @@ export default { id="issuable-title" ref="input" v-model="formState.title" - class="form-control qa-title-input" + class="form-control qa-title-input gl-border-gray-200" dir="auto" type="text" :placeholder="__('Title')" diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issue_show/components/fields/type.vue new file mode 100644 index 00000000000..1ed222531f4 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/type.vue @@ -0,0 +1,79 @@ +<script> +import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { capitalize } from 'lodash'; +import { __ } from '~/locale'; +import { IssuableTypes } from '../../constants'; +import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; +import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql'; + +export const i18n = { + label: __('Issue Type'), +}; + +export default { + i18n, + IssuableTypes, + components: { + GlFormGroup, + GlDropdown, + GlDropdownItem, + }, + data() { + return { + issueState: {}, + }; + }, + apollo: { + issueState: { + query: getIssueStateQuery, + }, + }, + computed: { + dropdownText() { + const { + issueState: { issueType }, + } = this; + return capitalize(issueType); + }, + }, + methods: { + updateIssueType(issueType) { + this.$apollo.mutate({ + mutation: updateIssueStateMutation, + variables: { + issueType, + isDirty: true, + }, + }); + }, + }, +}; +</script> + +<template> + <gl-form-group + :label="$options.i18n.label" + label-class="sr-only" + label-for="issuable-type" + class="mb-2 mb-md-0" + > + <gl-dropdown + id="issuable-type" + :aria-labelledby="$options.i18n.label" + :text="dropdownText" + :header-text="$options.i18n.label" + class="gl-w-full" + toggle-class="dropdown-menu-toggle" + > + <gl-dropdown-item + v-for="type in $options.IssuableTypes" + :key="type.value" + :is-checked="issueState.issueType === type.value" + is-check-item + @click="updateIssueType(type.value)" + > + {{ type.text }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index b37a911a669..bdaa8a4dd6b 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -2,21 +2,24 @@ import { GlAlert } from '@gitlab/ui'; import $ from 'jquery'; import Autosave from '~/autosave'; +import { IssuableType } from '~/issue_show/constants'; import eventHub from '../event_hub'; -import editActions from './edit_actions.vue'; -import descriptionField from './fields/description.vue'; -import descriptionTemplate from './fields/description_template.vue'; -import titleField from './fields/title.vue'; -import lockedWarning from './locked_warning.vue'; +import EditActions from './edit_actions.vue'; +import DescriptionField from './fields/description.vue'; +import DescriptionTemplateField from './fields/description_template.vue'; +import IssuableTitleField from './fields/title.vue'; +import IssuableTypeField from './fields/type.vue'; +import LockedWarning from './locked_warning.vue'; export default { components: { - lockedWarning, - titleField, - descriptionField, - descriptionTemplate, - editActions, + DescriptionField, + DescriptionTemplateField, + EditActions, GlAlert, + IssuableTitleField, + IssuableTypeField, + LockedWarning, }, props: { canDestroy: { @@ -89,6 +92,9 @@ export default { showLockedWarning() { return this.formState.lockedWarningVisible && !this.formState.updateLoading; }, + isIssueType() { + return this.issuableType === IssuableType.Issue; + }, }, created() { eventHub.$on('delete.issuable', this.resetAutosave); @@ -162,7 +168,7 @@ export default { </script> <template> - <form> + <form data-testid="issuable-form"> <locked-warning v-if="showLockedWarning" /> <gl-alert v-if="showOutdatedDescriptionWarning" @@ -179,9 +185,17 @@ export default { ) }}</gl-alert > + <div class="row gl-mb-3"> + <div class="col-12"> + <issuable-title-field ref="title" :form-state="formState" /> + </div> + </div> <div class="row"> - <div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3"> - <description-template + <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0"> + <issuable-type-field ref="issue-type" /> + </div> + <div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2"> + <description-template-field :form-state="formState" :issuable-templates="issuableTemplates" :project-path="projectPath" @@ -189,14 +203,6 @@ export default { :project-namespace="projectNamespace" /> </div> - <div - :class="{ - 'col-sm-8 col-lg-9': hasIssuableTemplates, - 'col-12': !hasIssuableTemplates, - }" - > - <title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" /> - </div> </div> <description-field ref="description" diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index a5ca91dffd4..d93f38c2ee1 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -16,6 +16,7 @@ export const IssuableType = { Issue: 'issue', Epic: 'epic', MergeRequest: 'merge_request', + Alert: 'alert', }; export const IssueStateEvent = { @@ -25,3 +26,14 @@ export const IssueStateEvent = { export const STATUS_PAGE_PUBLISHED = __('Published on status page'); export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); + +export const IssuableTypes = [ + { value: 'issue', text: __('Issue') }, + { value: 'incident', text: __('Incident') }, +]; + +export const IssueTypePath = 'issues'; +export const IncidentTypePath = 'issues/incident'; +export const IncidentType = 'incident'; + +export const issueState = { issueType: undefined, isDirty: false }; diff --git a/app/assets/javascripts/issue_show/graphql.js b/app/assets/javascripts/issue_show/graphql.js new file mode 100644 index 00000000000..5b8630f7d63 --- /dev/null +++ b/app/assets/javascripts/issue_show/graphql.js @@ -0,0 +1,9 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { defaultClient } from '~/sidebar/graphql'; + +Vue.use(VueApollo); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js index 0c81ecdc843..df986195656 100644 --- a/app/assets/javascripts/issue_show/incident.js +++ b/app/assets/javascripts/issue_show/incident.js @@ -1,15 +1,23 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import issuableApp from './components/app.vue'; import incidentTabs from './components/incidents/incident_tabs.vue'; - -Vue.use(VueApollo); +import { issueState } from './constants'; +import apolloProvider from './graphql'; +import getIssueStateQuery from './queries/get_issue_state.query.graphql'; export default function initIssuableApp(issuableData = {}) { - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return undefined; + } + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getIssueStateQuery, + data: { + issueState: { ...issueState, issueType: el.dataset.issueType }, + }, }); const { @@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) { const fullPath = `${projectNamespace}/${projectPath}`; return new Vue({ - el: document.getElementById('js-issuable-app'), + el, apolloProvider, components: { issuableApp, diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js index a93abbf64df..4374dba6eb7 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issue_show/issue.js @@ -1,14 +1,33 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; import { mapGetters } from 'vuex'; -import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import IssuableApp from './components/app.vue'; import HeaderActions from './components/header_actions.vue'; +import { issueState } from './constants'; +import apolloProvider from './graphql'; +import getIssueStateQuery from './queries/get_issue_state.query.graphql'; + +const bootstrapApollo = (state = {}) => { + return apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getIssueStateQuery, + data: { + issueState: state, + }, + }); +}; export function initIssuableApp(issuableData, store) { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return undefined; + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + return new Vue({ - el: document.getElementById('js-issuable-app'), + el, + apolloProvider, store, computed: { ...mapGetters(['getNoteableData']), @@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) { return undefined; } - Vue.use(VueApollo); - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); return new Vue({ el, diff --git a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql b/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql new file mode 100644 index 00000000000..33b737d2315 --- /dev/null +++ b/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql @@ -0,0 +1,3 @@ +query issueState { + issueState @client +} diff --git a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql b/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql new file mode 100644 index 00000000000..d91ca746066 --- /dev/null +++ b/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateIssueState($issueType: String, $isDirty: Boolean) { + updateIssueState(issueType: $issueType, isDirty: $isDirty) @client +} 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 93ba338a6b3..d5cab77f26c 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -27,6 +27,15 @@ import { PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_DESC, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_EPIC, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_WEIGHT, UPDATED_DESC, URL_PARAM, urlSortParams, @@ -110,15 +119,15 @@ export default { hasBlockedIssuesFeature: { default: false, }, - hasIssues: { - default: false, - }, hasIssueWeightsFeature: { default: false, }, hasMultipleIssueAssigneesFeature: { default: false, }, + hasProjectIssues: { + default: false, + }, initialEmail: { default: '', }, @@ -174,6 +183,9 @@ export default { }; }, computed: { + hasSearch() { + return this.searchQuery || Object.keys(this.urlFilterParams).length; + }, isBulkEditButtonDisabled() { return this.showBulkEditSidebar || !this.issues.length; }, @@ -193,9 +205,22 @@ export default { return convertToSearchQuery(this.filterTokens) || undefined; }, searchTokens() { + let preloadedAuthors = []; + + if (gon.current_user_id) { + preloadedAuthors = [ + { + id: gon.current_user_id, + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }, + ]; + } + const tokens = [ { - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, title: TOKEN_TITLE_AUTHOR, icon: 'pencil', token: AuthorToken, @@ -203,9 +228,10 @@ export default { unique: true, defaultAuthors: [], fetchAuthors: this.fetchUsers, + preloadedAuthors, }, { - type: 'assignee_username', + type: TOKEN_TYPE_ASSIGNEE, title: TOKEN_TITLE_ASSIGNEE, icon: 'user', token: AuthorToken, @@ -213,9 +239,10 @@ export default { unique: !this.hasMultipleIssueAssigneesFeature, defaultAuthors: DEFAULT_NONE_ANY, fetchAuthors: this.fetchUsers, + preloadedAuthors, }, { - type: 'milestone', + type: TOKEN_TYPE_MILESTONE, title: TOKEN_TITLE_MILESTONE, icon: 'clock', token: MilestoneToken, @@ -224,24 +251,28 @@ export default { fetchMilestones: this.fetchMilestones, }, { - type: 'labels', + type: TOKEN_TYPE_LABEL, title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, defaultLabels: [], fetchLabels: this.fetchLabels, }, - { - type: 'my_reaction_emoji', + ]; + + if (this.isSignedIn) { + tokens.push({ + type: TOKEN_TYPE_MY_REACTION, title: TOKEN_TITLE_MY_REACTION, icon: 'thumb-up', token: EmojiToken, unique: true, operators: OPERATOR_IS_ONLY, fetchEmojis: this.fetchEmojis, - }, - { - type: 'confidential', + }); + + tokens.push({ + type: TOKEN_TYPE_CONFIDENTIAL, title: TOKEN_TITLE_CONFIDENTIAL, icon: 'eye-slash', token: GlFilteredSearchToken, @@ -251,12 +282,12 @@ export default { { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, ], - }, - ]; + }); + } if (this.projectIterationsPath) { tokens.push({ - type: 'iteration', + type: TOKEN_TYPE_ITERATION, title: TOKEN_TITLE_ITERATION, icon: 'iteration', token: IterationToken, @@ -267,18 +298,19 @@ export default { if (this.groupEpicsPath) { tokens.push({ - type: 'epic_id', + type: TOKEN_TYPE_EPIC, title: TOKEN_TITLE_EPIC, icon: 'epic', token: EpicToken, unique: true, + idProperty: 'id', fetchEpics: this.fetchEpics, }); } if (this.hasIssueWeightsFeature) { tokens.push({ - type: 'weight', + type: TOKEN_TYPE_WEIGHT, title: TOKEN_TITLE_WEIGHT, icon: 'weight', token: WeightToken, @@ -304,13 +336,23 @@ export default { ); }, urlParams() { + const filterParams = { + ...this.urlFilterParams, + }; + + if (filterParams.epic_id) { + filterParams.epic_id = encodeURIComponent(filterParams.epic_id); + } else if (filterParams['not[epic_id]']) { + filterParams['not[epic_id]'] = encodeURIComponent(filterParams['not[epic_id]']); + } + return { due_date: this.dueDateFilter, page: this.page, search: this.searchQuery, state: this.state, ...urlSortParams[this.sortKey], - ...this.urlFilterParams, + ...filterParams, }; }, }, @@ -342,7 +384,7 @@ export default { fetchEmojis(search) { return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); }, - async fetchEpics(search) { + async fetchEpics({ search }) { const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics'); if (!search) { return epics.slice(0, MAX_LIST_SIZE); @@ -365,12 +407,22 @@ export default { return axios.get(this.autocompleteUsersPath, { params: { search } }); }, fetchIssues() { - if (!this.hasIssues) { + if (!this.hasProjectIssues) { return undefined; } this.isLoading = true; + const filterParams = { + ...this.apiFilterParams, + }; + + if (filterParams.epic_id) { + filterParams.epic_id = filterParams.epic_id.split('::&').pop(); + } else if (filterParams['not[epic_id]']) { + filterParams['not[epic_id]'] = filterParams['not[epic_id]'].split('::&').pop(); + } + return axios .get(this.endpoint, { params: { @@ -381,7 +433,7 @@ export default { state: this.state, with_labels_details: true, ...apiSortParams[this.sortKey], - ...this.apiFilterParams, + ...filterParams, }, }) .then(({ data, headers }) => { @@ -490,7 +542,7 @@ export default { </script> <template> - <div v-if="hasIssues"> + <div v-if="hasProjectIssues"> <issuable-list :namespace="projectPath" recent-searches-storage-key="issues" @@ -500,6 +552,7 @@ export default { :sort-options="sortOptions" :initial-sort-by="sortKey" :issuables="issues" + label-filter-param="label_name" :tabs="$options.IssuableListTabs" :current-tab="state" :tab-counts="tabCounts" @@ -536,7 +589,7 @@ export default { /> <csv-import-export-buttons v-if="isSignedIn" - class="gl-mr-3" + class="gl-md-mr-3" :export-csv-path="exportCsvPathWithQuery" :issuable-count="totalIssues" /> @@ -600,7 +653,7 @@ export default { <template #empty-state> <gl-empty-state - v-if="searchQuery" + v-if="hasSearch" :description="$options.i18n.noSearchResultsDescription" :title="$options.i18n.noSearchResultsTitle" :svg-path="emptyStateSvgPath" diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index 54e9668d300..06e140d6420 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -281,8 +281,18 @@ export const SPECIAL_FILTER = 'specialFilter'; export const ALTERNATIVE_FILTER = 'alternativeFilter'; export const SPECIAL_FILTER_VALUES = [FILTER_NONE, FILTER_ANY, FILTER_CURRENT]; +export const TOKEN_TYPE_AUTHOR = 'author_username'; +export const TOKEN_TYPE_ASSIGNEE = 'assignee_username'; +export const TOKEN_TYPE_MILESTONE = 'milestone'; +export const TOKEN_TYPE_LABEL = 'labels'; +export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji'; +export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; +export const TOKEN_TYPE_ITERATION = 'iteration'; +export const TOKEN_TYPE_EPIC = 'epic_id'; +export const TOKEN_TYPE_WEIGHT = 'weight'; + export const filters = { - author_username: { + [TOKEN_TYPE_AUTHOR]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'author_username', @@ -300,7 +310,7 @@ export const filters = { }, }, }, - assignee_username: { + [TOKEN_TYPE_ASSIGNEE]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'assignee_username', @@ -321,7 +331,7 @@ export const filters = { }, }, }, - milestone: { + [TOKEN_TYPE_MILESTONE]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'milestone', @@ -339,7 +349,7 @@ export const filters = { }, }, }, - labels: { + [TOKEN_TYPE_LABEL]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'labels', @@ -357,7 +367,7 @@ export const filters = { }, }, }, - my_reaction_emoji: { + [TOKEN_TYPE_MY_REACTION]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'my_reaction_emoji', @@ -371,7 +381,7 @@ export const filters = { }, }, }, - confidential: { + [TOKEN_TYPE_CONFIDENTIAL]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'confidential', @@ -383,7 +393,7 @@ export const filters = { }, }, }, - iteration: { + [TOKEN_TYPE_ITERATION]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'iteration_title', @@ -403,7 +413,7 @@ export const filters = { }, }, }, - epic_id: { + [TOKEN_TYPE_EPIC]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'epic_id', @@ -423,7 +433,7 @@ export const filters = { }, }, }, - weight: { + [TOKEN_TYPE_WEIGHT]: { [API_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'weight', diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 55719f6449b..d0c9462a3d7 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -88,9 +88,9 @@ export function mountIssuesListApp() { groupEpicsPath, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, - hasIssues, hasIssueWeightsFeature, hasMultipleIssueAssigneesFeature, + hasProjectIssues, importCsvIssuesPath, initialEmail, isSignedIn, @@ -126,9 +126,9 @@ export function mountIssuesListApp() { groupEpicsPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), - hasIssues: parseBoolean(hasIssues), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), + hasProjectIssues: parseBoolean(hasProjectIssues), isSignedIn: parseBoolean(isSignedIn), issuesPath, jiraIntegrationPath, @@ -147,9 +147,9 @@ export function mountIssuesListApp() { importCsvIssuesPath, maxAttachmentSize, projectImportJiraPath, - showExportButton: parseBoolean(hasIssues), + showExportButton: parseBoolean(hasProjectIssues), showImportButton: parseBoolean(canImportIssues), - showLabel: !parseBoolean(hasIssues), + showLabel: !parseBoolean(hasProjectIssues), // For IssuableByEmail component emailsHelpPagePath, initialEmail, diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js index 234fd59ca8d..b5ec44198da 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues_list/utils.js @@ -16,6 +16,7 @@ import { RELATIVE_POSITION_DESC, SPECIAL_FILTER, SPECIAL_FILTER_VALUES, + TOKEN_TYPE_ASSIGNEE, UPDATED_ASC, UPDATED_DESC, urlSortParams, @@ -173,7 +174,7 @@ export const getFilterTokens = (locationSearch) => { const getFilterType = (data, tokenType = '') => SPECIAL_FILTER_VALUES.includes(data) || - (tokenType === 'assignee_username' && isPositiveInteger(data)) + (tokenType === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data)) ? SPECIAL_FILTER : NORMAL_FILTER; diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 6f2fb41ca15..e7816f6d187 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -168,10 +168,12 @@ export default { }) .then(({ data }) => { this.users = - data?.project?.projectMembers?.nodes?.map(({ user }) => ({ - ...user, - id: getIdFromGraphQLId(user.id), - })) || []; + data?.project?.projectMembers?.nodes + .filter((x) => x?.user) + .map(({ user }) => ({ + ...user, + id: getIdFromGraphQLId(user.id), + })) || []; return this.users; }) .finally(() => { diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index c08ac0317b8..d0594d1ad27 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -54,7 +54,7 @@ export default { <template> <div class="build-job gl-relative" :class="classes"> <gl-link - v-gl-tooltip:tooltip-container.left + v-gl-tooltip.left.viewport :href="job.status.details_path" :title="tooltipText" class="gl-display-flex gl-align-items-center" diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js new file mode 100644 index 00000000000..7e973a34e5c --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -0,0 +1,9 @@ +export const GRAPHQL_PAGE_SIZE = 30; + +export const initialPaginationState = { + currentPage: 1, + prevPageCursor: '', + nextPageCursor: '', + first: GRAPHQL_PAGE_SIZE, + last: null, +}; 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 c2104754bad..68c6584cda6 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 @@ -1,6 +1,13 @@ -query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) { +query getJobs( + $fullPath: ID! + $first: Int + $last: Int + $after: String + $before: String + $statuses: [CiJobStatus!] +) { project(fullPath: $fullPath) { - jobs(first: 20, statuses: $statuses) { + jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) { pageInfo { endCursor hasNextPage diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index 4fe5bbf79cd..076c0e78b11 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,5 +1,6 @@ <script> import { GlTable } from '@gitlab/ui'; +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import ActionsCell from './cells/actions_cell.vue'; @@ -9,7 +10,7 @@ import PipelineCell from './cells/pipeline_cell.vue'; const defaultTableClasses = { tdClass: 'gl-p-5!', - thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!', + thClass: DEFAULT_TH_CLASSES, }; // eslint-disable-next-line @gitlab/require-i18n-strings const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; 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 cf7970f41b1..2061b1f1eb2 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -1,6 +1,7 @@ <script> -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui'; import { __ } from '~/locale'; +import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; import JobsTableEmptyState from './jobs_table_empty_state.vue'; @@ -12,6 +13,7 @@ export default { }, components: { GlAlert, + GlPagination, GlSkeletonLoader, JobsTable, JobsTableEmptyState, @@ -28,10 +30,18 @@ export default { variables() { return { fullPath: this.fullPath, + first: this.pagination.first, + last: this.pagination.last, + after: this.pagination.nextPageCursor, + before: this.pagination.prevPageCursor, }; }, - update({ project }) { - return project?.jobs?.nodes || []; + update(data) { + const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + return { + list, + pageInfo, + }; }, error() { this.hasError = true; @@ -40,10 +50,11 @@ export default { }, data() { return { - jobs: null, + jobs: {}, hasError: false, isAlertDismissed: false, scope: null, + pagination: initialPaginationState, }; }, computed: { @@ -51,7 +62,16 @@ export default { return this.hasError && !this.isAlertDismissed; }, showEmptyState() { - return this.jobs.length === 0 && !this.scope; + return this.jobs.list.length === 0 && !this.scope; + }, + prevPage() { + return Math.max(this.pagination.currentPage - 1, 0); + }, + nextPage() { + return this.jobs.pageInfo?.hasNextPage ? this.pagination.currentPage + 1 : null; + }, + showPaginationControls() { + return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading; }, }, methods: { @@ -60,6 +80,24 @@ export default { this.$apollo.queries.jobs.refetch({ statuses: scope }); }, + handlePageChange(page) { + const { startCursor, endCursor } = this.jobs.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + last: GRAPHQL_PAGE_SIZE, + first: null, + prevPageCursor: startCursor, + currentPage: page, + }; + } + }, }, }; </script> @@ -79,17 +117,34 @@ export default { <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> <div v-if="$apollo.loading" class="gl-mt-5"> - <gl-skeleton-loader - preserve-aspect-ratio="none" - equal-width-lines - :lines="5" - :width="600" - :height="66" - /> + <gl-skeleton-loader :width="1248" :height="73"> + <circle cx="748.031" cy="37.7193" r="15.0307" /> + <circle cx="787.241" cy="37.7193" r="15.0307" /> + <circle cx="827.759" cy="37.7193" r="15.0307" /> + <circle cx="866.969" cy="37.7193" r="15.0307" /> + <circle cx="380" cy="37" r="18" /> + <rect x="432" y="19" width="126.587" height="15" /> + <rect x="432" y="41" width="247" height="15" /> + <rect x="158" y="19" width="86.1" height="15" /> + <rect x="158" y="41" width="168" height="15" /> + <rect x="22" y="19" width="96" height="36" /> + <rect x="924" y="30" width="96" height="15" /> + <rect x="1057" y="20" width="166" height="35" /> + </gl-skeleton-loader> </div> <jobs-table-empty-state v-else-if="showEmptyState" /> - <jobs-table v-else :jobs="jobs" /> + <jobs-table v-else :jobs="jobs.list" /> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-3" + @input="handlePageChange" + /> </div> </template> diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index b19a4a01a5f..2d4765f54b9 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -16,3 +16,6 @@ export const BV_HIDE_MODAL = 'bv::hide::modal'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; + +export const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js new file mode 100644 index 00000000000..396c1703c1e --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -0,0 +1,703 @@ +import { isNumber } from 'lodash'; +import { __, n__ } from '../../../locale'; +import { getDayName, parseSeconds } from './date_format_utility'; + +const DAYS_IN_WEEK = 7; +export const SECONDS_IN_DAY = 86400; +export const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; + +/** + * This method allows you to create new Date instance from existing + * date instance without keeping the reference. + * + * @param {Date} date + */ +export const newDate = (date) => (date instanceof Date ? new Date(date.getTime()) : new Date()); + +/** + * Returns number of days in a month for provided date. + * courtesy: https://stacko(verflow.com/a/1185804/414749 + * + * @param {Date} date + */ +export const totalDaysInMonth = (date) => { + if (!date) { + return 0; + } + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); +}; + +/** + * Returns number of days in a quarter from provided + * months array. + * + * @param {Array} quarter + */ +export const totalDaysInQuarter = (quarter) => + quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0); + +/** + * Returns list of Dates referring to Sundays of the month + * based on provided date + * + * @param {Date} date + */ +export const getSundays = (date) => { + if (!date) { + return []; + } + + const daysToSunday = [ + __('Saturday'), + __('Friday'), + __('Thursday'), + __('Wednesday'), + __('Tuesday'), + __('Monday'), + __('Sunday'), + ]; + + const month = date.getMonth(); + const year = date.getFullYear(); + const sundays = []; + const dateOfMonth = new Date(year, month, 1); + + while (dateOfMonth.getMonth() === month) { + const dayName = getDayName(dateOfMonth); + if (dayName === __('Sunday')) { + sundays.push(new Date(dateOfMonth.getTime())); + } + + const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1; + dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday); + } + + return sundays; +}; + +/** + * Returns list of Dates representing a timeframe of months from startDate and length + * This method also supports going back in time when `length` is negative number + * + * @param {Date} initialStartDate + * @param {Number} length + */ +export const getTimeframeWindowFrom = (initialStartDate, length) => { + if (!(initialStartDate instanceof Date) || !length) { + return []; + } + + const startDate = newDate(initialStartDate); + const moveMonthBy = length > 0 ? 1 : -1; + + startDate.setDate(1); + startDate.setHours(0, 0, 0, 0); + + // Iterate and set date for the size of length + // and push date reference to timeframe list + const timeframe = new Array(Math.abs(length)).fill().map(() => { + const currentMonth = startDate.getTime(); + startDate.setMonth(startDate.getMonth() + moveMonthBy); + return new Date(currentMonth); + }); + + // Change date of last timeframe item to last date of the month + // when length is positive + if (length > 0) { + timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1])); + } + + return timeframe; +}; + +/** + * Returns count of day within current quarter from provided date + * and array of months for the quarter + * + * Eg; + * If date is 15 Feb 2018 + * and quarter is [Jan, Feb, Mar] + * + * Then 15th Feb is 46th day of the quarter + * Where 31 (days in Jan) + 15 (date of Feb). + * + * @param {Date} date + * @param {Array} quarter + */ +export const dayInQuarter = (date, quarter) => { + const dateValues = { + date: date.getDate(), + month: date.getMonth(), + }; + + return quarter.reduce((acc, month) => { + if (dateValues.month > month.getMonth()) { + return acc + totalDaysInMonth(month); + } else if (dateValues.month === month.getMonth()) { + return acc + dateValues.date; + } + return acc + 0; + }, 0); +}; + +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()); + + return Math.floor((date2 - date1) / millisecondsPerDay); +}; + +/** + * Calculates the milliseconds between now and a given date string. + * The result cannot become negative. + * + * @param endDate date string that the time difference is calculated for + * @return {Number} number of milliseconds remaining until the given date + */ +export const calculateRemainingMilliseconds = (endDate) => { + const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); + return Math.max(remainingMilliseconds, 0); +}; + +/** + * Subtracts a given number of days from a given date and returns the new date. + * + * @param {Date} date the date that we will substract days from + * @param {Number} daysInPast number of days that are subtracted from a given date + * @returns {Date} Date in past as Date object + */ +export const getDateInPast = (date, daysInPast) => + new Date(newDate(date).setDate(date.getDate() - daysInPast)); + +/** + * Adds a given number of days to a given date and returns the new date. + * + * @param {Date} date the date that we will add days to + * @param {Number} daysInFuture number of days that are added to a given date + * @returns {Date} Date in future as Date object + */ +export const getDateInFuture = (date, daysInFuture) => + new Date(newDate(date).setDate(date.getDate() + daysInFuture)); + +/** + * Checks if a given date-instance was created with a valid date + * + * @param {Date} date + * @returns boolean + */ +export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime()); + +/* + * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date + * to match the user's time zone. We want to display the date in server time for now, to + * be consistent with the "edit issue -> due date" UI. + */ + +export const newDateAsLocaleTime = (date) => { + const suffix = 'T00:00:00'; + return new Date(`${date}${suffix}`); +}; + +export const beginOfDayTime = 'T00:00:00Z'; +export const endOfDayTime = 'T23:59:59Z'; + +/** + * @param {Date} d1 + * @param {Date} d2 + * @param {Function} formatter + * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date) + */ +export const getDatesInRange = (d1, d2, formatter = (x) => x) => { + if (!(d1 instanceof Date) || !(d2 instanceof Date)) { + return []; + } + let startDate = d1.getTime(); + const endDate = d2.getTime(); + const oneDay = 24 * 3600 * 1000; + const range = [d1]; + + while (startDate < endDate) { + startDate += oneDay; + range.push(new Date(startDate)); + } + + return range.map(formatter); +}; + +/** + * Converts the supplied number of seconds to milliseconds. + * + * @param {Number} seconds + * @return {Number} number of milliseconds + */ +export const secondsToMilliseconds = (seconds) => seconds * 1000; + +/** + * Converts the supplied number of seconds to days. + * + * @param {Number} seconds + * @return {Number} number of days + */ +export const secondsToDays = (seconds) => Math.round(seconds / 86400); + +/** + * Converts a numeric utc offset in seconds to +/- hours + * ie -32400 => -9 hours + * ie -12600 => -3.5 hours + * + * @param {Number} offset UTC offset in seconds as a integer + * + * @return {String} the + or - offset in hours + */ +export const secondsToHours = (offset) => { + const parsed = parseInt(offset, 10); + if (Number.isNaN(parsed) || parsed === 0) { + return `0`; + } + const num = offset / 3600; + return parseInt(num, 10) !== num ? num.toFixed(1) : num; +}; + +/** + * Returns the date `n` days after the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfDays number of days after + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` days after the provided `Date` + */ +export const nDaysAfter = (date, numberOfDays, { utc = false } = {}) => { + const clone = newDate(date); + + const cloneValue = utc + ? clone.setUTCDate(date.getUTCDate() + numberOfDays) + : clone.setDate(date.getDate() + numberOfDays); + + return new Date(cloneValue); +}; + +/** + * Returns the date `n` days before the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfDays number of days before + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * @return {Date} A `Date` object `n` days before the provided `Date` + */ +export const nDaysBefore = (date, numberOfDays, options) => + nDaysAfter(date, -numberOfDays, options); + +/** + * Returns the date `n` months after the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfMonths number of months after + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` months after the provided `Date` + */ +export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => { + const clone = newDate(date); + + const cloneValue = utc + ? clone.setUTCMonth(date.getUTCMonth() + numberOfMonths) + : clone.setMonth(date.getMonth() + numberOfMonths); + + return new Date(cloneValue); +}; + +/** + * Returns the date `n` months before the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfMonths number of months before + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` months before the provided `Date` + */ +export const nMonthsBefore = (date, numberOfMonths, options) => + nMonthsAfter(date, -numberOfMonths, options); + +/** + * Returns the date `n` weeks after the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfWeeks number of weeks after + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` weeks after the provided `Date` + */ +export const nWeeksAfter = (date, numberOfWeeks, options) => + nDaysAfter(date, DAYS_IN_WEEK * numberOfWeeks, options); + +/** + * Returns the date `n` weeks before the date provided + * + * @param {Date} date the initial date + * @param {Number} numberOfWeeks number of weeks before + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} A `Date` object `n` weeks before the provided `Date` + */ +export const nWeeksBefore = (date, numberOfWeeks, options) => + nWeeksAfter(date, -numberOfWeeks, options); + +/** + * Returns the date `n` years after the date provided. + * + * @param {Date} date the initial date + * @param {Number} numberOfYears number of years after + * @return {Date} A `Date` object `n` years after the provided `Date` + */ +export const nYearsAfter = (date, numberOfYears) => { + const clone = newDate(date); + clone.setFullYear(clone.getFullYear() + numberOfYears); + return clone; +}; + +/** + * Returns the date after the date provided + * + * @param {Date} date the initial date + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. + * This will cause Daylight Saving Time to be ignored. Defaults to `false` + * if not provided, which causes the calculation to be performed in the + * user's timezone. + * + * @return {Date} the date following the date provided + */ +export const dayAfter = (date, options) => nDaysAfter(date, 1, options); + +/** + * A utility function which computes the difference in seconds + * between 2 dates. + * + * @param {Date} startDate the start date + * @param {Date} endDate the end date + * + * @return {Int} the difference in seconds + */ +export const differenceInSeconds = (startDate, endDate) => { + return (endDate.getTime() - startDate.getTime()) / 1000; +}; + +/** + * A utility function which computes the difference in months + * between 2 dates. + * + * @param {Date} startDate the start date + * @param {Date} endDate the end date + * + * @return {Int} the difference in months + */ +export const differenceInMonths = (startDate, endDate) => { + const yearDiff = endDate.getYear() - startDate.getYear(); + const monthDiff = endDate.getMonth() - startDate.getMonth(); + return monthDiff + 12 * yearDiff; +}; + +/** + * A utility function which computes the difference in milliseconds + * between 2 dates. + * + * @param {Date|Int} startDate the start date. Can be either a date object or a unix timestamp. + * @param {Date|Int} endDate the end date. Can be either a date object or a unix timestamp. Defaults to now. + * + * @return {Int} the difference in milliseconds + */ +export const differenceInMilliseconds = (startDate, endDate = Date.now()) => { + const startDateInMS = startDate instanceof Date ? startDate.getTime() : startDate; + const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate; + return endDateInMS - startDateInMS; +}; + +/** + * A utility which returns a new date at the first day of the month for any given date. + * + * @param {Date} date + * + * @return {Date} the date at the first day of the month + */ +export const dateAtFirstDayOfMonth = (date) => new Date(newDate(date).setDate(1)); + +/** + * A utility function which checks if two dates match. + * + * @param {Date|Int} date1 Can be either a date object or a unix timestamp. + * @param {Date|Int} date2 Can be either a date object or a unix timestamp. + * + * @return {Boolean} true if the dates match + */ +export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0; + +/** + * A utility function which checks if two date ranges overlap. + * + * @param {Object} givenPeriodLeft - the first period to compare. + * @param {Object} givenPeriodRight - the second period to compare. + * @returns {Object} { overlap: number of days the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format } + * @throws {Error} Uncaught Error: Invalid period + * + * @example + * getOverlappingDaysInPeriods( + * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) }, + * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) } + * ) => { daysOverlap: 2, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 } + * + */ +export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => { + const leftStartTime = new Date(givenPeriodLeft.start).getTime(); + const leftEndTime = new Date(givenPeriodLeft.end).getTime(); + const rightStartTime = new Date(givenPeriodRight.start).getTime(); + const rightEndTime = new Date(givenPeriodRight.end).getTime(); + + if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) { + throw new Error(__('Invalid period')); + } + + const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime; + + if (!isOverlapping) { + return { daysOverlap: 0 }; + } + + const overlapStartDate = Math.max(leftStartTime, rightStartTime); + const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime; + const differenceInMs = overlapEndDate - overlapStartDate; + + return { + daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY), + overlapStartDate, + overlapEndDate, + }; +}; + +/** + * Mimics the behaviour of the rails distance_of_time_in_words function + * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words + * 0 < -> 29 secs => less than a minute + * 30 secs < -> 1 min, 29 secs => 1 minute + * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes + * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour + * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours + * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day + * 41 hrs, 59 mins, 30 secs => x days + * + * @param {Number} seconds + * @return {String} approximated time + */ +export const approximateDuration = (seconds = 0) => { + if (!isNumber(seconds) || seconds < 0) { + return ''; + } + + const ONE_MINUTE_LIMIT = 90; // 1 minute 30s + const MINUTES_LIMIT = 2670; // 44 minutes 30s + const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s + const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s + const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s + + const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, { + daysPerWeek: 7, + hoursPerDay: 24, + limitToDays: true, + }); + + if (seconds < 30) { + return __('less than a minute'); + } else if (seconds < MINUTES_LIMIT) { + return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes); + } else if (seconds < HOURS_LIMIT) { + return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours); + } + return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); +}; + +/** + * A utility function which helps creating a date object + * for a specific date. Accepts the year, month and day + * returning a date object for the given params. + * + * @param {Int} year the full year as a number i.e. 2020 + * @param {Int} month the month index i.e. January => 0 + * @param {Int} day the day as a number i.e. 23 + * + * @return {Date} the date object from the params + */ +export const dateFromParams = (year, month, day) => { + return new Date(year, month, day); +}; + +/** + * A utility function which computes a formatted 24 hour + * time string from a positive int in the range 0 - 24. + * + * @param {Int} time a positive Int between 0 and 24 + * + * @returns {String} formatted 24 hour time String + */ +export const format24HourTimeStringFromInt = (time) => { + if (!Number.isInteger(time) || time < 0 || time > 24) { + return ''; + } + + const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`; + return formatted24HourString; +}; + +/** + * A utility function that checks that the date is today + * + * @param {Date} date + * + * @return {Boolean} true if provided date is today + */ +export const isToday = (date) => { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); +}; + +/** + * Checks whether the date is in the past. + * + * @param {Date} date + * @return {Boolean} Returns true if the date falls before today, otherwise false. + */ +export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0; + +/** + * Checks whether the date is in the future. + * . + * @param {Date} date + * @return {Boolean} Returns true if the date falls after today, otherwise false. + */ +export const isInFuture = (date) => + !isToday(date) && differenceInMilliseconds(Date.now(), date) > 0; + +/** + * Checks whether dateA falls before dateB. + * + * @param {Date} dateA + * @param {Date} dateB + * @return {Boolean} Returns true if dateA falls before dateB, otherwise false + */ +export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0; + +/** + * Removes the time component of the date. + * + * @param {Date} date + * @return {Date} Returns a clone of the date with the time set to midnight + */ +export const removeTime = (date) => { + const clone = newDate(date); + clone.setHours(0, 0, 0, 0); + return clone; +}; + +/** + * Returns the start of the provided day + * + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC time. + * If `true`, the time returned will be midnight UTC. If `false` (the default) + * the time returned will be midnight in the user's local time. + * + * @returns {Date} A new `Date` object that represents the start of the day + * of the provided date + */ +export const getStartOfDay = (date, { utc = false } = {}) => { + const clone = newDate(date); + + const cloneValue = utc ? clone.setUTCHours(0, 0, 0, 0) : clone.setHours(0, 0, 0, 0); + + return new Date(cloneValue); +}; + +/** + * Returns the start of the current week against the provide date + * + * @param {Date} date The current date instance to calculate against + * @param {Object} [options={}] Additional options for this calculation + * @param {boolean} [options.utc=false] Performs the calculation using UTC time. + * If `true`, the time returned will be midnight UTC. If `false` (the default) + * the time returned will be midnight in the user's local time. + * + * @returns {Date} A new `Date` object that represents the start of the current week + * of the provided date + */ +export const getStartOfWeek = (date, { utc = false } = {}) => { + const cloneValue = utc + ? new Date(date.setUTCHours(0, 0, 0, 0)) + : new Date(date.setHours(0, 0, 0, 0)); + + const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1); + + return new Date(date.setDate(diff)); +}; + +/** + * Calculates the time remaining from today in words in the format + * `n days/weeks/months/years remaining`. + * + * @param {Date} date A date in future + * @return {String} The time remaining in the format `n days/weeks/months/years remaining` + */ +export const getTimeRemainingInWords = (date) => { + const today = removeTime(new Date()); + const dateInFuture = removeTime(date); + + const oneWeekFromNow = nWeeksAfter(today, 1); + const oneMonthFromNow = nMonthsAfter(today, 1); + const oneYearFromNow = nYearsAfter(today, 1); + + if (fallsBefore(dateInFuture, oneWeekFromNow)) { + const days = getDayDifference(today, dateInFuture); + return n__('1 day remaining', '%d days remaining', days); + } + + if (fallsBefore(dateInFuture, oneMonthFromNow)) { + const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7); + return n__('1 week remaining', '%d weeks remaining', weeks); + } + + if (fallsBefore(dateInFuture, oneYearFromNow)) { + const months = differenceInMonths(today, dateInFuture); + return n__('1 month remaining', '%d months remaining', months); + } + + const years = dateInFuture.getFullYear() - today.getFullYear(); + return n__('1 year remaining', '%d years remaining', years); +}; diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js new file mode 100644 index 00000000000..246f290a90a --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -0,0 +1,260 @@ +import dateFormat from 'dateformat'; +import { isString, mapValues, reduce } from 'lodash'; +import { s__, n__, __ } from '../../../locale'; + +/** + * Returns i18n month names array. + * If `abbreviated` is provided, returns abbreviated + * name. + * + * @param {Boolean} abbreviated + */ +export const getMonthNames = (abbreviated) => { + if (abbreviated) { + return [ + s__('Jan'), + s__('Feb'), + s__('Mar'), + s__('Apr'), + s__('May'), + s__('Jun'), + s__('Jul'), + s__('Aug'), + s__('Sep'), + s__('Oct'), + s__('Nov'), + s__('Dec'), + ]; + } + return [ + s__('January'), + s__('February'), + s__('March'), + s__('April'), + s__('May'), + s__('June'), + s__('July'), + s__('August'), + s__('September'), + s__('October'), + s__('November'), + s__('December'), + ]; +}; + +/** + * Returns month name based on provided date. + * + * @param {Date} date + * @param {Boolean} abbreviated + */ +export const monthInWords = (date, abbreviated = false) => { + if (!date) { + return ''; + } + + return getMonthNames(abbreviated)[date.getMonth()]; +}; + +export const dateInWords = (date, abbreviated = false, hideYear = false) => { + if (!date) return date; + + const month = date.getMonth(); + const year = date.getFullYear(); + + const monthName = getMonthNames(abbreviated)[month]; + + if (hideYear) { + return `${monthName} ${date.getDate()}`; + } + + return `${monthName} ${date.getDate()}, ${year}`; +}; + +/** + * Similar to `timeIntervalInWords`, but rounds the return value + * to 1/10th of the largest time unit. For example: + * + * 30 => 30 seconds + * 90 => 1.5 minutes + * 7200 => 2 hours + * 86400 => 1 day + * ... etc. + * + * The largest supported unit is "days". + * + * @param {Number} intervalInSeconds The time interval in seconds + * @returns {String} A humanized description of the time interval + */ +export const humanizeTimeInterval = (intervalInSeconds) => { + if (intervalInSeconds < 60 /* = 1 minute */) { + const seconds = Math.round(intervalInSeconds * 10) / 10; + return n__('%d second', '%d seconds', seconds); + } else if (intervalInSeconds < 3600 /* = 1 hour */) { + const minutes = Math.round(intervalInSeconds / 6) / 10; + return n__('%d minute', '%d minutes', minutes); + } else if (intervalInSeconds < 86400 /* = 1 day */) { + const hours = Math.round(intervalInSeconds / 360) / 10; + return n__('%d hour', '%d hours', hours); + } + + const days = Math.round(intervalInSeconds / 8640) / 10; + return n__('%d day', '%d days', days); +}; + +/** + * Returns i18n weekday names array. + */ +export const getWeekdayNames = () => [ + __('Sunday'), + __('Monday'), + __('Tuesday'), + __('Wednesday'), + __('Thursday'), + __('Friday'), + __('Saturday'), +]; + +/** + * Given a date object returns the day of the week in English + * @param {date} date + * @returns {String} + */ +export const getDayName = (date) => getWeekdayNames()[date.getDay()]; + +/** + * Returns the i18n month name from a given date + * @example + * formatDateAsMonth(new Date('2020-06-28')) -> 'Jun' + * @param {String} datetime where month is extracted from + * @param {Object} options + * @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not + * @return {String} the i18n month name + */ +export function formatDateAsMonth(datetime, options = {}) { + const { abbreviated = true } = options; + const month = new Date(datetime).getMonth(); + return getMonthNames(abbreviated)[month]; +} + +/** + * @example + * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am UTC" + * @param {date} datetime + * @param {String} format + * @param {Boolean} UTC convert local time to UTC + * @returns {String} + */ +export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => { + if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { + throw new Error(__('Invalid date')); + } + return dateFormat(datetime, format, utc); +}; + +/** + * Formats milliseconds as timestamp (e.g. 01:02:03). + * This takes durations longer than a day into account (e.g. two days would be 48:00:00). + * + * @param milliseconds + * @returns {string} + */ +export const formatTime = (milliseconds) => { + const remainingSeconds = Math.floor(milliseconds / 1000) % 60; + const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60; + const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60); + let formattedTime = ''; + if (remainingHours < 10) formattedTime += '0'; + formattedTime += `${remainingHours}:`; + if (remainingMinutes < 10) formattedTime += '0'; + formattedTime += `${remainingMinutes}:`; + if (remainingSeconds < 10) formattedTime += '0'; + formattedTime += remainingSeconds; + return formattedTime; +}; + +/** + * Port of ruby helper time_interval_in_words. + * + * @param {Number} seconds + * @return {String} + */ +export const timeIntervalInWords = (intervalInSeconds) => { + const secondsInteger = parseInt(intervalInSeconds, 10); + const minutes = Math.floor(secondsInteger / 60); + const seconds = secondsInteger - minutes * 60; + const secondsText = n__('%d second', '%d seconds', seconds); + return minutes >= 1 + ? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ') + : secondsText; +}; + +/** + * Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it + * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included. + * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days' + */ +export const stringifyTime = (timeObject, fullNameFormat = false) => { + const reducedTime = reduce( + timeObject, + (memo, unitValue, unitName) => { + const isNonZero = Boolean(unitValue); + + if (fullNameFormat && isNonZero) { + // Remove traling 's' if unit value is singular + const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); + return `${memo} ${unitValue} ${formattedUnitName}`; + } + + return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; + }, + '', + ).trim(); + return reducedTime.length ? reducedTime : '0m'; +}; + +/** + * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } + * Seconds can be negative or positive, zero or non-zero. Can be configured for any day + * or week length. + */ +export const parseSeconds = ( + seconds, + { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {}, +) => { + const DAYS_PER_WEEK = daysPerWeek; + const HOURS_PER_DAY = hoursPerDay; + const SECONDS_PER_MINUTE = 60; + const MINUTES_PER_HOUR = 60; + const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; + const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; + + const timePeriodConstraints = { + weeks: MINUTES_PER_WEEK, + days: MINUTES_PER_DAY, + hours: MINUTES_PER_HOUR, + minutes: 1, + }; + + if (limitToDays || limitToHours) { + timePeriodConstraints.weeks = 0; + } + + if (limitToHours) { + timePeriodConstraints.days = 0; + } + + let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE); + + return mapValues(timePeriodConstraints, (minutesPerPeriod) => { + if (minutesPerPeriod === 0) { + return 0; + } + + const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); + + unorderedMinutes -= periodCount * minutesPerPeriod; + + return periodCount; + }); +}; diff --git a/app/assets/javascripts/lib/utils/datetime/pikaday_utility.js b/app/assets/javascripts/lib/utils/datetime/pikaday_utility.js new file mode 100644 index 00000000000..63542ddbb6a --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/pikaday_utility.js @@ -0,0 +1,28 @@ +export const pad = (val, len = 2) => `0${val}`.slice(-len); + +/** + * Formats dates in Pickaday + * @param {String} dateString Date in yyyy-mm-dd format + * @return {Date} UTC format + */ +export const parsePikadayDate = (dateString) => { + const parts = dateString.split('-'); + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1] - 1, 10); + const day = parseInt(parts[2], 10); + + return new Date(year, month, day); +}; + +/** + * Used `onSelect` method in pickaday + * @param {Date} date UTC format + * @return {String} Date formatted in yyyy-mm-dd + */ +export const pikadayToString = (date) => { + const day = pad(date.getDate()); + const month = pad(date.getMonth() + 1); + const year = date.getFullYear(); + + return `${year}-${month}-${day}`; +}; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js new file mode 100644 index 00000000000..512b1f079a1 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -0,0 +1,124 @@ +import $ from 'jquery'; +import * as timeago from 'timeago.js'; +import { languageCode, s__ } from '../../../locale'; +import { formatDate } from './date_format_utility'; + +window.timeago = timeago; + +/** + * Timeago uses underscores instead of dashes to separate language from country code. + * + * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales + */ +const timeagoLanguageCode = languageCode().replace(/-/g, '_'); + +/** + * Registers timeago locales + */ +const memoizedLocaleRemaining = () => { + const cache = []; + + const timeAgoLocaleRemaining = [ + () => [s__('Timeago|just now'), s__('Timeago|right now')], + () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')], + () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], + () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], + () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], + () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], + () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], + () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], + () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], + () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], + () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], + () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], + () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], + () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], + ]; + + return (number, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index](); + return cache[index]; + }; +}; + +const memoizedLocale = () => { + const cache = []; + + const timeAgoLocale = [ + () => [s__('Timeago|just now'), s__('Timeago|right now')], + () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')], + () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], + () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], + () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], + () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], + () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], + () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], + () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], + () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], + () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], + () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], + () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], + () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], + ]; + + return (number, index) => { + if (cache[index]) { + return cache[index]; + } + cache[index] = timeAgoLocale[index] && timeAgoLocale[index](); + return cache[index]; + }; +}; + +timeago.register(timeagoLanguageCode, memoizedLocale()); +timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); + +export const getTimeago = () => timeago; + +/** + * For the given elements, sets a tooltip with a formatted date. + * @param {JQuery} $timeagoEls + * @param {Boolean} setTimeago + */ +export const localTimeAgo = ($timeagoEls, setTimeago = true) => { + $timeagoEls.each((i, el) => { + $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode)); + }); + + if (!setTimeago) { + return; + } + + function addTimeAgoTooltip() { + $timeagoEls.each((i, el) => { + // Recreate with custom template + el.setAttribute('title', formatDate(el.dateTime)); + }); + } + + requestIdleCallback(addTimeAgoTooltip); +}; + +/** + * Returns remaining or passed time over the given time. + * @param {*} time + * @param {*} expiredLabel + */ +export const timeFor = (time, expiredLabel) => { + if (!time) { + return ''; + } + if (new Date(time) < new Date()) { + return expiredLabel || s__('Timeago|Past due'); + } + return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim(); +}; + +window.gl = window.gl || {}; +window.gl.utils = { + ...(window.gl.utils || {}), + localTimeAgo, +}; diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js index 391b685f740..a2b161d1446 100644 --- a/app/assets/javascripts/lib/utils/datetime_range.js +++ b/app/assets/javascripts/lib/utils/datetime_range.js @@ -26,14 +26,7 @@ const isValidDateString = (dateString) => { return false; } - try { - // dateformat throws error that can be caught. - // This is better than using `new Date()` - dateformat(dateString, 'isoUtcDateTime'); - return true; - } catch (e) { - return false; - } + return !Number.isNaN(Date.parse(dateformat(dateString, 'isoUtcDateTime'))); }; const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => { diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 0a038febb9f..c1081239544 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,1107 +1,4 @@ -import dateFormat from 'dateformat'; -import $ from 'jquery'; -import { isString, mapValues, isNumber, reduce } from 'lodash'; -import * as timeago from 'timeago.js'; -import { languageCode, s__, __, n__ } from '../../locale'; - -export const SECONDS_IN_DAY = 86400; - -const DAYS_IN_WEEK = 7; - -window.timeago = timeago; - -/** - * This method allows you to create new Date instance from existing - * date instance without keeping the reference. - * - * @param {Date} date - */ -export const newDate = (date) => (date instanceof Date ? new Date(date.getTime()) : new Date()); - -/** - * Returns i18n month names array. - * If `abbreviated` is provided, returns abbreviated - * name. - * - * @param {Boolean} abbreviated - */ -export const getMonthNames = (abbreviated) => { - if (abbreviated) { - return [ - s__('Jan'), - s__('Feb'), - s__('Mar'), - s__('Apr'), - s__('May'), - s__('Jun'), - s__('Jul'), - s__('Aug'), - s__('Sep'), - s__('Oct'), - s__('Nov'), - s__('Dec'), - ]; - } - return [ - s__('January'), - s__('February'), - s__('March'), - s__('April'), - s__('May'), - s__('June'), - s__('July'), - s__('August'), - s__('September'), - s__('October'), - s__('November'), - s__('December'), - ]; -}; - -export const pad = (val, len = 2) => `0${val}`.slice(-len); - -/** - * Returns i18n weekday names array. - */ -export const getWeekdayNames = () => [ - __('Sunday'), - __('Monday'), - __('Tuesday'), - __('Wednesday'), - __('Thursday'), - __('Friday'), - __('Saturday'), -]; - -/** - * Given a date object returns the day of the week in English - * @param {date} date - * @returns {String} - */ -export const getDayName = (date) => - [ - __('Sunday'), - __('Monday'), - __('Tuesday'), - __('Wednesday'), - __('Thursday'), - __('Friday'), - __('Saturday'), - ][date.getDay()]; - -/** - * Returns the i18n month name from a given date - * @example - * formatDateAsMonth(new Date('2020-06-28')) -> 'Jun' - * @param {String} datetime where month is extracted from - * @param {Object} options - * @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not - * @return {String} the i18n month name - */ -export function formatDateAsMonth(datetime, options = {}) { - const { abbreviated = true } = options; - const month = new Date(datetime).getMonth(); - return getMonthNames(abbreviated)[month]; -} - -/** - * @example - * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000" - * @param {date} datetime - * @param {String} format - * @param {Boolean} UTC convert local time to UTC - * @returns {String} - */ -export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = false) => { - if (isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { - throw new Error(__('Invalid date')); - } - return dateFormat(datetime, format, utc); -}; - -/** - * Timeago uses underscores instead of dashes to separate language from country code. - * - * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales - */ -const timeagoLanguageCode = languageCode().replace(/-/g, '_'); - -/** - * Registers timeago locales - */ -const memoizedLocaleRemaining = () => { - const cache = []; - - const timeAgoLocaleRemaining = [ - () => [s__('Timeago|just now'), s__('Timeago|right now')], - () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')], - () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], - () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], - () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], - () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], - () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], - () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], - () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], - () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], - () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], - () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], - () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], - () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], - ]; - - return (number, index) => { - if (cache[index]) { - return cache[index]; - } - cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index](); - return cache[index]; - }; -}; - -const memoizedLocale = () => { - const cache = []; - - const timeAgoLocale = [ - () => [s__('Timeago|just now'), s__('Timeago|right now')], - () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')], - () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], - () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], - () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], - () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], - () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], - () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], - () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], - () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], - () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], - () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], - () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], - () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], - ]; - - return (number, index) => { - if (cache[index]) { - return cache[index]; - } - cache[index] = timeAgoLocale[index] && timeAgoLocale[index](); - return cache[index]; - }; -}; - -timeago.register(timeagoLanguageCode, memoizedLocale()); -timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); - -export const getTimeago = () => timeago; - -/** - * For the given elements, sets a tooltip with a formatted date. - * @param {JQuery} $timeagoEls - * @param {Boolean} setTimeago - */ -export const localTimeAgo = ($timeagoEls, setTimeago = true) => { - $timeagoEls.each((i, el) => { - $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode)); - }); - - if (!setTimeago) { - return; - } - - function addTimeAgoTooltip() { - $timeagoEls.each((i, el) => { - // Recreate with custom template - el.setAttribute('title', formatDate(el.dateTime)); - }); - } - - requestIdleCallback(addTimeAgoTooltip); -}; - -/** - * Returns remaining or passed time over the given time. - * @param {*} time - * @param {*} expiredLabel - */ -export const timeFor = (time, expiredLabel) => { - if (!time) { - return ''; - } - if (new Date(time) < new Date()) { - return expiredLabel || s__('Timeago|Past due'); - } - return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim(); -}; - -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()); - - return Math.floor((date2 - date1) / millisecondsPerDay); -}; - -/** - * Port of ruby helper time_interval_in_words. - * - * @param {Number} seconds - * @return {String} - */ -export const timeIntervalInWords = (intervalInSeconds) => { - const secondsInteger = parseInt(intervalInSeconds, 10); - const minutes = Math.floor(secondsInteger / 60); - const seconds = secondsInteger - minutes * 60; - const secondsText = n__('%d second', '%d seconds', seconds); - return minutes >= 1 - ? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ') - : secondsText; -}; - -/** - * Similar to `timeIntervalInWords`, but rounds the return value - * to 1/10th of the largest time unit. For example: - * - * 30 => 30 seconds - * 90 => 1.5 minutes - * 7200 => 2 hours - * 86400 => 1 day - * ... etc. - * - * The largest supported unit is "days". - * - * @param {Number} intervalInSeconds The time interval in seconds - * @returns {String} A humanized description of the time interval - */ -export const humanizeTimeInterval = (intervalInSeconds) => { - if (intervalInSeconds < 60 /* = 1 minute */) { - const seconds = Math.round(intervalInSeconds * 10) / 10; - return n__('%d second', '%d seconds', seconds); - } else if (intervalInSeconds < 3600 /* = 1 hour */) { - const minutes = Math.round(intervalInSeconds / 6) / 10; - return n__('%d minute', '%d minutes', minutes); - } else if (intervalInSeconds < 86400 /* = 1 day */) { - const hours = Math.round(intervalInSeconds / 360) / 10; - return n__('%d hour', '%d hours', hours); - } - - const days = Math.round(intervalInSeconds / 8640) / 10; - return n__('%d day', '%d days', days); -}; - -export const dateInWords = (date, abbreviated = false, hideYear = false) => { - if (!date) return date; - - const month = date.getMonth(); - const year = date.getFullYear(); - - const monthNames = [ - s__('January'), - s__('February'), - s__('March'), - s__('April'), - s__('May'), - s__('June'), - s__('July'), - s__('August'), - s__('September'), - s__('October'), - s__('November'), - s__('December'), - ]; - const monthNamesAbbr = [ - s__('Jan'), - s__('Feb'), - s__('Mar'), - s__('Apr'), - s__('May'), - s__('Jun'), - s__('Jul'), - s__('Aug'), - s__('Sep'), - s__('Oct'), - s__('Nov'), - s__('Dec'), - ]; - - const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month]; - - if (hideYear) { - return `${monthName} ${date.getDate()}`; - } - - return `${monthName} ${date.getDate()}, ${year}`; -}; - -/** - * Returns month name based on provided date. - * - * @param {Date} date - * @param {Boolean} abbreviated - */ -export const monthInWords = (date, abbreviated = false) => { - if (!date) { - return ''; - } - - return getMonthNames(abbreviated)[date.getMonth()]; -}; - -/** - * Returns number of days in a month for provided date. - * courtesy: https://stacko(verflow.com/a/1185804/414749 - * - * @param {Date} date - */ -export const totalDaysInMonth = (date) => { - if (!date) { - return 0; - } - return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); -}; - -/** - * Returns number of days in a quarter from provided - * months array. - * - * @param {Array} quarter - */ -export const totalDaysInQuarter = (quarter) => - quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0); - -/** - * Returns list of Dates referring to Sundays of the month - * based on provided date - * - * @param {Date} date - */ -export const getSundays = (date) => { - if (!date) { - return []; - } - - const daysToSunday = [ - __('Saturday'), - __('Friday'), - __('Thursday'), - __('Wednesday'), - __('Tuesday'), - __('Monday'), - __('Sunday'), - ]; - - const month = date.getMonth(); - const year = date.getFullYear(); - const sundays = []; - const dateOfMonth = new Date(year, month, 1); - - while (dateOfMonth.getMonth() === month) { - const dayName = getDayName(dateOfMonth); - if (dayName === __('Sunday')) { - sundays.push(new Date(dateOfMonth.getTime())); - } - - const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1; - dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday); - } - - return sundays; -}; - -/** - * Returns list of Dates representing a timeframe of months from startDate and length - * This method also supports going back in time when `length` is negative number - * - * @param {Date} initialStartDate - * @param {Number} length - */ -export const getTimeframeWindowFrom = (initialStartDate, length) => { - if (!(initialStartDate instanceof Date) || !length) { - return []; - } - - const startDate = newDate(initialStartDate); - const moveMonthBy = length > 0 ? 1 : -1; - - startDate.setDate(1); - startDate.setHours(0, 0, 0, 0); - - // Iterate and set date for the size of length - // and push date reference to timeframe list - const timeframe = new Array(Math.abs(length)).fill().map(() => { - const currentMonth = startDate.getTime(); - startDate.setMonth(startDate.getMonth() + moveMonthBy); - return new Date(currentMonth); - }); - - // Change date of last timeframe item to last date of the month - // when length is positive - if (length > 0) { - timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1])); - } - - return timeframe; -}; - -/** - * Returns count of day within current quarter from provided date - * and array of months for the quarter - * - * Eg; - * If date is 15 Feb 2018 - * and quarter is [Jan, Feb, Mar] - * - * Then 15th Feb is 46th day of the quarter - * Where 31 (days in Jan) + 15 (date of Feb). - * - * @param {Date} date - * @param {Array} quarter - */ -export const dayInQuarter = (date, quarter) => { - const dateValues = { - date: date.getDate(), - month: date.getMonth(), - }; - - return quarter.reduce((acc, month) => { - if (dateValues.month > month.getMonth()) { - return acc + totalDaysInMonth(month); - } else if (dateValues.month === month.getMonth()) { - return acc + dateValues.date; - } - return acc + 0; - }, 0); -}; - -window.gl = window.gl || {}; -window.gl.utils = { - ...(window.gl.utils || {}), - localTimeAgo, -}; - -/** - * Formats milliseconds as timestamp (e.g. 01:02:03). - * This takes durations longer than a day into account (e.g. two days would be 48:00:00). - * - * @param milliseconds - * @returns {string} - */ -export const formatTime = (milliseconds) => { - const remainingSeconds = Math.floor(milliseconds / 1000) % 60; - const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60; - const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60); - let formattedTime = ''; - if (remainingHours < 10) formattedTime += '0'; - formattedTime += `${remainingHours}:`; - if (remainingMinutes < 10) formattedTime += '0'; - formattedTime += `${remainingMinutes}:`; - if (remainingSeconds < 10) formattedTime += '0'; - formattedTime += remainingSeconds; - return formattedTime; -}; - -/** - * Formats dates in Pickaday - * @param {String} dateString Date in yyyy-mm-dd format - * @return {Date} UTC format - */ -export const parsePikadayDate = (dateString) => { - const parts = dateString.split('-'); - const year = parseInt(parts[0], 10); - const month = parseInt(parts[1] - 1, 10); - const day = parseInt(parts[2], 10); - - return new Date(year, month, day); -}; - -/** - * Used `onSelect` method in pickaday - * @param {Date} date UTC format - * @return {String} Date formatted in yyyy-mm-dd - */ -export const pikadayToString = (date) => { - const day = pad(date.getDate()); - const month = pad(date.getMonth() + 1); - const year = date.getFullYear(); - - return `${year}-${month}-${day}`; -}; - -/** - * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } - * Seconds can be negative or positive, zero or non-zero. Can be configured for any day - * or week length. - */ -export const parseSeconds = ( - seconds, - { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {}, -) => { - const DAYS_PER_WEEK = daysPerWeek; - const HOURS_PER_DAY = hoursPerDay; - const SECONDS_PER_MINUTE = 60; - const MINUTES_PER_HOUR = 60; - const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; - const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; - - const timePeriodConstraints = { - weeks: MINUTES_PER_WEEK, - days: MINUTES_PER_DAY, - hours: MINUTES_PER_HOUR, - minutes: 1, - }; - - if (limitToDays || limitToHours) { - timePeriodConstraints.weeks = 0; - } - - if (limitToHours) { - timePeriodConstraints.days = 0; - } - - let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE); - - return mapValues(timePeriodConstraints, (minutesPerPeriod) => { - if (minutesPerPeriod === 0) { - return 0; - } - - const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); - - unorderedMinutes -= periodCount * minutesPerPeriod; - - return periodCount; - }); -}; - -/** - * Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it - * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included. - * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days' - */ -export const stringifyTime = (timeObject, fullNameFormat = false) => { - const reducedTime = reduce( - timeObject, - (memo, unitValue, unitName) => { - const isNonZero = Boolean(unitValue); - - if (fullNameFormat && isNonZero) { - // Remove traling 's' if unit value is singular - const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); - return `${memo} ${unitValue} ${formattedUnitName}`; - } - - return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; - }, - '', - ).trim(); - return reducedTime.length ? reducedTime : '0m'; -}; - -/** - * Calculates the milliseconds between now and a given date string. - * The result cannot become negative. - * - * @param endDate date string that the time difference is calculated for - * @return {Number} number of milliseconds remaining until the given date - */ -export const calculateRemainingMilliseconds = (endDate) => { - const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); - return Math.max(remainingMilliseconds, 0); -}; - -/** - * Subtracts a given number of days from a given date and returns the new date. - * - * @param {Date} date the date that we will substract days from - * @param {Number} daysInPast number of days that are subtracted from a given date - * @returns {Date} Date in past as Date object - */ -export const getDateInPast = (date, daysInPast) => - new Date(newDate(date).setDate(date.getDate() - daysInPast)); - -/** - * Adds a given number of days to a given date and returns the new date. - * - * @param {Date} date the date that we will add days to - * @param {Number} daysInFuture number of days that are added to a given date - * @returns {Date} Date in future as Date object - */ -export const getDateInFuture = (date, daysInFuture) => - new Date(newDate(date).setDate(date.getDate() + daysInFuture)); - -/** - * Checks if a given date-instance was created with a valid date - * - * @param {Date} date - * @returns boolean - */ -export const isValidDate = (date) => date instanceof Date && !Number.isNaN(date.getTime()); - -/* - * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date - * to match the user's time zone. We want to display the date in server time for now, to - * be consistent with the "edit issue -> due date" UI. - */ - -export const newDateAsLocaleTime = (date) => { - const suffix = 'T00:00:00'; - return new Date(`${date}${suffix}`); -}; - -export const beginOfDayTime = 'T00:00:00Z'; -export const endOfDayTime = 'T23:59:59Z'; - -/** - * @param {Date} d1 - * @param {Date} d2 - * @param {Function} formatter - * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date) - */ -export const getDatesInRange = (d1, d2, formatter = (x) => x) => { - if (!(d1 instanceof Date) || !(d2 instanceof Date)) { - return []; - } - let startDate = d1.getTime(); - const endDate = d2.getTime(); - const oneDay = 24 * 3600 * 1000; - const range = [d1]; - - while (startDate < endDate) { - startDate += oneDay; - range.push(new Date(startDate)); - } - - return range.map(formatter); -}; - -/** - * Converts the supplied number of seconds to milliseconds. - * - * @param {Number} seconds - * @return {Number} number of milliseconds - */ -export const secondsToMilliseconds = (seconds) => seconds * 1000; - -/** - * Converts the supplied number of seconds to days. - * - * @param {Number} seconds - * @return {Number} number of days - */ -export const secondsToDays = (seconds) => Math.round(seconds / 86400); - -/** - * Converts a numeric utc offset in seconds to +/- hours - * ie -32400 => -9 hours - * ie -12600 => -3.5 hours - * - * @param {Number} offset UTC offset in seconds as a integer - * - * @return {String} the + or - offset in hours - */ -export const secondsToHours = (offset) => { - const parsed = parseInt(offset, 10); - if (Number.isNaN(parsed) || parsed === 0) { - return `0`; - } - const num = offset / 3600; - return parseInt(num, 10) !== num ? num.toFixed(1) : num; -}; - -/** - * Returns the date `n` days after the date provided - * - * @param {Date} date the initial date - * @param {Number} numberOfDays number of days after - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * - * @return {Date} A `Date` object `n` days after the provided `Date` - */ -export const nDaysAfter = (date, numberOfDays, { utc = false } = {}) => { - const clone = newDate(date); - - const cloneValue = utc - ? clone.setUTCDate(date.getUTCDate() + numberOfDays) - : clone.setDate(date.getDate() + numberOfDays); - - return new Date(cloneValue); -}; - -/** - * Returns the date `n` days before the date provided - * - * @param {Date} date the initial date - * @param {Number} numberOfDays number of days before - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * @return {Date} A `Date` object `n` days before the provided `Date` - */ -export const nDaysBefore = (date, numberOfDays, options) => - nDaysAfter(date, -numberOfDays, options); - -/** - * Returns the date `n` weeks after the date provided - * - * @param {Date} date the initial date - * @param {Number} numberOfWeeks number of weeks after - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * - * @return {Date} A `Date` object `n` weeks after the provided `Date` - */ -export const nWeeksAfter = (date, numberOfWeeks, options) => - nDaysAfter(date, DAYS_IN_WEEK * numberOfWeeks, options); - -/** - * Returns the date `n` weeks before the date provided - * - * @param {Date} date the initial date - * @param {Number} numberOfWeeks number of weeks before - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * - * @return {Date} A `Date` object `n` weeks before the provided `Date` - */ -export const nWeeksBefore = (date, numberOfWeeks, options) => - nWeeksAfter(date, -numberOfWeeks, options); - -/** - * Returns the date `n` months after the date provided - * - * @param {Date} date the initial date - * @param {Number} numberOfMonths number of months after - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * - * @return {Date} A `Date` object `n` months after the provided `Date` - */ -export const nMonthsAfter = (date, numberOfMonths, { utc = false } = {}) => { - const clone = newDate(date); - - const cloneValue = utc - ? clone.setUTCMonth(date.getUTCMonth() + numberOfMonths) - : clone.setMonth(date.getMonth() + numberOfMonths); - - return new Date(cloneValue); -}; - -/** - * Returns the date `n` years after the date provided. - * - * @param {Date} date the initial date - * @param {Number} numberOfYears number of years after - * @return {Date} A `Date` object `n` years after the provided `Date` - */ -export const nYearsAfter = (date, numberOfYears) => { - const clone = newDate(date); - clone.setFullYear(clone.getFullYear() + numberOfYears); - return clone; -}; - -/** - * Returns the date `n` months before the date provided - * - * @param {Date} date the initial date - * @param {Number} numberOfMonths number of months before - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * - * @return {Date} A `Date` object `n` months before the provided `Date` - */ -export const nMonthsBefore = (date, numberOfMonths, options) => - nMonthsAfter(date, -numberOfMonths, options); - -/** - * Returns the date after the date provided - * - * @param {Date} date the initial date - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC dates. - * This will cause Daylight Saving Time to be ignored. Defaults to `false` - * if not provided, which causes the calculation to be performed in the - * user's timezone. - * - * @return {Date} the date following the date provided - */ -export const dayAfter = (date, options) => nDaysAfter(date, 1, options); - -/** - * Mimics the behaviour of the rails distance_of_time_in_words function - * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words - * 0 < -> 29 secs => less than a minute - * 30 secs < -> 1 min, 29 secs => 1 minute - * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes - * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour - * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours - * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day - * 41 hrs, 59 mins, 30 secs => x days - * - * @param {Number} seconds - * @return {String} approximated time - */ -export const approximateDuration = (seconds = 0) => { - if (!isNumber(seconds) || seconds < 0) { - return ''; - } - - const ONE_MINUTE_LIMIT = 90; // 1 minute 30s - const MINUTES_LIMIT = 2670; // 44 minutes 30s - const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s - const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s - const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s - - const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, { - daysPerWeek: 7, - hoursPerDay: 24, - limitToDays: true, - }); - - if (seconds < 30) { - return __('less than a minute'); - } else if (seconds < MINUTES_LIMIT) { - return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes); - } else if (seconds < HOURS_LIMIT) { - return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours); - } - return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); -}; - -/** - * A utility function which helps creating a date object - * for a specific date. Accepts the year, month and day - * returning a date object for the given params. - * - * @param {Int} year the full year as a number i.e. 2020 - * @param {Int} month the month index i.e. January => 0 - * @param {Int} day the day as a number i.e. 23 - * - * @return {Date} the date object from the params - */ -export const dateFromParams = (year, month, day) => { - return new Date(year, month, day); -}; - -/** - * A utility function which computes the difference in seconds - * between 2 dates. - * - * @param {Date} startDate the start date - * @param {Date} endDate the end date - * - * @return {Int} the difference in seconds - */ -export const differenceInSeconds = (startDate, endDate) => { - return (endDate.getTime() - startDate.getTime()) / 1000; -}; - -/** - * A utility function which computes the difference in months - * between 2 dates. - * - * @param {Date} startDate the start date - * @param {Date} endDate the end date - * - * @return {Int} the difference in months - */ -export const differenceInMonths = (startDate, endDate) => { - const yearDiff = endDate.getYear() - startDate.getYear(); - const monthDiff = endDate.getMonth() - startDate.getMonth(); - return monthDiff + 12 * yearDiff; -}; - -/** - * A utility function which computes the difference in milliseconds - * between 2 dates. - * - * @param {Date|Int} startDate the start date. Can be either a date object or a unix timestamp. - * @param {Date|Int} endDate the end date. Can be either a date object or a unix timestamp. Defaults to now. - * - * @return {Int} the difference in milliseconds - */ -export const differenceInMilliseconds = (startDate, endDate = Date.now()) => { - const startDateInMS = startDate instanceof Date ? startDate.getTime() : startDate; - const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate; - return endDateInMS - startDateInMS; -}; - -/** - * A utility which returns a new date at the first day of the month for any given date. - * - * @param {Date} date - * - * @return {Date} the date at the first day of the month - */ -export const dateAtFirstDayOfMonth = (date) => new Date(newDate(date).setDate(1)); - -/** - * A utility function which checks if two dates match. - * - * @param {Date|Int} date1 Can be either a date object or a unix timestamp. - * @param {Date|Int} date2 Can be either a date object or a unix timestamp. - * - * @return {Boolean} true if the dates match - */ -export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0; - -/** - * A utility function which computes a formatted 24 hour - * time string from a positive int in the range 0 - 24. - * - * @param {Int} time a positive Int between 0 and 24 - * - * @returns {String} formatted 24 hour time String - */ -export const format24HourTimeStringFromInt = (time) => { - if (!Number.isInteger(time) || time < 0 || time > 24) { - return ''; - } - - const formatted24HourString = time > 9 ? `${time}:00` : `0${time}:00`; - return formatted24HourString; -}; - -/** - * A utility function that checks that the date is today - * - * @param {Date} date - * - * @return {Boolean} true if provided date is today - */ -export const isToday = (date) => { - const today = new Date(); - return ( - date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear() - ); -}; - -/** - * Checks whether the date is in the past. - * - * @param {Date} date - * @return {Boolean} Returns true if the date falls before today, otherwise false. - */ -export const isInPast = (date) => !isToday(date) && differenceInMilliseconds(date, Date.now()) > 0; - -/** - * Checks whether the date is in the future. - * . - * @param {Date} date - * @return {Boolean} Returns true if the date falls after today, otherwise false. - */ -export const isInFuture = (date) => - !isToday(date) && differenceInMilliseconds(Date.now(), date) > 0; - -/** - * Checks whether dateA falls before dateB. - * - * @param {Date} dateA - * @param {Date} dateB - * @return {Boolean} Returns true if dateA falls before dateB, otherwise false - */ -export const fallsBefore = (dateA, dateB) => differenceInMilliseconds(dateA, dateB) > 0; - -/** - * Removes the time component of the date. - * - * @param {Date} date - * @return {Date} Returns a clone of the date with the time set to midnight - */ -export const removeTime = (date) => { - const clone = newDate(date); - clone.setHours(0, 0, 0, 0); - return clone; -}; - -/** - * Calculates the time remaining from today in words in the format - * `n days/weeks/months/years remaining`. - * - * @param {Date} date A date in future - * @return {String} The time remaining in the format `n days/weeks/months/years remaining` - */ -export const getTimeRemainingInWords = (date) => { - const today = removeTime(new Date()); - const dateInFuture = removeTime(date); - - const oneWeekFromNow = nWeeksAfter(today, 1); - const oneMonthFromNow = nMonthsAfter(today, 1); - const oneYearFromNow = nYearsAfter(today, 1); - - if (fallsBefore(dateInFuture, oneWeekFromNow)) { - const days = getDayDifference(today, dateInFuture); - return n__('1 day remaining', '%d days remaining', days); - } - - if (fallsBefore(dateInFuture, oneMonthFromNow)) { - const weeks = Math.floor(getDayDifference(today, dateInFuture) / 7); - return n__('1 week remaining', '%d weeks remaining', weeks); - } - - if (fallsBefore(dateInFuture, oneYearFromNow)) { - const months = differenceInMonths(today, dateInFuture); - return n__('1 month remaining', '%d months remaining', months); - } - - const years = dateInFuture.getFullYear() - today.getFullYear(); - return n__('1 year remaining', '%d years remaining', years); -}; - -/** - * Returns the start of the provided day - * - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC time. - * If `true`, the time returned will be midnight UTC. If `false` (the default) - * the time returned will be midnight in the user's local time. - * - * @returns {Date} A new `Date` object that represents the start of the day - * of the provided date - */ -export const getStartOfDay = (date, { utc = false } = {}) => { - const clone = newDate(date); - - const cloneValue = utc ? clone.setUTCHours(0, 0, 0, 0) : clone.setHours(0, 0, 0, 0); - - return new Date(cloneValue); -}; - -/** - * Returns the start of the current week against the provide date - * - * @param {Date} date The current date instance to calculate against - * @param {Object} [options={}] Additional options for this calculation - * @param {boolean} [options.utc=false] Performs the calculation using UTC time. - * If `true`, the time returned will be midnight UTC. If `false` (the default) - * the time returned will be midnight in the user's local time. - * - * @returns {Date} A new `Date` object that represents the start of the current week - * of the provided date - */ -export const getStartOfWeek = (date, { utc = false } = {}) => { - const cloneValue = utc - ? new Date(date.setUTCHours(0, 0, 0, 0)) - : new Date(date.setHours(0, 0, 0, 0)); - - const diff = cloneValue.getDate() - cloneValue.getDay() + (cloneValue.getDay() === 0 ? -6 : 1); - - return new Date(date.setDate(diff)); -}; +export * from './datetime/timeago_utility'; +export * from './datetime/date_format_utility'; +export * from './datetime/date_calculation_utility'; +export * from './datetime/pikaday_utility'; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index e3500d02a79..f3dedb7726a 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -72,11 +72,13 @@ export function bytesToGiB(number) { * @returns {String} */ export function numberToHumanSize(size) { - if (size < BYTES_IN_KIB) { + const abs = Math.abs(size); + + if (abs < BYTES_IN_KIB) { return sprintf(__('%{size} bytes'), { size }); - } else if (size < BYTES_IN_KIB * BYTES_IN_KIB) { + } else if (abs < BYTES_IN_KIB ** 2) { return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) }); - } else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) { + } else if (abs < BYTES_IN_KIB ** 3) { return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) }); } return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) }); diff --git a/app/assets/javascripts/lib/utils/table_utility.js b/app/assets/javascripts/lib/utils/table_utility.js new file mode 100644 index 00000000000..33db7686e0f --- /dev/null +++ b/app/assets/javascripts/lib/utils/table_utility.js @@ -0,0 +1,9 @@ +import { DEFAULT_TH_CLASSES } from './constants'; + +/** + * Generates the table header classes to be used for GlTable fields. + * + * @param {Number} width - The column width as a percentage. + * @returns {String} The classes to be used in GlTable fields object. + */ +export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 5b3aa3cf9dc..48abc072675 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -323,7 +323,7 @@ export function isAbsolute(url) { * @param {String} url */ export function isRootRelative(url) { - return /^\//.test(url); + return /^\/(?!\/)/.test(url); } /** @@ -414,29 +414,35 @@ export function getWebSocketUrl(path) { * * @param {String} query from "document.location.search" * @param {Object} options - * @param {Boolean} options.gatherArrays - gather array values into an Array + * @param {Boolean?} options.gatherArrays - gather array values into an Array + * @param {Boolean?} options.legacySpacesDecode - (deprecated) plus symbols (+) are not replaced with spaces, false by default * @returns {Object} * * ex: "?one=1&two=2" into {one: 1, two: 2} */ -export function queryToObject(query, options = {}) { - const { gatherArrays = false } = options; +export function queryToObject(query, { gatherArrays = false, legacySpacesDecode = false } = {}) { const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query; return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => { const [key, value] = curr.split('='); if (value === undefined) { return accumulator; } - const decodedValue = decodeURIComponent(value); + + const decodedValue = legacySpacesDecode ? decodeURIComponent(value) : decodeUrlParameter(value); if (gatherArrays && key.endsWith('[]')) { - const decodedKey = decodeURIComponent(key.slice(0, -2)); + const decodedKey = legacySpacesDecode + ? decodeURIComponent(key.slice(0, -2)) + : decodeUrlParameter(key.slice(0, -2)); + if (!Array.isArray(accumulator[decodedKey])) { accumulator[decodedKey] = []; } accumulator[decodedKey].push(decodedValue); } else { - accumulator[decodeURIComponent(key)] = decodedValue; + const decodedKey = legacySpacesDecode ? decodeURIComponent(key) : decodeUrlParameter(key); + + accumulator[decodedKey] = decodedValue; } return accumulator; @@ -484,13 +490,17 @@ export const setUrlParams = ( searchParams.delete(key); } else if (Array.isArray(params[key])) { const keyName = railsArraySyntax ? `${key}[]` : key; - params[key].forEach((val, idx) => { - if (idx === 0) { - searchParams.set(keyName, val); - } else { - searchParams.append(keyName, val); - } - }); + if (params[key].length === 0) { + searchParams.delete(keyName); + } else { + params[key].forEach((val, idx) => { + if (idx === 0) { + searchParams.set(keyName, val); + } else { + searchParams.append(keyName, val); + } + }); + } } else { searchParams.set(key, params[key]); } diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index 82fc816fe9e..e1749331d90 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -15,8 +15,10 @@ export default (input, parameters, escapeParameters = true) => { let output = input; if (parameters) { - Object.keys(parameters).forEach((parameterName) => { - const parameterValue = parameters[parameterName]; + const mappedParameters = new Map(Object.entries(parameters)); + + mappedParameters.forEach((key, parameterName) => { + const parameterValue = mappedParameters.get(parameterName); const escapedParameterValue = escapeParameters ? escape(parameterValue) : parameterValue; output = output.replace(new RegExp(`%{${parameterName}}`, 'g'), escapedParameterValue); }); diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index 5092c6905c4..39041aa1447 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -5,7 +5,6 @@ import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, - GlDropdownDivider, GlInfiniteScroll, } from '@gitlab/ui'; import { throttle } from 'lodash'; @@ -25,7 +24,6 @@ export default { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, - GlDropdownDivider, GlInfiniteScroll, LogSimpleFilters, LogAdvancedFilters, @@ -66,7 +64,7 @@ export default { }; }, computed: { - ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods', 'managedApps']), + ...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']), ...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']), showLoader() { @@ -88,15 +86,12 @@ export default { }); this.fetchEnvironments(this.environmentsPath); - this.fetchManagedApps(this.clustersPath); }, methods: { ...mapActions('environmentLogs', [ 'setInitData', 'showEnvironment', - 'showManagedApp', 'fetchEnvironments', - 'fetchManagedApps', 'refreshPodLogs', 'fetchMoreLogsPrepend', 'dismissRequestEnvironmentsError', @@ -107,9 +102,6 @@ export default { isCurrentEnvironment(envName) { return envName === this.environments.current; }, - isCurrentManagedApp(appName) { - return appName === this.managedApps.current; - }, topReached() { if (!this.logs.isLoading) { this.fetchMoreLogsPrepend(); @@ -173,7 +165,7 @@ export default { <div class="flex-grow-0"> <gl-dropdown id="environments-dropdown" - :text="environments.current || managedApps.current" + :text="environments.current" :disabled="environments.isLoading" class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown" > @@ -189,19 +181,6 @@ export default { > {{ env.name }} </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-section-header> - {{ s__('Environments|Managed apps') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="app in managedApps.options" - :key="app.id" - :is-check-item="true" - :is-checked="isCurrentManagedApp(app.name)" - @click="showManagedApp(app.name)" - > - {{ app.name }} - </gl-dropdown-item> </gl-dropdown> </div> diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js index 0cdef53df34..abc4d6679a0 100644 --- a/app/assets/javascripts/logs/constants.js +++ b/app/assets/javascripts/logs/constants.js @@ -13,5 +13,4 @@ export const tracking = { export const logExplorerOptions = { environments: 'environments', - managedApps: 'managedApps', }; diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index c3dc9f4bc12..56b832de9b8 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -25,15 +25,9 @@ const requestUntilData = (url, params) => const requestLogsUntilData = ({ commit, state }) => { const params = {}; - const type = state.environments.current - ? logExplorerOptions.environments - : logExplorerOptions.managedApps; + const type = logExplorerOptions.environments; const selectedObj = state[type].options.find(({ name }) => name === state[type].current); - - const path = - type === logExplorerOptions.environments - ? selectedObj.logs_api_path - : selectedObj.gitlab_managed_apps_logs_path; + const path = selectedObj.logs_api_path; if (state.pods.current) { params.pod_name = state.pods.current; @@ -106,11 +100,6 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => { dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); }; -export const showManagedApp = ({ dispatch, commit }, managedApp) => { - commit(types.SET_MANAGED_APP, managedApp); - dispatch('fetchLogs', tracking.MANAGED_APP_SELECTED); -}; - export const refreshPodLogs = ({ dispatch, commit }) => { commit(types.REFRESH_POD_LOGS); dispatch('fetchLogs', tracking.REFRESH_POD_LOGS); @@ -135,23 +124,6 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { }); }; -/** - * Fetch managed apps data - * @param {Object} store - * @param {String} clustersPath - */ - -export const fetchManagedApps = ({ commit }, clustersPath) => { - return axios - .get(clustersPath) - .then(({ data }) => { - commit(types.RECEIVE_MANAGED_APPS_DATA_SUCCESS, data.clusters); - }) - .catch(() => { - commit(types.RECEIVE_MANAGED_APPS_DATA_ERROR); - }); -}; - export const fetchLogs = ({ commit, state }, trackingLabel) => { commit(types.REQUEST_LOGS_DATA); diff --git a/app/assets/javascripts/logs/stores/getters.js b/app/assets/javascripts/logs/stores/getters.js index 836e6e82385..bf71cfd8eb2 100644 --- a/app/assets/javascripts/logs/stores/getters.js +++ b/app/assets/javascripts/logs/stores/getters.js @@ -6,16 +6,9 @@ const mapTrace = ({ timestamp = null, pod = '', message = '' }) => export const trace = (state) => state.logs.lines.map(mapTrace).join('\n'); export const showAdvancedFilters = (state) => { - if (state.environments.current) { - const environment = state.environments.options.find( - ({ name }) => name === state.environments.current, - ); - - return Boolean(environment?.enable_advanced_logs_querying); - } - const managedApp = state.managedApps.options.find( - ({ name }) => name === state.managedApps.current, + const environment = state.environments.options.find( + ({ name }) => name === state.environments.current, ); - return Boolean(managedApp?.enable_advanced_logs_querying); + return Boolean(environment?.enable_advanced_logs_querying); }; diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js index eaa4b13f8bd..c1ed65ff48b 100644 --- a/app/assets/javascripts/logs/stores/mutation_types.js +++ b/app/assets/javascripts/logs/stores/mutation_types.js @@ -13,9 +13,6 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'; export const HIDE_REQUEST_ENVIRONMENTS_ERROR = 'HIDE_REQUEST_ENVIRONMENTS_ERROR'; -export const RECEIVE_MANAGED_APPS_DATA_SUCCESS = 'RECEIVE_MANAGED_APPS_DATA_SUCCESS'; -export const RECEIVE_MANAGED_APPS_DATA_ERROR = 'RECEIVE_MANAGED_APPS_DATA_ERROR'; - export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA'; export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS'; export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR'; diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js index 21031838adf..6736d7204b4 100644 --- a/app/assets/javascripts/logs/stores/mutations.js +++ b/app/assets/javascripts/logs/stores/mutations.js @@ -32,9 +32,6 @@ export default { // Clear current pod options state.pods.current = null; state.pods.options = []; - - // Clear current managedApps options - state.managedApps.current = null; }, [types.REQUEST_ENVIRONMENTS_DATA](state) { state.environments.options = []; @@ -110,26 +107,4 @@ export default { [types.RECEIVE_PODS_DATA_ERROR](state) { state.pods.options = []; }, - // Managed apps data - [types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, apps) { - state.managedApps.options = apps.filter( - ({ gitlab_managed_apps_logs_path }) => gitlab_managed_apps_logs_path, // eslint-disable-line babel/camelcase - ); - state.managedApps.isLoading = false; - }, - [types.RECEIVE_MANAGED_APPS_DATA_ERROR](state) { - state.managedApps.options = []; - state.managedApps.isLoading = false; - state.managedApps.fetchError = true; - }, - [types.SET_MANAGED_APP](state, managedApp) { - state.managedApps.current = managedApp; - - // Clear current pod options - state.pods.current = null; - state.pods.options = []; - - // Clear current environment options - state.environments.current = null; - }, }; diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js index e2028621ac8..83080589362 100644 --- a/app/assets/javascripts/logs/stores/state.js +++ b/app/assets/javascripts/logs/stores/state.js @@ -31,16 +31,6 @@ export default () => ({ }, /** - * Managed apps list information - */ - managedApps: { - options: [], - isLoading: false, - current: null, - fetchError: false, - }, - - /** * Logs including trace */ logs: { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 6200ade3595..2309f7a420f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -20,7 +20,7 @@ import { removeFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; -import { localTimeAgo } from './lib/utils/datetime_utility'; +import { localTimeAgo } from './lib/utils/datetime/timeago_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else @@ -36,6 +36,7 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; +import navEventHub, { EVENT_RESPONSIVE_TOGGLE } from './nav/event_hub'; import 'ee_else_ce/main_ee'; @@ -202,7 +203,11 @@ document.addEventListener('DOMContentLoaded', () => { }); $('.navbar-toggler').on('click', () => { + // The order is important. The `menu-expanded` is used as a source of truth for now. + // This can be simplified when the :combined_menu feature flag is removed. + // https://gitlab.com/gitlab-org/gitlab/-/issues/333180 $('.header-content').toggleClass('menu-expanded'); + navEventHub.$emit(EVENT_RESPONSIVE_TOGGLE); }); /** diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index 540314f8f9b..9613246d6a6 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -3,7 +3,7 @@ import { getBoardSortableDefaultOptions, sortableStart, } from '~/boards/mixins/sortable_default_options'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; @@ -15,7 +15,9 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => group_full_path: issueList.dataset.groupFullPath, }) .catch(() => { - createFlash(s__("ManualOrdering|Couldn't save the order of the issues")); + createFlash({ + message: s__("ManualOrdering|Couldn't save the order of the issues"), + }); }); const initManualOrdering = (draggableSelector = 'li.issue') => { diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index 585fabdf3ff..a08518584f3 100644 --- a/app/assets/javascripts/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -9,7 +9,17 @@ import MembersTable from './table/members_table.vue'; export default { name: 'MembersApp', components: { MembersTable, FilterSortContainer, GlAlert }, - inject: ['namespace'], + provide() { + return { + namespace: this.namespace, + }; + }, + props: { + namespace: { + type: String, + required: true, + }, + }, computed: { ...mapState({ showError(state) { diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index f84ded427cd..fa895cf24c4 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -78,7 +78,7 @@ export default { ref="glDropdown" :right="!isDesktop" :text="member.accessLevel.stringValue" - :header-text="__('Change permissions')" + :header-text="__('Change role')" :disabled="disabled" > <gl-dropdown-item diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 6c913af8a0f..2ed0958d1dc 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -2,20 +2,11 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { parseDataAttributes } from '~/members/utils'; -import App from './components/app.vue'; +import MembersTabs from './components/members_tabs.vue'; +import { MEMBER_TYPES } from './constants'; import membersStore from './store'; -export const initMembersApp = ( - el, - { - namespace, - tableFields = [], - tableAttrs = {}, - tableSortableFields = [], - requestFormatter = () => {}, - filteredSearchBar = { show: false }, - }, -) => { +export const initMembersApp = (el, options) => { if (!el) { return () => {}; } @@ -25,29 +16,45 @@ export const initMembersApp = ( const { sourceId, canManageMembers, ...vuexStoreAttributes } = parseDataAttributes(el); - const store = new Vuex.Store({ - modules: { + const modules = Object.keys(MEMBER_TYPES).reduce((accumulator, namespace) => { + const namespacedOptions = options[namespace]; + + if (!namespacedOptions) { + return accumulator; + } + + const { + tableFields = [], + tableAttrs = {}, + tableSortableFields = [], + requestFormatter = () => {}, + filteredSearchBar = { show: false }, + } = namespacedOptions; + + return { + ...accumulator, [namespace]: membersStore({ - ...vuexStoreAttributes, + ...vuexStoreAttributes[namespace], tableFields, tableAttrs, tableSortableFields, requestFormatter, filteredSearchBar, }), - }, - }); + }; + }, {}); + + const store = new Vuex.Store({ modules }); return new Vue({ el, - components: { App }, + components: { MembersTabs }, store, provide: { - namespace, currentUserId: gon.current_user_id || null, sourceId, canManageMembers, }, - render: (createElement) => createElement('app'), + render: (createElement) => createElement('members-tabs'), }); }; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 1a0156f8c0e..feaf8b0d996 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,7 +1,7 @@ /* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import eventHub from '~/vue_merge_request_widget/event_hub'; import axios from './lib/utils/axios_utils'; @@ -36,11 +36,11 @@ function MergeRequest(opts) { document.querySelector('#task_status_short').innerText = result.task_status_short; }, onError: () => { - createFlash( - __( + createFlash({ + message: __( 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', ), - ); + }); }, }); } @@ -93,7 +93,9 @@ MergeRequest.prototype.initMRBtnListeners = function () { }) .catch(() => { draftToggle.removeAttribute('disabled'); - createFlash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); }); }); }); @@ -169,7 +171,10 @@ MergeRequest.hideCloseButton = function () { MergeRequest.toggleDraftStatus = function (title, isReady) { if (isReady) { - createFlash(__('The merge request can now be merged.'), 'notice'); + createFlash({ + message: __('The merge request can now be merged.'), + type: 'notice', + }); } const titleEl = document.querySelector('.merge-request .detail-page-description .title'); diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue index 9724ef9950b..c18c13f2574 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -1,7 +1,7 @@ <script> import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; import { values, get } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import { OPERATORS } from '../constants'; @@ -130,7 +130,9 @@ export default { this.isLoading = false; }) .catch(() => { - createFlash(s__('PrometheusAlerts|Error fetching alert')); + createFlash({ + message: s__('PrometheusAlerts|Error fetching alert'), + }); this.isLoading = false; }); }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 05e7fb7a3e9..be9f104b81e 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -3,7 +3,7 @@ import { GlButton, GlModalDirective, GlTooltipDirective, GlIcon } from '@gitlab/ import Mousetrap from 'mousetrap'; import VueDraggable from 'vuedraggable'; import { mapActions, mapState, mapGetters } from 'vuex'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import invalidUrl from '~/lib/utils/invalid_url'; import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -11,6 +11,7 @@ import { s__ } from '~/locale'; import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { defaultTimeRange } from '~/vue_shared/constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { metricStates, keyboardShortcutKeys } from '../constants'; import { timeRangeFromUrl, @@ -46,6 +47,7 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, + mixins: [glFeatureFlagMixin()], props: { hasMetrics: { type: Boolean, @@ -176,11 +178,11 @@ export default { this.setExpandedPanel(expandedPanel); } } catch { - createFlash( - s__( + createFlash({ + message: s__( 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.', ), - ); + }); } }, expandedPanel: { @@ -201,12 +203,13 @@ export default { * This watcher is set for future SPA behaviour of the dashboard */ if (hasWarnings) { - createFlash( - s__( + createFlash({ + message: s__( 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.', ), - 'warning', - ); + + type: 'warning', + }); } }, }, @@ -318,11 +321,11 @@ export default { this.isRearrangingPanels = isRearrangingPanels; }, onDateTimePickerInvalid() { - createFlash( - s__( + createFlash({ + message: s__( 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', ), - ); + }); // As a fallback, switch to default time range instead this.selectedTimeRange = defaultTimeRange; }, @@ -396,7 +399,7 @@ export default { <template> <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> - <alerts-deprecation-warning /> + <alerts-deprecation-warning v-if="!glFeatures.managedAlertsDeprecation" /> <dashboard-header v-if="showHeader" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 55e73d17842..202d18ac721 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -21,6 +21,7 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { panelTypes } from '../constants'; import { graphDataToCsv } from '../csv_export'; @@ -61,6 +62,7 @@ export default { GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, + mixins: [glFeatureFlagMixin()], props: { clipboardText: { type: String, @@ -258,7 +260,8 @@ export default { this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData && - this.hasMetricsInDb + this.hasMetricsInDb && + !this.glFeatures.managedAlertsDeprecation ); }, alertModalId() { diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index a0b4fd0b608..215b4b7b2d7 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; @@ -134,15 +134,17 @@ export const fetchDashboard = ({ state, commit, dispatch, getters }) => { if (state.showErrorBanner) { if (error.response.data && error.response.data.message) { const { message } = error.response.data; - createFlash( - sprintf( + createFlash({ + message: sprintf( s__('Metrics|There was an error while retrieving metrics. %{message}'), { message }, false, ), - ); + }); } else { - createFlash(s__('Metrics|There was an error while retrieving metrics')); + createFlash({ + message: s__('Metrics|There was an error while retrieving metrics'), + }); } } }); @@ -174,7 +176,10 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { dispatch('fetchDeploymentsData'); if (!state.timeRange) { - createFlash(s__(`Metrics|Invalid time range, please verify.`), 'warning'); + createFlash({ + message: s__(`Metrics|Invalid time range, please verify.`), + type: 'warning', + }); return Promise.reject(); } @@ -202,7 +207,10 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { }); }) .catch(() => { - createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning'); + createFlash({ + message: s__(`Metrics|There was an error while retrieving metrics`), + type: 'warning', + }); }); }; @@ -254,7 +262,9 @@ export const fetchDeploymentsData = ({ state, dispatch }) => { .then((resp) => resp.data) .then((response) => { if (!response || !response.deployments) { - createFlash(s__('Metrics|Unexpected deployment data response from prometheus endpoint')); + createFlash({ + message: s__('Metrics|Unexpected deployment data response from prometheus endpoint'), + }); } dispatch('receiveDeploymentsDataSuccess', response.deployments); @@ -262,7 +272,9 @@ export const fetchDeploymentsData = ({ state, dispatch }) => { .catch((error) => { Sentry.captureException(error); dispatch('receiveDeploymentsDataFailure'); - createFlash(s__('Metrics|There was an error getting deployment information.')); + createFlash({ + message: s__('Metrics|There was an error getting deployment information.'), + }); }); }; export const receiveDeploymentsDataSuccess = ({ commit }, data) => { @@ -290,9 +302,11 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { ) .then((environments) => { if (!environments) { - createFlash( - s__('Metrics|There was an error fetching the environments data, please try again'), - ); + createFlash({ + message: s__( + 'Metrics|There was an error fetching the environments data, please try again', + ), + }); } dispatch('receiveEnvironmentsDataSuccess', environments); @@ -300,7 +314,9 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { .catch((err) => { Sentry.captureException(err); dispatch('receiveEnvironmentsDataFailure'); - createFlash(s__('Metrics|There was an error getting environments information.')); + createFlash({ + message: s__('Metrics|There was an error getting environments information.'), + }); }); }; export const requestEnvironmentsData = ({ commit }) => { @@ -332,7 +348,9 @@ export const fetchAnnotations = ({ state, dispatch, getters }) => { .then(parseAnnotationsResponse) .then((annotations) => { if (!annotations) { - createFlash(s__('Metrics|There was an error fetching annotations. Please try again.')); + createFlash({ + message: s__('Metrics|There was an error fetching annotations. Please try again.'), + }); } dispatch('receiveAnnotationsSuccess', annotations); @@ -340,7 +358,9 @@ export const fetchAnnotations = ({ state, dispatch, getters }) => { .catch((err) => { Sentry.captureException(err); dispatch('receiveAnnotationsFailure'); - createFlash(s__('Metrics|There was an error getting annotations information.')); + createFlash({ + message: s__('Metrics|There was an error getting annotations information.'), + }); }); }; @@ -377,9 +397,11 @@ export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) = .catch((err) => { Sentry.captureException(err); dispatch('receiveDashboardValidationWarningsFailure'); - createFlash( - s__('Metrics|There was an error getting dashboard validation warnings information.'), - ); + createFlash({ + message: s__( + 'Metrics|There was an error getting dashboard validation warnings information.', + ), + }); }); }; @@ -480,11 +502,14 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); }) .catch(() => { - createFlash( - sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), { - name: variable.name, - }), - ); + createFlash({ + message: sprintf( + s__('Metrics|There was an error getting options for variable "%{name}".'), + { + name: variable.name, + }, + ), + }); }); optionsRequests.push(optionsRequest); } diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 8adf1862af2..74b777d7b44 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -175,7 +175,7 @@ export const graphDataValidatorForAnomalyValues = (graphData) => { * Returns `null` if no parameters form a time range. */ export const timeRangeFromUrl = (search = window.location.search) => { - const params = queryToObject(search); + const params = queryToObject(search, { legacySpacesDecode: true }); return timeRangeFromParams(params); }; @@ -228,7 +228,7 @@ export const convertVariablesForURL = (variables) => * @returns {Object} The custom variables defined by the user in the URL */ export const templatingVariablesFromUrl = (search = window.location.search) => { - const params = queryToObject(search); + const params = queryToObject(search, { legacySpacesDecode: true }); // pick the params with variable prefix const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX)); // remove the prefix before storing in the Vuex store @@ -289,7 +289,7 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => { * @throws Will throw an error if Panel cannot be located. */ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => { - const params = queryToObject(search); + const params = queryToObject(search, { legacySpacesDecode: true }); // Search for the panel if any of the search params is identified if (params.group || params.title || params.y_label) { diff --git a/app/assets/javascripts/nav/components/responsive_app.vue b/app/assets/javascripts/nav/components/responsive_app.vue new file mode 100644 index 00000000000..d601586a3f8 --- /dev/null +++ b/app/assets/javascripts/nav/components/responsive_app.vue @@ -0,0 +1,107 @@ +<script> +import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; +import { BV_DROPDOWN_SHOW, BV_DROPDOWN_HIDE } from '~/lib/utils/constants'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; +import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '../event_hub'; +import { resetMenuItemsActive, hasMenuExpanded } from '../utils'; +import ResponsiveHeader from './responsive_header.vue'; +import ResponsiveHome from './responsive_home.vue'; +import TopNavContainerView from './top_nav_container_view.vue'; + +export default { + components: { + KeepAliveSlots, + ResponsiveHeader, + ResponsiveHome, + TopNavContainerView, + }, + props: { + navData: { + type: Object, + required: true, + }, + }, + data() { + return { + activeView: 'home', + hasMobileOverlay: false, + }; + }, + computed: { + nav() { + return resetMenuItemsActive(this.navData); + }, + }, + created() { + eventHub.$on(EVENT_RESPONSIVE_TOGGLE, this.updateResponsiveOpen); + this.$root.$on(BV_DROPDOWN_SHOW, this.showMobileOverlay); + this.$root.$on(BV_DROPDOWN_HIDE, this.hideMobileOverlay); + + this.updateResponsiveOpen(); + }, + beforeDestroy() { + eventHub.$off(EVENT_RESPONSIVE_TOGGLE, this.onToggle); + this.$root.$off(BV_DROPDOWN_SHOW, this.showMobileOverlay); + this.$root.$off(BV_DROPDOWN_HIDE, this.hideMobileOverlay); + }, + methods: { + updateResponsiveOpen() { + if (hasMenuExpanded()) { + document.body.classList.add('top-nav-responsive-open'); + } else { + document.body.classList.remove('top-nav-responsive-open'); + } + }, + onMenuItemClick({ view }) { + if (view) { + this.activeView = view; + } + }, + showMobileOverlay() { + this.hasMobileOverlay = true; + }, + hideMobileOverlay() { + this.hasMobileOverlay = false; + }, + }, + FREQUENT_ITEMS_PROJECTS, + FREQUENT_ITEMS_GROUPS, +}; +</script> + +<template> + <div> + <div + class="mobile-overlay" + :class="{ 'mobile-nav-open': hasMobileOverlay }" + data-testid="mobile-overlay" + ></div> + <keep-alive-slots :slot-key="activeView"> + <template #home> + <responsive-home :nav-data="nav" @menu-item-click="onMenuItemClick" /> + </template> + <template #projects> + <responsive-header @menu-item-click="onMenuItemClick"> + {{ __('Projects') }} + </responsive-header> + <top-nav-container-view + :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace" + :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule" + container-class="gl-px-3" + v-bind="nav.views.projects" + /> + </template> + <template #groups> + <responsive-header @menu-item-click="onMenuItemClick"> + {{ __('Groups') }} + </responsive-header> + <top-nav-container-view + :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace" + :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule" + container-class="gl-px-3" + v-bind="nav.views.groups" + /> + </template> + </keep-alive-slots> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/responsive_header.vue b/app/assets/javascripts/nav/components/responsive_header.vue new file mode 100644 index 00000000000..8a1d21993b7 --- /dev/null +++ b/app/assets/javascripts/nav/components/responsive_header.vue @@ -0,0 +1,37 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import TopNavMenuItem from './top_nav_menu_item.vue'; + +export default { + components: { + TopNavMenuItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + computed: { + menuItem() { + return { + id: 'home', + view: 'home', + icon: 'angle-left', + }; + }, + }, +}; +</script> + +<template> + <header class="gl-py-4 gl-display-flex gl-align-items-center"> + <top-nav-menu-item + v-gl-tooltip="{ title: s__('TopNav|Go back') }" + class="gl-p-3!" + :menu-item="menuItem" + icon-only + @click="$emit('menu-item-click', menuItem)" + /> + <span class="gl-font-size-h2 gl-font-weight-bold gl-ml-2"> + <slot></slot> + </span> + </header> +</template> diff --git a/app/assets/javascripts/nav/components/responsive_home.vue b/app/assets/javascripts/nav/components/responsive_home.vue new file mode 100644 index 00000000000..c8f2f0bfb10 --- /dev/null +++ b/app/assets/javascripts/nav/components/responsive_home.vue @@ -0,0 +1,62 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import TopNavMenuItem from './top_nav_menu_item.vue'; +import TopNavMenuSections from './top_nav_menu_sections.vue'; +import TopNavNewDropdown from './top_nav_new_dropdown.vue'; + +const NEW_VIEW = 'new'; +const SEARCH_VIEW = 'search'; + +export default { + components: { + TopNavMenuItem, + TopNavMenuSections, + TopNavNewDropdown, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + navData: { + type: Object, + required: true, + }, + }, + computed: { + menuSections() { + return [ + { id: 'primary', menuItems: this.navData.primary }, + { id: 'secondary', menuItems: this.navData.secondary }, + ].filter((x) => x.menuItems?.length); + }, + newDropdownViewModel() { + return this.navData.views[NEW_VIEW]; + }, + searchMenuItem() { + return this.navData.views[SEARCH_VIEW]; + }, + }, +}; +</script> + +<template> + <div> + <header class="gl-display-flex gl-align-items-center gl-py-4 gl-pl-4"> + <h1 class="gl-m-0 gl-font-size-h2 gl-reset-color gl-mr-auto">{{ __('Menu') }}</h1> + <top-nav-menu-item + v-if="searchMenuItem" + v-gl-tooltip="{ title: searchMenuItem.title }" + class="gl-ml-3" + :menu-item="searchMenuItem" + icon-only + /> + <top-nav-new-dropdown + v-if="newDropdownViewModel" + v-gl-tooltip="{ title: newDropdownViewModel.title }" + :view-model="newDropdownViewModel" + class="gl-ml-3" + /> + </header> + <top-nav-menu-sections class="gl-h-full" :sections="menuSections" v-on="$listeners" /> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue index f8f3ba26536..08a2c6952c8 100644 --- a/app/assets/javascripts/nav/components/top_nav_app.vue +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -1,16 +1,12 @@ <script> -import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlNav, GlNavItemDropdown, GlDropdownForm } from '@gitlab/ui'; import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; -const TOOLTIP = s__('TopNav|Switch to...'); - export default { components: { GlNav, GlNavItemDropdown, GlDropdownForm, - GlTooltip, TopNavDropdownMenu, }, props: { @@ -19,15 +15,6 @@ export default { required: true, }, }, - methods: { - findTooltipTarget() { - // ### Why use a target function instead of `v-gl-tooltip`? - // To get the tooltip to align correctly, we need it to target the actual - // toggle button which we don't directly render. - return this.$el.querySelector('.js-top-nav-dropdown-toggle'); - }, - }, - TOOLTIP, }; </script> @@ -35,10 +22,13 @@ export default { <gl-nav class="navbar-sub-nav"> <gl-nav-item-dropdown :text="navData.activeTitle" - icon="dot-grid" - menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!" + data-qa-selector="navbar_dropdown" + :data-qa-title="navData.activeTitle" + icon="hamburger" + 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 > <gl-dropdown-form> <top-nav-dropdown-menu @@ -48,12 +38,5 @@ export default { /> </gl-dropdown-form> </gl-nav-item-dropdown> - <gl-tooltip - boundary="window" - :boundary-padding="0" - :target="findTooltipTarget" - placement="right" - :title="$options.TOOLTIP" - /> </gl-nav> </template> diff --git a/app/assets/javascripts/nav/components/top_nav_container_view.vue b/app/assets/javascripts/nav/components/top_nav_container_view.vue index 21ff3ebcd7d..6f98f85ff90 100644 --- a/app/assets/javascripts/nav/components/top_nav_container_view.vue +++ b/app/assets/javascripts/nav/components/top_nav_container_view.vue @@ -2,14 +2,15 @@ import FrequentItemsApp from '~/frequent_items/components/app.vue'; import eventHub from '~/frequent_items/event_hub'; import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; -import TopNavMenuItem from './top_nav_menu_item.vue'; +import TopNavMenuSections from './top_nav_menu_sections.vue'; export default { components: { FrequentItemsApp, - TopNavMenuItem, + TopNavMenuSections, VuexModuleProvider, }, + inheritAttrs: false, props: { frequentItemsVuexModule: { type: String, @@ -19,6 +20,11 @@ export default { type: String, required: true, }, + containerClass: { + type: String, + required: false, + default: '', + }, linksPrimary: { type: Array, required: false, @@ -31,11 +37,11 @@ export default { }, }, computed: { - linkGroups() { + menuSections() { return [ - { key: 'primary', links: this.linksPrimary }, - { key: 'secondary', links: this.linksSecondary }, - ].filter((x) => x.links?.length); + { id: 'primary', menuItems: this.linksPrimary }, + { id: 'secondary', menuItems: this.linksSecondary }, + ].filter((x) => x.menuItems?.length); }, }, mounted() { @@ -49,26 +55,17 @@ export default { <template> <div class="top-nav-container-view gl-display-flex gl-flex-direction-column"> - <div class="frequent-items-dropdown-container gl-w-auto"> + <div + class="frequent-items-dropdown-container gl-w-auto" + :class="containerClass" + data-testid="frequent-items-container" + > <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!"> <vuex-module-provider :vuex-module="frequentItemsVuexModule"> <frequent-items-app v-bind="$attrs" /> </vuex-module-provider> </div> </div> - <div - v-for="({ key, links }, groupIndex) in linkGroups" - :key="key" - :class="{ 'gl-mt-3': groupIndex !== 0 }" - class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100" - data-testid="menu-item-group" - > - <top-nav-menu-item - v-for="(link, linkIndex) in links" - :key="link.title" - :menu-item="link" - :class="{ 'gl-mt-1': linkIndex !== 0 }" - /> - </div> + <top-nav-menu-sections class="gl-mt-auto" :sections="menuSections" with-top-border /> </div> </template> diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue index 1cbd64b501d..cac8fecb6b1 100644 --- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -1,17 +1,15 @@ <script> +import { cloneDeep } from 'lodash'; import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; import TopNavContainerView from './top_nav_container_view.vue'; -import TopNavMenuItem from './top_nav_menu_item.vue'; - -const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active'; -const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100'; +import TopNavMenuSections from './top_nav_menu_sections.vue'; export default { components: { KeepAliveSlots, TopNavContainerView, - TopNavMenuItem, + TopNavMenuSections, }, props: { primary: { @@ -31,29 +29,25 @@ export default { }, }, data() { + // It's expected that primary & secondary never change, so these are treated as "init" props. + // We need to clone so that we can mutate the data without mutating the props + const menuSections = [ + { id: 'primary', menuItems: cloneDeep(this.primary) }, + { id: 'secondary', menuItems: cloneDeep(this.secondary) }, + ].filter((x) => x.menuItems?.length); + return { - activeId: '', + menuSections, }; }, computed: { - menuItemGroups() { - return [ - { key: 'primary', items: this.primary, classes: '' }, - { - key: 'secondary', - items: this.secondary, - classes: SECONDARY_GROUP_CLASS, - }, - ].filter((x) => x.items?.length); - }, allMenuItems() { - return this.menuItemGroups.flatMap((x) => x.items); - }, - activeMenuItem() { - return this.allMenuItems.find((x) => x.id === this.activeId); + return this.menuSections.flatMap((x) => x.menuItems); }, activeView() { - return this.activeMenuItem?.view; + const active = this.allMenuItems.find((x) => x.active); + + return active?.view; }, menuClass() { if (!this.activeView) { @@ -63,67 +57,33 @@ export default { return ''; }, }, - created() { - // Initialize activeId based on initialization prop - this.activeId = this.allMenuItems.find((x) => x.active)?.id; - }, methods: { - onClick({ id, href }) { - // If we're a link, let's just do the default behavior so the view won't change - if (href) { - return; - } - - this.activeId = id; - }, - menuItemClasses(menuItem) { - if (menuItem.id === this.activeId) { - return ACTIVE_CLASS; - } - - return ''; + onMenuItemClick({ id }) { + this.allMenuItems.forEach((menuItem) => { + this.$set(menuItem, 'active', id === menuItem.id); + }); }, }, FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS, - // expose for unit tests - ACTIVE_CLASS, - SECONDARY_GROUP_CLASS, }; </script> <template> <div class="gl-display-flex gl-align-items-stretch"> <div - class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10" + class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-py-3 gl-px-5" :class="menuClass" data-testid="menu-sidebar" > - <div - class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column" - > - <div - v-for="group in menuItemGroups" - :key="group.key" - :class="group.classes" - data-testid="menu-item-group" - > - <top-nav-menu-item - v-for="(menu, index) in group.items" - :key="menu.id" - data-testid="menu-item" - :class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]" - :menu-item="menu" - @click="onClick(menu)" - /> - </div> - </div> + <top-nav-menu-sections :sections="menuSections" @menu-item-click="onMenuItemClick" /> </div> <keep-alive-slots v-show="activeView" :slot-key="activeView" class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5" data-testid="menu-subview" + data-qa-selector="menu_subview_container" > <template #projects> <top-nav-container-view diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue index a0d92811a6f..08b2fbf2ed1 100644 --- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue +++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue @@ -1,5 +1,10 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; +import { kebabCase, mapKeys } from 'lodash'; + +const getDataKey = (key) => `data-${kebabCase(key)}`; + +const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active'; export default { components: { @@ -11,7 +16,18 @@ export default { type: Object, required: true, }, + iconOnly: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + dataAttrs() { + return mapKeys(this.menuItem.data || {}, (value, key) => getDataKey(key)); + }, }, + ACTIVE_CLASS, }; </script> @@ -20,12 +36,17 @@ export default { category="tertiary" :href="menuItem.href" class="top-nav-menu-item gl-display-block" + :class="[menuItem.css_class, { [$options.ACTIVE_CLASS]: menuItem.active }]" + :aria-label="menuItem.title" + v-bind="dataAttrs" v-on="$listeners" > <span class="gl-display-flex"> - <gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" /> - {{ menuItem.title }} - <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> + <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-2!': !iconOnly }" /> + <template v-if="!iconOnly"> + {{ menuItem.title }} + <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> + </template> </span> </gl-button> </template> diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue new file mode 100644 index 00000000000..442af512350 --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue @@ -0,0 +1,63 @@ +<script> +import TopNavMenuItem from './top_nav_menu_item.vue'; + +const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100'; + +export default { + components: { + TopNavMenuItem, + }, + props: { + sections: { + type: Array, + required: true, + }, + withTopBorder: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + onClick(menuItem) { + // If we're a link, let's just do the default behavior so the view won't change + if (menuItem.href) { + return; + } + + this.$emit('menu-item-click', menuItem); + }, + getMenuSectionClasses(index) { + // This is a method instead of a computed so we don't have to incur the cost of + // creating a whole new array/object. + return { + [BORDER_CLASSES]: this.withTopBorder || index > 0, + 'gl-mt-3': index > 0, + }; + }, + }, + // Expose for unit tests + BORDER_CLASSES, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-stretch gl-flex-direction-column"> + <div + v-for="({ id, menuItems }, sectionIndex) in sections" + :key="id" + :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)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue new file mode 100644 index 00000000000..154bed81854 --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue @@ -0,0 +1,55 @@ +<script> +import { GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + }, + props: { + viewModel: { + type: Object, + required: true, + }, + }, + computed: { + sections() { + return this.viewModel.menu_sections || []; + }, + showHeaders() { + return this.sections.length > 1; + }, + }, +}; +</script> + +<template> + <gl-dropdown + toggle-class="top-nav-menu-item" + icon="plus" + :text="viewModel.title" + category="tertiary" + text-sr-only + no-caret + right + > + <template v-for="({ title, menu_items }, index) in sections"> + <gl-dropdown-divider v-if="index > 0" :key="`${index}_divider`" data-testid="divider" /> + <gl-dropdown-section-header v-if="showHeaders" :key="`${index}_header`" data-testid="header"> + {{ title }} + </gl-dropdown-section-header> + <template v-for="menuItem in menu_items"> + <gl-dropdown-item + :key="`${index}_item_${menuItem.id}`" + link-class="top-nav-menu-item" + :href="menuItem.href" + data-testid="item" + > + {{ menuItem.title }} + </gl-dropdown-item> + </template> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/nav/event_hub.js b/app/assets/javascripts/nav/event_hub.js new file mode 100644 index 00000000000..2c8b1371fe3 --- /dev/null +++ b/app/assets/javascripts/nav/event_hub.js @@ -0,0 +1,5 @@ +import eventHubFactory from '~/helpers/event_hub_factory'; + +export const EVENT_RESPONSIVE_TOGGLE = 'top-nav-responsive-toggle'; + +export default eventHubFactory(); diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js index 646ce3f0ecf..86d6b42e4ea 100644 --- a/app/assets/javascripts/nav/index.js +++ b/app/assets/javascripts/nav/index.js @@ -1,12 +1,28 @@ -export const initTopNav = async () => { +// With combined_menu feature flag, there's a benefit to splitting up the import +const importModule = () => import(/* webpackChunkName: 'top_nav' */ './mount'); + +const tryMountTopNav = async () => { const el = document.getElementById('js-top-nav'); if (!el) { return; } - // With combined_menu feature flag, there's a benefit to splitting up the import - const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount'); + const { mountTopNav } = await importModule(); mountTopNav(el); }; + +const tryMountTopNavResponsive = async () => { + const el = document.getElementById('js-top-nav-responsive'); + + if (!el) { + return; + } + + const { mountTopNavResponsive } = await importModule(); + + mountTopNavResponsive(el); +}; + +export const initTopNav = async () => Promise.all([tryMountTopNav(), tryMountTopNavResponsive()]); diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js index 0d46ff56249..51b6a31b8cb 100644 --- a/app/assets/javascripts/nav/mount.js +++ b/app/assets/javascripts/nav/mount.js @@ -1,11 +1,12 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import ResponsiveApp from './components/responsive_app.vue'; import App from './components/top_nav_app.vue'; import { createStore } from './stores'; Vue.use(Vuex); -export const mountTopNav = (el) => { +const mount = (el, Component) => { const viewModel = JSON.parse(el.dataset.viewModel); const store = createStore(); @@ -13,7 +14,7 @@ export const mountTopNav = (el) => { el, store, render(h) { - return h(App, { + return h(Component, { props: { navData: viewModel, }, @@ -21,3 +22,7 @@ export const mountTopNav = (el) => { }, }); }; + +export const mountTopNav = (el) => mount(el, App); + +export const mountTopNavResponsive = (el) => mount(el, ResponsiveApp); diff --git a/app/assets/javascripts/nav/utils/has_menu_expanded.js b/app/assets/javascripts/nav/utils/has_menu_expanded.js new file mode 100644 index 00000000000..5f126bbdf76 --- /dev/null +++ b/app/assets/javascripts/nav/utils/has_menu_expanded.js @@ -0,0 +1,2 @@ +export const hasMenuExpanded = () => + Boolean(document.querySelector('.header-content.menu-expanded')); diff --git a/app/assets/javascripts/nav/utils/index.js b/app/assets/javascripts/nav/utils/index.js new file mode 100644 index 00000000000..4fa3d0910da --- /dev/null +++ b/app/assets/javascripts/nav/utils/index.js @@ -0,0 +1,2 @@ +export * from './has_menu_expanded'; +export * from './reset_menu_items_active'; diff --git a/app/assets/javascripts/nav/utils/reset_menu_items_active.js b/app/assets/javascripts/nav/utils/reset_menu_items_active.js new file mode 100644 index 00000000000..9b5d8e97c9c --- /dev/null +++ b/app/assets/javascripts/nav/utils/reset_menu_items_active.js @@ -0,0 +1,14 @@ +const resetActiveInArray = (arr) => arr?.map((menuItem) => ({ ...menuItem, active: false })); + +/** + * This method sets `active: false` for the menu items within the given nav data. + * + * @returns navData with the menu items updated with `active: false` + */ +export const resetMenuItemsActive = ({ primary, secondary, ...navData }) => { + return { + ...navData, + primary: resetActiveInArray(primary), + secondary: resetActiveInArray(secondary), + }; +}; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 90be5b3e470..7213658bdf2 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -273,6 +273,13 @@ export default { this.toggleIssueState(); } }, + handleEnter() { + if (this.hasDrafts) { + this.handleSaveDraft(); + } else { + this.handleSave(); + } + }, toggleIssueState() { if (this.isIssue) { // We want to invoke the close/reopen logic in the issue header @@ -395,8 +402,8 @@ export default { :aria-label="$options.i18n.comment" :placeholder="$options.i18n.bodyPlaceholder" @keydown.up="editCurrentUserLastNote()" - @keydown.meta.enter="handleSave()" - @keydown.ctrl.enter="handleSave()" + @keydown.meta.enter="handleEnter()" + @keydown.ctrl.enter="handleEnter()" ></textarea> </template> </markdown-field> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 0cc818c6d0e..0f72b4f2dba 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -304,7 +304,7 @@ export default { v-else v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" - class="note-action-button note-emoji-button add-reaction-button btn-icon js-add-award js-note-emoji" + class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" category="tertiary" variant="default" :title="$options.i18n.addReactionLabel" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 34dd21dcbac..1af9e4be373 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -135,6 +135,13 @@ export default { resolveWithIssuePath() { return !this.discussionResolved ? this.discussion.resolve_with_issue_path : ''; }, + canShowReplyActions() { + if (this.shouldRenderDiffs && !this.discussion.diff_file.diff_refs) { + return false; + } + + return true; + }, }, created() { eventHub.$on('startReplying', this.onStartReplying); @@ -263,7 +270,7 @@ export default { :draft="draftForDiscussion(discussion.reply_id)" /> <div - v-else-if="showReplies" + v-else-if="canShowReplyActions && showReplies" :class="{ 'is-replying': isReplying }" class="discussion-reply-holder gl-border-t-0! clearfix" > diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 76342e07c04..7b9c0959464 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -1,7 +1,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils'; import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { clearDraft } from '~/lib/utils/autosave'; import { s__ } from '~/locale'; import { formatLineRange } from '~/notes/components/multiline_comment_utils'; @@ -42,7 +42,9 @@ export default { this.handleClearForm(this.discussion.line_code); }) .catch(() => { - createFlash(s__('MergeRequests|An error occurred while saving the draft comment.')); + createFlash({ + message: s__('MergeRequests|An error occurred while saving the draft comment.'), + }); }); }, addToReview(note) { @@ -80,7 +82,9 @@ export default { } }) .catch(() => { - createFlash(s__('MergeRequests|An error occurred while saving the draft comment.')); + createFlash({ + message: s__('MergeRequests|An error occurred while saving the draft comment.'), + }); }); }, handleClearForm(lineCode) { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index bdb85360be8..086e9122c60 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,6 +12,7 @@ import loadAwardsHandler from '../../awards_handler'; import { deprecatedCreateFlash as Flash } from '../../flash'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import Poll from '../../lib/utils/poll'; +import { create } from '../../lib/utils/recurrence'; import { mergeUrlParams } from '../../lib/utils/url_utility'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import TaskList from '../../task_list'; @@ -21,6 +22,7 @@ import eventHub from '../event_hub'; import * as types from './mutation_types'; import * as utils from './utils'; +const NOTES_POLLING_INTERVAL = 6000; let eTagPoll; export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) => { @@ -469,6 +471,19 @@ const getFetchDataParams = (state) => { }; export const poll = ({ commit, state, getters, dispatch }) => { + const notePollOccurrenceTracking = create(); + let flashContainer; + + notePollOccurrenceTracking.handle(1, () => { + // Since polling halts internally after 1 failure, we manually try one more time + setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); + }); + notePollOccurrenceTracking.handle(2, () => { + // On the second failure in a row, show the alert and try one more time (hoping to succeed and clear the error) + flashContainer = Flash(__('Something went wrong while fetching latest comments.')); + setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); + }); + eTagPoll = new Poll({ resource: { poll: () => { @@ -477,8 +492,15 @@ export const poll = ({ commit, state, getters, dispatch }) => { }, }, method: 'poll', - successCallback: ({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch), - errorCallback: () => Flash(__('Something went wrong while fetching latest comments.')), + successCallback: ({ data }) => { + pollSuccessCallBack(data, commit, state, getters, dispatch); + + if (notePollOccurrenceTracking.count) { + notePollOccurrenceTracking.reset(); + } + flashContainer?.close(); + }, + errorCallback: () => notePollOccurrenceTracking.occur(), }); if (!Visibility.hidden()) { diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue index a24612e4680..959fffa2629 100644 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -34,19 +34,19 @@ export default { <h4 class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" > - {{ s__('MetricsSettings|Metrics dashboard') }} + {{ s__('MetricsSettings|Metrics') }} </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> - {{ s__('MetricsSettings|Manage Metrics Dashboard settings.') }} - <gl-link :href="helpPage">{{ __('Learn more') }}</gl-link> + {{ s__('MetricsSettings|Manage metrics dashboard settings.') }} + <gl-link :href="helpPage">{{ __('Learn more.') }}</gl-link> </p> </div> <div class="settings-content"> <form> <dashboard-timezone /> <external-dashboard /> - <gl-button variant="success" category="primary" @click="saveChanges"> + <gl-button variant="confirm" category="primary" @click="saveChanges"> {{ __('Save Changes') }} </gl-button> </form> diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js index af66e344b35..969904bc6d0 100644 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -35,5 +35,8 @@ export const receiveSaveChangesError = (_, error) => { const { response = {} } = error; const message = response.data && response.data.message ? response.data.message : ''; - createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); + createFlash({ + message: `${__('There was an error saving your changes.')} ${message}`, + type: 'alert', + }); }; diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue index bcfb17a1529..55ffe10a608 100644 --- a/app/assets/javascripts/packages/details/components/app.vue +++ b/app/assets/javascripts/packages/details/components/app.vue @@ -13,7 +13,7 @@ import { import { mapActions, mapState } from 'vuex'; import { objectToQueryString } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; import PackageListRow from '../../shared/components/package_list_row.vue'; import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; @@ -24,7 +24,6 @@ import DependencyRow from './dependency_row.vue'; import InstallationCommands from './installation_commands.vue'; import PackageFiles from './package_files.vue'; import PackageHistory from './package_history.vue'; -import PackageTitle from './package_title.vue'; export default { name: 'PackagesApp', @@ -36,7 +35,9 @@ export default { GlTab, GlTabs, GlSprintf, - PackageTitle, + PackageTitle: () => import('./package_title.vue'), + TerraformTitle: () => + import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'), PackagesListLoader, PackageListRow, DependencyRow, @@ -50,7 +51,18 @@ export default { GlModal: GlModalDirective, }, mixins: [Tracking.mixin()], + inject: { + titleComponent: { + default: 'PackageTitle', + from: 'titleComponent', + }, + }, trackingActions: { ...TrackingActions }, + data() { + return { + fileToDelete: null, + }; + }, computed: { ...mapState([ 'projectName', @@ -86,13 +98,10 @@ export default { }, }, methods: { - ...mapActions(['deletePackage', 'fetchPackageVersions']), + ...mapActions(['deletePackage', 'fetchPackageVersions', 'deletePackageFile']), formatSize(size) { return numberToHumanSize(size); }, - cancelDelete() { - this.$refs.deleteModal.hide(); - }, getPackageVersions() { if (!this.packageEntity.versions) { this.fetchPackageVersions(); @@ -108,12 +117,43 @@ export default { const modalQuery = objectToQueryString({ [SHOW_DELETE_SUCCESS_ALERT]: true }); window.location.replace(`${returnTo}?${modalQuery}`); }, + handleFileDelete(file) { + this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE); + this.fileToDelete = { ...file }; + this.$refs.deleteFileModal.show(); + }, + confirmFileDelete() { + this.track(TrackingActions.DELETE_PACKAGE_FILE); + this.deletePackageFile(this.fileToDelete.id); + this.fileToDelete = null; + }, }, i18n: { deleteModalTitle: s__(`PackageRegistry|Delete Package Version`), deleteModalContent: s__( `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, ), + deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`), + deleteFileModalContent: s__( + `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, + ), + }, + modal: { + packageDeletePrimaryAction: { + text: __('Delete'), + attributes: [ + { variant: 'danger' }, + { category: 'primary' }, + { 'data-qa-selector': 'delete_modal_button' }, + ], + }, + fileDeletePrimaryAction: { + text: __('Delete'), + attributes: [{ variant: 'danger' }, { category: 'primary' }], + }, + cancelAction: { + text: __('Cancel'), + }, }, }; </script> @@ -127,7 +167,7 @@ export default { /> <div v-else class="packages-app"> - <package-title> + <component :is="titleComponent"> <template #delete-button> <gl-button v-if="canDelete" @@ -140,7 +180,7 @@ export default { {{ __('Delete') }} </gl-button> </template> - </package-title> + </component> <gl-tabs> <gl-tab :title="__('Detail')"> @@ -159,7 +199,9 @@ export default { <package-files v-if="showFiles" :package-files="packageFiles" + :can-delete="canDelete" @download-file="track($options.trackingActions.PULL_PACKAGE)" + @delete-file="handleFileDelete" /> </gl-tab> @@ -210,7 +252,15 @@ export default { </gl-tab> </gl-tabs> - <gl-modal ref="deleteModal" class="js-delete-modal" modal-id="delete-modal"> + <gl-modal + ref="deleteModal" + class="js-delete-modal" + modal-id="delete-modal" + :action-primary="$options.modal.packageDeletePrimaryAction" + :action-cancel="$options.modal.cancelAction" + @primary="confirmPackageDeletion" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)" + > <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template> <gl-sprintf :message="$options.i18n.deleteModalContent"> <template #version> @@ -221,23 +271,22 @@ export default { <strong>{{ packageEntity.name }}</strong> </template> </gl-sprintf> + </gl-modal> - <template #modal-footer> - <div class="gl-w-full"> - <div class="float-right"> - <gl-button @click="cancelDelete">{{ __('Cancel') }}</gl-button> - <gl-button - ref="modal-delete-button" - variant="danger" - category="primary" - data-qa-selector="delete_modal_button" - @click="confirmPackageDeletion" - > - {{ __('Delete') }} - </gl-button> - </div> - </div> - </template> + <gl-modal + ref="deleteFileModal" + modal-id="delete-file-modal" + :action-primary="$options.modal.fileDeletePrimaryAction" + :action-cancel="$options.modal.cancelAction" + @primary="confirmFileDelete" + @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" + > + <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template> + <gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent"> + <template #filename> + <strong>{{ fileToDelete.file_name }}</strong> + </template> + </gl-sprintf> </gl-modal> </div> </template> diff --git a/app/assets/javascripts/packages/details/components/file_sha.vue b/app/assets/javascripts/packages/details/components/file_sha.vue new file mode 100644 index 00000000000..a25839be7e1 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/file_sha.vue @@ -0,0 +1,41 @@ +<script> +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +export default { + name: 'FileSha', + components: { + DetailsRow, + ClipboardButton, + }, + props: { + sha: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + i18n: { + copyButtonTitle: s__('PackageRegistry|Copy SHA'), + }, +}; +</script> + +<template> + <details-row dashed> + <div class="gl-px-4"> + {{ title }}: + {{ sha }} + <clipboard-button + :text="sha" + :title="$options.i18n.copyButtonTitle" + category="tertiary" + size="small" + /> + </div> + </details-row> +</template> diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue index 28978913e6e..ed55d7fe782 100644 --- a/app/assets/javascripts/packages/details/components/installation_commands.vue +++ b/app/assets/javascripts/packages/details/components/installation_commands.vue @@ -1,5 +1,6 @@ <script> -import { PackageType } from '../../shared/constants'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; +import { PackageType, TERRAFORM_PACKAGE_TYPE } from '../../shared/constants'; import ComposerInstallation from './composer_installation.vue'; import ConanInstallation from './conan_installation.vue'; import MavenInstallation from './maven_installation.vue'; @@ -16,6 +17,7 @@ export default { [PackageType.NUGET]: NugetInstallation, [PackageType.PYPI]: PypiInstallation, [PackageType.COMPOSER]: ComposerInstallation, + [TERRAFORM_PACKAGE_TYPE]: TerraformInstallation, }, props: { packageEntity: { diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages/details/components/package_files.vue index 103d1f489bd..0563b612d04 100644 --- a/app/assets/javascripts/packages/details/components/package_files.vue +++ b/app/assets/javascripts/packages/details/components/package_files.vue @@ -1,8 +1,9 @@ <script> -import { GlLink, GlTable } from '@gitlab/ui'; +import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui'; import { last } from 'lodash'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; +import FileSha from '~/packages/details/components/file_sha.vue'; import Tracking from '~/tracking'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -12,8 +13,13 @@ export default { components: { GlLink, GlTable, + GlIcon, + GlDropdown, + GlDropdownItem, + GlButton, FileIcon, TimeAgoTooltip, + FileSha, }, mixins: [Tracking.mixin()], props: { @@ -22,6 +28,11 @@ export default { required: false, default: () => [], }, + canDelete: { + type: Boolean, + default: false, + required: false, + }, }, computed: { filesTableRows() { @@ -39,7 +50,6 @@ export default { { key: 'name', label: __('Name'), - tdClass: 'gl-display-flex gl-align-items-center', }, { key: 'commit', @@ -55,6 +65,13 @@ export default { label: __('Created'), class: 'gl-text-right', }, + { + key: 'actions', + label: '', + hide: !this.canDelete, + class: 'gl-text-right', + tdClass: 'gl-w-4', + }, ].filter((c) => !c.hide); }, }, @@ -62,6 +79,12 @@ export default { formatSize(size) { return numberToHumanSize(size); }, + hasDetails(item) { + return item.file_sha256 || item.file_md5 || item.file_sha1; + }, + }, + i18n: { + deleteFile: __('Delete file'), }, }; </script> @@ -74,10 +97,18 @@ export default { :items="filesTableRows" :tbody-tr-attr="{ 'data-testid': 'file-row' }" > - <template #cell(name)="{ item }"> + <template #cell(name)="{ item, toggleDetails, detailsShowing }"> + <gl-button + v-if="hasDetails(item)" + :icon="detailsShowing ? 'angle-up' : 'angle-down'" + :aria-label="detailsShowing ? __('Collapse') : __('Expand')" + category="tertiary" + size="small" + @click="toggleDetails" + /> <gl-link :href="item.download_path" - class="gl-relative gl-text-gray-500" + class="gl-text-gray-500" data-testid="download-link" @click="$emit('download-file')" > @@ -86,7 +117,7 @@ export default { css-classes="gl-relative file-icon" class="gl-mr-1 gl-relative" /> - <span class="gl-relative">{{ item.file_name }}</span> + <span>{{ item.file_name }}</span> </gl-link> </template> @@ -103,6 +134,32 @@ export default { <template #cell(created)="{ item }"> <time-ago-tooltip :time="item.created_at" /> </template> + + <template #cell(actions)="{ item }"> + <gl-dropdown category="tertiary" right> + <template #button-content> + <gl-icon name="ellipsis_v" /> + </template> + <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)"> + {{ $options.i18n.deleteFile }} + </gl-dropdown-item> + </gl-dropdown> + </template> + + <template #row-details="{ item }"> + <div + class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100" + > + <file-sha + v-if="item.file_sha256" + data-testid="sha-256" + title="SHA-256" + :sha="item.file_sha256" + /> + <file-sha v-if="item.file_md5" data-testid="md5" title="MD5" :sha="item.file_md5" /> + <file-sha v-if="item.file_sha1" data-testid="sha-1" title="SHA-1" :sha="item.file_sha1" /> + </div> + </template> </gl-table> </div> </template> diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages/details/store/actions.js index 87216366c8b..a03fa8d9d63 100644 --- a/app/assets/javascripts/packages/details/store/actions.js +++ b/app/assets/javascripts/packages/details/store/actions.js @@ -1,6 +1,10 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import createFlash from '~/flash'; +import { + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, +} from '~/packages/shared/constants'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants'; import * as types from './mutation_types'; @@ -16,7 +20,7 @@ export const fetchPackageVersions = ({ commit, state }) => { } }) .catch(() => { - createFlash(FETCH_PACKAGE_VERSIONS_ERROR); + createFlash({ message: FETCH_PACKAGE_VERSIONS_ERROR, type: 'warning' }); }) .finally(() => { commit(types.SET_LOADING, false); @@ -29,6 +33,27 @@ export const deletePackage = ({ }, }) => { return Api.deleteProjectPackage(project_id, id).catch(() => { - createFlash(DELETE_PACKAGE_ERROR_MESSAGE); + createFlash({ message: DELETE_PACKAGE_ERROR_MESSAGE, type: 'warning' }); }); }; + +export const deletePackageFile = ( + { + state: { + packageEntity: { project_id, id }, + packageFiles, + }, + commit, + }, + fileId, +) => { + return Api.deleteProjectPackageFile(project_id, id, fileId) + .then(() => { + const filtered = packageFiles.filter((f) => f.id !== fileId); + commit(types.UPDATE_PACKAGE_FILES, filtered); + createFlash({ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, type: 'success' }); + }) + .catch(() => { + createFlash({ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, type: 'warning' }); + }); +}; diff --git a/app/assets/javascripts/packages/details/store/mutation_types.js b/app/assets/javascripts/packages/details/store/mutation_types.js index 340d668819c..590f2d9f970 100644 --- a/app/assets/javascripts/packages/details/store/mutation_types.js +++ b/app/assets/javascripts/packages/details/store/mutation_types.js @@ -1,2 +1,3 @@ export const SET_LOADING = 'SET_LOADING'; export const SET_PACKAGE_VERSIONS = 'SET_PACKAGE_VERSIONS'; +export const UPDATE_PACKAGE_FILES = 'UPDATE_PACKAGE_FILES'; diff --git a/app/assets/javascripts/packages/details/store/mutations.js b/app/assets/javascripts/packages/details/store/mutations.js index e113638311b..762fd5a4040 100644 --- a/app/assets/javascripts/packages/details/store/mutations.js +++ b/app/assets/javascripts/packages/details/store/mutations.js @@ -11,4 +11,7 @@ export default { versions, }; }, + [types.UPDATE_PACKAGE_FILES](state, files) { + state.packageFiles = files; + }, }; diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages/list/stores/actions.js index 8dfe3c82ab3..81f587971c2 100644 --- a/app/assets/javascripts/packages/list/stores/actions.js +++ b/app/assets/javascripts/packages/list/stores/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; import { @@ -43,7 +43,9 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { dispatch('receivePackagesListSuccess', { data, headers }); }) .catch(() => { - createFlash(FETCH_PACKAGES_LIST_ERROR_MESSAGE); + createFlash({ + message: FETCH_PACKAGES_LIST_ERROR_MESSAGE, + }); }) .finally(() => { dispatch('setLoading', false); @@ -52,7 +54,9 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { export const requestDeletePackage = ({ dispatch, state }, { _links }) => { if (!_links || !_links.delete_api_path) { - createFlash(DELETE_PACKAGE_ERROR_MESSAGE); + createFlash({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }); const error = new Error(MISSING_DELETE_PATH_ERROR); return Promise.reject(error); } @@ -65,10 +69,15 @@ export const requestDeletePackage = ({ dispatch, state }, { _links }) => { const page = getNewPaginationPage(currentPage, perPage, total - 1); dispatch('requestPackagesList', { page }); - createFlash(DELETE_PACKAGE_SUCCESS_MESSAGE, 'success'); + createFlash({ + message: DELETE_PACKAGE_SUCCESS_MESSAGE, + type: 'success', + }); }) .catch(() => { dispatch('setLoading', false); - createFlash(DELETE_PACKAGE_ERROR_MESSAGE); + createFlash({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }); }); }; diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index b3df542e0ae..0ef6a3d0d12 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const PackageType = { CONAN: 'conan', @@ -11,11 +11,17 @@ export const PackageType = { GENERIC: 'generic', }; +// we want this separated from the main dictionary to avoid it being pulled in the search of package +export const TERRAFORM_PACKAGE_TYPE = 'terraform_module'; + export const TrackingActions = { DELETE_PACKAGE: 'delete_package', REQUEST_DELETE_PACKAGE: 'request_delete_package', CANCEL_DELETE_PACKAGE: 'cancel_delete_package', PULL_PACKAGE: 'pull_package', + DELETE_PACKAGE_FILE: 'delete_package_file', + REQUEST_DELETE_PACKAGE_FILE: 'request_delete_package_file', + CANCEL_DELETE_PACKAGE_FILE: 'cancel_delete_package_file', }; export const TrackingCategories = { @@ -25,7 +31,15 @@ export const TrackingCategories = { }; export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; -export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.'); +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.'), +); +export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( + 'PackageRegistry|Package file deleted successfully', +); export const PACKAGE_ERROR_STATUS = 'error'; export const PACKAGE_DEFAULT_STATUS = 'default'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue new file mode 100644 index 00000000000..3e551706ed0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue @@ -0,0 +1,82 @@ +<script> +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +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'; + +export default { + name: 'DetailsTitle', + components: { + TitleArea, + GlIcon, + GlSprintf, + MetadataItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + i18n: { + packageInfo: __('v%{version} published %{timeAgo}'), + }, + computed: { + ...mapState(['packageEntity', 'packageFiles']), + ...mapGetters(['packagePipeline']), + totalSize() { + return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0)); + }, + }, + methods: { + dynamicSlotName(index) { + return `metadata-tag${index}`; + }, + }, +}; +</script> + +<template> + <title-area :title="packageEntity.name" data-qa-selector="package_title"> + <template #sub-header> + <gl-icon name="eye" class="gl-mr-3" /> + <gl-sprintf :message="$options.i18n.packageInfo"> + <template #version> + {{ packageEntity.version }} + </template> + + <template #timeAgo> + <span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)"> + {{ timeFormatted(packageEntity.created_at) }} + </span> + </template> + </gl-sprintf> + </template> + + <template #metadata-type> + <metadata-item data-testid="package-type" icon="infrastructure-registry" text="Terraform" /> + </template> + + <template #metadata-size> + <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> + </template> + + <template v-if="packagePipeline" #metadata-pipeline> + <metadata-item + data-testid="pipeline-project" + icon="review-list" + :text="packagePipeline.project.name" + :link="packagePipeline.project.web_url" + /> + </template> + + <template v-if="packagePipeline" #metadata-ref> + <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> + </template> + + <template #right-actions> + <slot name="delete-button"></slot> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue new file mode 100644 index 00000000000..399a3e086f1 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue @@ -0,0 +1,66 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'ConanInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['packageEntity', 'terraformHelpPath', 'projectPath']), + provisionInstructions() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `module "${this.packageEntity.name}" { + source = "${this.projectPath}/${this.packageEntity.name}" + version = "${this.packageEntity.version}" +}`; + }, + registrySetup() { + return `credentials "gitlab.com" { + token = "<TOKEN>" +}`; + }, + }, + i18n: { + helpText: s__( + 'InfrastructureRegistry|For more information on the Terraform registry, %{linkStart}see our documentation%{linkEnd}.', + ), + }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Provision instructions') }}</h3> + + <code-instruction + :label=" + s__( + 'InfrastructureRegistry|Copy and paste into your Terraform configuration, insert the variables, and run Terraform init:', + ) + " + :instruction="provisionInstructions" + :copy-text="s__('InfrastructureRegistry|Copy Terraform Command')" + multiline + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + + <code-instruction + :label="s__('InfrastructureRegistry|To authorize access to the Terraform registry:')" + :instruction="registrySetup" + :copy-text="s__('InfrastructureRegistry|Copy Terraform Setup Command')" + multiline + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="terraformHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js new file mode 100644 index 00000000000..98942b1e578 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import PackagesApp from '~/packages/details/components/app.vue'; +import createStore from '~/packages/details/store'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); + +export default () => { + const el = document.querySelector('#js-vue-packages-detail'); + const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset; + const packageEntity = JSON.parse(packageJson); + const canDelete = parseBoolean(canDeleteStr); + + const store = createStore({ + packageEntity, + packageFiles: packageEntity.package_files, + canDelete, + ...rest, + }); + + return new Vue({ + el, + store, + provide: { + titleComponent: 'TerraformTitle', + }, + render(createElement) { + return createElement(PackagesApp); + }, + }); +}; 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 edbe9441e57..6da2e3a47e8 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 @@ -9,17 +9,28 @@ import { 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 CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; import SettingsForm from './settings_form.vue'; export default { components: { + SettingsBlock, SettingsForm, + CleanupPolicyEnabledAlert, GlAlert, GlSprintf, GlLink, }, - inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'], + inject: [ + 'projectPath', + 'isAdmin', + 'adminSettingsPath', + 'enableHistoricEntries', + 'helpPagePath', + 'showCleanupPolicyOnAlert', + ], i18n: { UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, @@ -75,32 +86,53 @@ export default { </script> <template> - <div> - <settings-form - v-if="!isDisabled" - v-model="workingCopy" - :is-loading="$apollo.queries.containerExpirationPolicy.loading" - :is-edited="isEdited" - @reset="restoreOriginal" - /> - <template v-else> - <gl-alert - v-if="showDisabledFormMessage" - :dismissible="false" - :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE" - variant="tip" - > - {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }} + <section data-testid="registry-settings-app"> + <cleanup-policy-enabled-alert v-if="showCleanupPolicyOnAlert" :project-path="projectPath" /> + <settings-block default-expanded> + <template #title> {{ __('Clean up image tags') }}</template> + <template #description> + <span data-testid="description"> + <gl-sprintf + :message=" + __( + 'Save space and find images in the container Registry. remove unneeded tags and keep only the ones you want. %{linkStart}How does cleanup work?%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + <template #default> + <settings-form + v-if="!isDisabled" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + @reset="restoreOriginal" + /> + <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" target="_blank">{{ 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> + <gl-sprintf :message="unavailableFeatureMessage"> + <template #link="{ content }"> + <gl-link :href="adminSettingsPath" target="_blank">{{ 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> + </template> + </settings-block> + </section> </template> 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 65af6f846aa..2a3e2c28fa6 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 @@ -19,6 +19,8 @@ export default () => { projectPath, adminSettingsPath, tagsRegexHelpPagePath, + helpPagePath, + showCleanupPolicyOnAlert, } = el.dataset; return new Vue({ el, @@ -32,6 +34,8 @@ export default () => { projectPath, adminSettingsPath, tagsRegexHelpPagePath, + helpPagePath, + showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert), }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue b/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue new file mode 100644 index 00000000000..d51c62e0623 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue @@ -0,0 +1,54 @@ +<script> +import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + LocalStorageSync, + }, + props: { + projectPath: { + type: String, + required: true, + }, + cleanupPoliciesSettingsPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + dismissed: false, + }; + }, + computed: { + storageKey() { + return `cleanup_policy_enabled_for_project_${this.projectPath}`; + }, + }, + i18n: { + message: s__( + 'ContainerRegistry|Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}', + ), + }, +}; +</script> + +<template> + <local-storage-sync v-model="dismissed" :storage-key="storageKey"> + <gl-alert v-if="!dismissed" class="gl-mt-2" dismissible @dismiss="dismissed = true"> + <gl-sprintf :message="$options.i18n.message"> + <template #link="{ content }"> + <gl-link v-if="cleanupPoliciesSettingsPath" :href="cleanupPoliciesSettingsPath">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + </local-storage-sync> +</template> diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index cc5c7ce82bf..93eb90535d1 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -1,7 +1,8 @@ import { queryToObject } from '~/lib/utils/url_utility'; import { FILTERED_SEARCH_TERM } from './constants'; -export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true }); +export const getQueryParams = (query) => + queryToObject(query, { gatherArrays: true, legacySpacesDecode: true }); export const keyValueToFilterToken = (type, data) => ({ type, value: { data } }); diff --git a/app/assets/javascripts/pages/admin/application_settings/integrations/index.js b/app/assets/javascripts/pages/admin/application_settings/integrations/index.js index f318b6f62d5..53068f72d3f 100644 --- a/app/assets/javascripts/pages/admin/application_settings/integrations/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/integrations/index.js @@ -1,8 +1,3 @@ import initIntegrationsList from '~/integrations/index'; -import PersistentUserCallout from '~/persistent_user_callout'; - -const callout = document.querySelector('.js-admin-integrations-moved'); - -PersistentUserCallout.factory(callout); initIntegrationsList(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index 798eeee48bf..ffccc1419a6 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,6 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { redirectTo } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -31,7 +31,9 @@ export default { redirectTo(response.request.responseURL); }) .catch((error) => { - createFlash(s__('AdminArea|Stopping jobs failed')); + createFlash({ + message: s__('AdminArea|Stopping jobs failed'), + }); throw error; }); }, diff --git a/app/assets/javascripts/pages/admin/runners/index/index.js b/app/assets/javascripts/pages/admin/runners/index/index.js index 45ed3ac6bd8..d5563470394 100644 --- a/app/assets/javascripts/pages/admin/runners/index/index.js +++ b/app/assets/javascripts/pages/admin/runners/index/index.js @@ -2,6 +2,7 @@ import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; +import { initRunnerList } from '~/runner/runner_list'; initFilteredSearch({ page: FILTERED_SEARCH.ADMIN_RUNNERS, @@ -10,3 +11,7 @@ initFilteredSearch({ }); initInstallRunner(); + +if (gon.features?.runnerListViewVueUi) { + initRunnerList(); +} diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index b0a70055835..13656ee9b16 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -29,46 +29,43 @@ function mountRemoveMemberModal() { const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; -initMembersApp(document.querySelector('.js-group-members-list'), { - namespace: MEMBER_TYPES.user, - tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, - tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], - requestFormatter: groupMemberRequestFormatter, - filteredSearchBar: { - show: true, - tokens: ['two_factor', 'with_inherited_permissions'], - searchParam: 'search', - placeholder: s__('Members|Filter members'), - recentSearchesStorageKey: 'group_members', +initMembersApp(document.querySelector('.js-group-members-list-app'), { + [MEMBER_TYPES.user]: { + tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, + tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + requestFormatter: groupMemberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: ['two_factor', 'with_inherited_permissions'], + searchParam: 'search', + placeholder: s__('Members|Filter members'), + recentSearchesStorageKey: 'group_members', + }, }, -}); - -initMembersApp(document.querySelector('.js-group-group-links-list'), { - namespace: MEMBER_TYPES.group, - tableFields: SHARED_FIELDS.concat('granted'), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, + [MEMBER_TYPES.group]: { + tableFields: SHARED_FIELDS.concat('granted'), + tableAttrs: { + table: { 'data-qa-selector': 'groups_list' }, + tr: { 'data-qa-selector': 'group_row' }, + }, + requestFormatter: groupLinkRequestFormatter, }, - requestFormatter: groupLinkRequestFormatter, -}); -initMembersApp(document.querySelector('.js-group-invited-members-list'), { - namespace: MEMBER_TYPES.invite, - tableFields: SHARED_FIELDS.concat('invited'), - requestFormatter: groupMemberRequestFormatter, - filteredSearchBar: { - show: true, - tokens: [], - searchParam: 'search_invited', - placeholder: s__('Members|Search invited'), - recentSearchesStorageKey: 'group_invited_members', + [MEMBER_TYPES.invite]: { + tableFields: SHARED_FIELDS.concat('invited'), + requestFormatter: groupMemberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: [], + searchParam: 'search_invited', + placeholder: s__('Members|Search invited'), + recentSearchesStorageKey: 'group_invited_members', + }, + }, + [MEMBER_TYPES.accessRequest]: { + tableFields: SHARED_FIELDS.concat('requested'), + requestFormatter: groupMemberRequestFormatter, }, -}); -initMembersApp(document.querySelector('.js-group-access-requests-list'), { - namespace: MEMBER_TYPES.accessRequest, - tableFields: SHARED_FIELDS.concat('requested'), - requestFormatter: groupMemberRequestFormatter, }); groupsSelect(); diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue new file mode 100644 index 00000000000..9aac364d20e --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/components/app.vue @@ -0,0 +1,55 @@ +<script> +import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg'; +import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg'; + +import { s__ } from '~/locale'; +import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; +import createGroupDescriptionDetails from './create_group_description_details.vue'; + +const PANELS = [ + { + name: 'create-group-pane', + selector: '#create-group-pane', + title: s__('GroupsNew|Create group'), + description: s__( + 'GroupsNew|Assemble related projects together and grant members access to several projects at once.', + ), + illustration: newGroupIllustration, + details: createGroupDescriptionDetails, + }, + { + name: 'import-group-pane', + selector: '#import-group-pane', + title: s__('GroupsNew|Import group'), + description: s__( + 'GroupsNew|Export groups with all their related data and move to a new GitLab instance.', + ), + illustration: importGroupIllustration, + details: 'Migrate your existing groups from another instance of GitLab.', + }, +]; + +export default { + components: { + NewNamespacePage, + }, + props: { + hasErrors: { + type: Boolean, + required: false, + default: false, + }, + }, + PANELS, +}; +</script> + +<template> + <new-namespace-page + :jump-to-last-persisted-panel="hasErrors" + :initial-breadcrumb="s__('New group')" + :panels="$options.PANELS" + :title="s__('GroupsNew|Create new group')" + persistence-key="new_group_last_active_tab" + /> +</template> diff --git a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue new file mode 100644 index 00000000000..ea08a0821a8 --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue @@ -0,0 +1,44 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + components: { + GlLink, + GlSprintf, + }, + paths: { + groupsHelpPath: helpPagePath('user/group/index'), + subgroupsHelpPath: helpPagePath('user/group/subgroups/index'), + }, +}; +</script> + +<template> + <div> + <p> + <gl-sprintf + :message=" + s__( + 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects. Groups can also be nested by creating subgroups.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf + :message=" + s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.') + " + > + <template #link="{ content }"> + <gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 569b5afd676..7557edb1b49 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,8 +1,9 @@ -import $ from 'jquery'; +import Vue from 'vue'; import BindInOut from '~/behaviors/bind_in_out'; import initFilePickers from '~/file_pickers'; import Group from '~/group'; -import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import NewGroupCreationApp from './components/app.vue'; import GroupPathValidator from './group_path_validator'; new GroupPathValidator(); // eslint-disable-line no-new @@ -12,15 +13,21 @@ initFilePickers(); new Group(); // eslint-disable-line no-new -const CONTAINER_SELECTOR = '.group-edit-container .nav-tabs'; -const DEFAULT_ACTION = '#create-group-pane'; -// eslint-disable-next-line no-new -new LinkedTabs({ - defaultAction: DEFAULT_ACTION, - parentEl: CONTAINER_SELECTOR, - hashedTabs: true, -}); - -if (window.location.hash) { - $(CONTAINER_SELECTOR).find(`a[href="${window.location.hash}"]`).tab('show'); +function initNewGroupCreation(el) { + const { hasErrors } = el.dataset; + + const props = { + hasErrors: parseBoolean(hasErrors), + }; + + return new Vue({ + el, + render(h) { + return h(NewGroupCreationApp, { props }); + }, + }); } + +const el = document.querySelector('.js-new-group-creation'); + +initNewGroupCreation(el); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 636eea5d7ac..a8d7a83cdd6 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -3,6 +3,7 @@ import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; +import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSettingsPanels from '~/settings_panels'; @@ -20,3 +21,4 @@ initSharedRunnersForm(); initVariableList(); initInstallRunner(); +initRunnerAwsDeployments(); diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index 8d4997586dd..e42e89ce021 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -1,6 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; @@ -63,7 +63,9 @@ export default { visitUrl(response.data.url); }) .catch((error) => { - createFlash(error); + createFlash({ + message: error, + }); }) .finally(() => { this.visible = false; diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index b5441127797..226ef4c4e23 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -2,7 +2,7 @@ import emojiRegex from 'emoji-regex'; import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import EmojiMenu from './emoji_menu'; @@ -81,4 +81,8 @@ Emoji.initEmojiMap() } }); }) - .catch(() => createFlash(__('Failed to load emoji list.'))); + .catch(() => + createFlash({ + message: __('Failed to load emoji list.'), + }), + ); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 8a8ce70e998..6cc0095f5a5 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import TableOfContents from '~/blob/components/table_contents.vue'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import BlobViewer from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; @@ -19,12 +20,16 @@ const apolloProvider = new VueApollo({ const viewBlobEl = document.querySelector('#js-view-blob-app'); if (viewBlobEl) { - const { blobPath, projectPath } = viewBlobEl.dataset; + const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, apolloProvider, + provide: { + targetBranch, + originalBranch, + }, render(createElement) { return createElement(BlobContentViewer, { props: { @@ -92,3 +97,15 @@ if (successPipelineEl) { }, }); } + +const tableContentsEl = document.querySelector('.js-table-contents'); + +if (tableContentsEl) { + // eslint-disable-next-line no-new + new Vue({ + el: tableContentsEl, + render(h) { + return h(TableOfContents); + }, + }); +} diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index 27ec746ad02..97dc76908af 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -3,6 +3,8 @@ import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; import BranchSortDropdown from '~/branches/branch_sort_dropdown'; import DeleteModal from '~/branches/branches_delete_modal'; import initDiverganceGraph from '~/branches/divergence_graph'; +import initDeleteBranchButton from '~/branches/init_delete_branch_button'; +import initDeleteBranchModal from '~/branches/init_delete_branch_modal'; AjaxLoadingSpinner.init(); new DeleteModal(); // eslint-disable-line no-new @@ -14,3 +16,9 @@ const { divergingCountsEndpoint, defaultBranch } = document.querySelector( initDiverganceGraph(divergingCountsEndpoint, defaultBranch); BranchSortDropdown(); initDeprecatedRemoveRowBehavior(); + +document + .querySelectorAll('.js-delete-branch-button') + .forEach((elem) => initDeleteBranchButton(elem)); + +initDeleteBranchModal(); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js new file mode 100644 index 00000000000..519e04e14fb --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js @@ -0,0 +1,25 @@ +/* eslint-disable no-new */ + +import Vue from 'vue'; +import Vuex from 'vuex'; +import UserLists from '~/user_lists/components/user_lists.vue'; +import createStore from '~/user_lists/store/index'; + +Vue.use(Vuex); + +const el = document.querySelector('#js-user-lists'); + +const { featureFlagsHelpPagePath, errorStateSvgPath, projectId, newUserListPath } = el.dataset; + +new Vue({ + el, + store: createStore({ projectId }), + provide: { + featureFlagsHelpPagePath, + errorStateSvgPath, + newUserListPath, + }, + render(createElement) { + return createElement(UserLists); + }, +}); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue index 02b357d389b..7fb41c6e7b7 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/app.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue @@ -38,6 +38,10 @@ export default { type: String, required: true, }, + restrictedVisibilityLevels: { + type: Array, + required: true, + }, }, }; </script> @@ -66,6 +70,7 @@ export default { :project-path="projectPath" :project-description="projectDescription" :project-visibility="projectVisibility" + :restricted-visibility-levels="restrictedVisibilityLevels" /> </div> </div> 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 07cc0ce46bc..75c3b6d564c 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 @@ -26,10 +26,10 @@ const PRIVATE_VISIBILITY = 'private'; const INTERNAL_VISIBILITY = 'internal'; const PUBLIC_VISIBILITY = 'public'; -const ALLOWED_VISIBILITY = { - private: [PRIVATE_VISIBILITY], - internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY], - public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], +const VISIBILITY_LEVEL = { + [PRIVATE_VISIBILITY]: 0, + [INTERNAL_VISIBILITY]: 10, + [PUBLIC_VISIBILITY]: 20, }; const initFormField = ({ value, required = true, skipValidation = false }) => ({ @@ -95,6 +95,10 @@ export default { type: String, required: true, }, + restrictedVisibilityLevels: { + type: Array, + required: true, + }, }, data() { const form = { @@ -111,10 +115,7 @@ export default { required: false, skipValidation: true, }), - visibility: initFormField({ - value: this.projectVisibility, - skipValidation: true, - }), + visibility: initFormField({ value: this.getInitialVisibilityValue() }), }, }; return { @@ -127,14 +128,38 @@ export default { projectUrl() { return `${gon.gitlab_url}/`; }, - projectAllowedVisibility() { - return ALLOWED_VISIBILITY[this.projectVisibility]; + projectVisibilityLevel() { + return VISIBILITY_LEVEL[this.projectVisibility]; + }, + namespaceVisibilityLevel() { + const visibility = this.form.fields.namespace.value?.visibility || PUBLIC_VISIBILITY; + return VISIBILITY_LEVEL[visibility]; + }, + visibilityLevelCap() { + return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel); + }, + restrictedVisibilityLevelsSet() { + return new Set(this.restrictedVisibilityLevels); }, - namespaceAllowedVisibility() { - return ( - ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] || - ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] + allowedVisibilityLevels() { + const allowedLevels = Object.entries(VISIBILITY_LEVEL).reduce( + (levels, [levelName, levelValue]) => { + if ( + !this.restrictedVisibilityLevelsSet.has(levelValue) && + levelValue <= this.visibilityLevelCap + ) { + levels.push(levelName); + } + return levels; + }, + [], ); + + if (!allowedLevels.length) { + return [PRIVATE_VISIBILITY]; + } + + return allowedLevels; }, visibilityLevels() { return [ @@ -142,7 +167,9 @@ export default { text: s__('ForkProject|Private'), value: PRIVATE_VISIBILITY, icon: 'lock', - help: s__('ForkProject|The project can be accessed without any authentication.'), + 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), }, { @@ -156,9 +183,7 @@ export default { text: s__('ForkProject|Public'), value: PUBLIC_VISIBILITY, icon: 'earth', - 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.', - ), + help: s__('ForkProject|The project can be accessed without any authentication.'), disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY), }, ]; @@ -166,12 +191,9 @@ export default { }, watch: { // eslint-disable-next-line func-names - 'form.fields.namespace.value': function (newVal) { - const { visibility } = newVal; - - if (this.projectAllowedVisibility.includes(visibility)) { - this.form.fields.visibility.value = visibility; - } + '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) { @@ -186,11 +208,11 @@ export default { const { data } = await axios.get(this.endpoint); this.namespaces = data.namespaces; }, - isVisibilityLevelDisabled(visibilityLevel) { - return !( - this.projectAllowedVisibility.includes(visibilityLevel) && - this.namespaceAllowedVisibility.includes(visibilityLevel) - ); + isVisibilityLevelDisabled(visibility) { + return !this.allowedVisibilityLevels.includes(visibility); + }, + getInitialVisibilityValue() { + return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility; }, async onSubmit() { this.form.showValidation = true; @@ -222,7 +244,11 @@ export default { redirectTo(data.web_url); return; } catch (error) { - createFlash({ message: error }); + createFlash({ + message: s__( + 'ForkProject|An error occurred while forking the project. Please try again.', + ), + }); } }, }, @@ -322,7 +348,11 @@ export default { /> </gl-form-group> - <gl-form-group> + <gl-form-group + v-validation:[form.showValidation] + :invalid-feedback="s__('ForkProject|Please select a visibility level')" + :state="form.fields.visibility.state" + > <label> {{ s__('ForkProject|Visibility level') }} <gl-link :href="visibilityHelpPath" target="_blank"> @@ -333,6 +363,7 @@ export default { v-model="form.fields.visibility.value" data-testid="fork-visibility-radio-group" name="visibility" + :aria-label="__('visibility')" required > <gl-form-radio diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue index bc47b124f8b..10753de6cd0 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue @@ -1,6 +1,6 @@ <script> import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import ForkGroupsListItem from './fork_groups_list_item.vue'; @@ -44,7 +44,11 @@ export default { .then((response) => { this.namespaces = response.data.namespaces; }) - .catch(() => createFlash(__('There was a problem fetching groups.'))); + .catch(() => + createFlash({ + message: __('There was a problem fetching groups.'), + }), + ); }, }, diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index 372967c8a1e..1a171252048 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -16,6 +16,7 @@ if (gon.features.forkProjectForm) { projectPath, projectDescription, projectVisibility, + restrictedVisibilityLevels, } = mountElement.dataset; // eslint-disable-next-line no-new @@ -38,6 +39,7 @@ if (gon.features.forkProjectForm) { projectPath, projectDescription, projectVisibility, + restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels), }, }); }, diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 45e9643b3f3..1eab3becbc3 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,7 @@ import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; +import { initSidebarTracking } from '../shared/nav/sidebar_tracking'; import Project from './project'; new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new +initSidebarTracking(); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 3143ff5adac..3cea61262ea 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -1,8 +1,6 @@ import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initIssuableSidebar from '~/init_issuable_sidebar'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issuable_show/constants'; import Issue from '~/issue'; import '~/notes/index'; @@ -34,8 +32,6 @@ export default function initShowIssue() { initIssueHeaderActions(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); - initInviteMembersModal(); - initInviteMembersTrigger(); import(/* webpackChunkName: 'design_management' */ '~/design_management') .then((module) => module.default()) diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index 81ffaa6f7a3..aaa9bb906b2 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -1,6 +1,6 @@ <script> import { GlSprintf, GlModal } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; @@ -70,7 +70,9 @@ export default { labelUrl: this.url, successful: false, }); - createFlash(error); + createFlash({ + message: error, + }); }); }, }, 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 6cd3202815b..d6b6c9fe06a 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 @@ -4,8 +4,6 @@ import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initIssuableSidebar from '~/init_issuable_sidebar'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import StatusBox from '~/issuable/components/status_box.vue'; import createDefaultClient from '~/lib/graphql'; import { handleLocationHash } from '~/lib/utils/common_utils'; @@ -29,8 +27,6 @@ export default function initMergeRequestShow() { } else { loadAwardsHandler(); } - initInviteMembersModal(); - initInviteMembersTrigger(); const el = document.querySelector('.js-mr-status-box'); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient() }); diff --git a/app/assets/javascripts/pages/projects/packages/infrastructure_registry/show/index.js b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/show/index.js new file mode 100644 index 00000000000..44d9e2ffb6e --- /dev/null +++ b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/show/index.js @@ -0,0 +1,3 @@ +import initDetails from '~/packages_and_registries/infrastructure_registry/details_app_bundle'; + +initDetails(); 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 159c619e16c..d0ec5668d21 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,7 +1,15 @@ <script> -import { GlFormRadio, GlFormRadioGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { + GlFormRadio, + GlFormRadioGroup, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; import { getWeekdayNames } from '~/lib/utils/datetime_utility'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const KEY_EVERY_DAY = 'everyDay'; const KEY_EVERY_WEEK = 'everyWeek'; @@ -12,15 +20,25 @@ export default { components: { GlFormRadio, GlFormRadioGroup, + GlIcon, GlLink, GlSprintf, }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], props: { initialCronInterval: { type: String, required: false, default: '', }, + dailyLimit: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -80,6 +98,17 @@ export default { weekday() { return getWeekdayNames()[this.randomWeekDayIndex]; }, + parsedDailyLimit() { + return this.dailyLimit ? (24 * 60) / this.dailyLimit : null; + }, + scheduleDailyLimitMsg() { + return sprintf( + __( + 'Scheduled pipelines cannot run more frequently than once per %{limit} minutes. A pipeline configured to run more frequently only starts after %{limit} minutes have elapsed since the last time it ran.', + ), + { limit: this.parsedDailyLimit }, + ); + }, }, watch: { cronInterval() { @@ -111,6 +140,11 @@ export default { generateRandomDay() { return Math.floor(Math.random() * 28); }, + showDailyLimitMessage({ value }) { + return ( + value === KEY_CUSTOM && this.glFeatures.ciDailyLimitForPipelineSchedules && this.dailyLimit + ); + }, }, }; </script> @@ -131,7 +165,15 @@ export default { </gl-link> </template> </gl-sprintf> + <template v-else>{{ option.text }}</template> + + <gl-icon + v-if="showDailyLimitMessage(option)" + v-gl-tooltip.hover + name="question" + :title="scheduleDailyLimitMsg" + /> </gl-form-radio> </gl-form-radio-group> <input 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 ce0e573fed2..9056c76d6ca 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 @@ -12,6 +12,7 @@ Vue.use(Translate); function initIntervalPatternInput() { const intervalPatternMount = document.getElementById('interval-pattern-input'); const initialCronInterval = intervalPatternMount?.dataset?.initialInterval; + const dailyLimit = intervalPatternMount.dataset?.dailyLimit; return new Vue({ el: intervalPatternMount, @@ -22,6 +23,7 @@ function initIntervalPatternInput() { return createElement('interval-pattern-input', { props: { initialCronInterval, + dailyLimit, }, }); }, diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 91f376060f8..3b24c2c128b 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -129,7 +129,7 @@ export default class Project { const currentRef = $dropdown.data('ref'); // The split and startWith is to ensure an exact word match // and avoid partial match ie. currentRef is "dev" and loc is "development" - const splitPathAfterRefPortion = loc.split(currentRef)[1]; + const splitPathAfterRefPortion = loc.split('/-/')[1].split(currentRef)[1]; const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/'); if (doesPathContainRef) { diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 471798d2931..177dc346c60 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -42,46 +42,41 @@ initInviteMembersForm(); new UsersSelect(); // eslint-disable-line no-new const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; -initMembersApp(document.querySelector('.js-project-members-list'), { - namespace: MEMBER_TYPES.user, - tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, - tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], - requestFormatter: projectMemberRequestFormatter, - filteredSearchBar: { - show: true, - tokens: ['with_inherited_permissions'], - searchParam: 'search', - placeholder: s__('Members|Filter members'), - recentSearchesStorageKey: 'project_members', +initMembersApp(document.querySelector('.js-project-members-list-app'), { + [MEMBER_TYPES.user]: { + tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, + tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + requestFormatter: projectMemberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: ['with_inherited_permissions'], + searchParam: 'search', + placeholder: s__('Members|Filter members'), + recentSearchesStorageKey: 'project_members', + }, }, -}); - -initMembersApp(document.querySelector('.js-project-group-links-list'), { - namespace: MEMBER_TYPES.group, - tableFields: SHARED_FIELDS.concat('granted'), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, + [MEMBER_TYPES.group]: { + tableFields: SHARED_FIELDS.concat('granted'), + tableAttrs: { + table: { 'data-qa-selector': 'groups_list' }, + tr: { 'data-qa-selector': 'group_row' }, + }, + requestFormatter: groupLinkRequestFormatter, + filteredSearchBar: { + show: true, + tokens: [], + searchParam: 'search_groups', + placeholder: s__('Members|Search groups'), + recentSearchesStorageKey: 'project_group_links', + }, }, - requestFormatter: groupLinkRequestFormatter, - filteredSearchBar: { - show: true, - tokens: [], - searchParam: 'search_groups', - placeholder: s__('Members|Search groups'), - recentSearchesStorageKey: 'project_group_links', + [MEMBER_TYPES.invite]: { + tableFields: SHARED_FIELDS.concat('invited'), + requestFormatter: projectMemberRequestFormatter, + }, + [MEMBER_TYPES.accessRequest]: { + tableFields: SHARED_FIELDS.concat('requested'), + requestFormatter: projectMemberRequestFormatter, }, -}); - -initMembersApp(document.querySelector('.js-project-invited-members-list'), { - namespace: MEMBER_TYPES.invite, - tableFields: SHARED_FIELDS.concat('invited'), - requestFormatter: projectMemberRequestFormatter, -}); - -initMembersApp(document.querySelector('.js-project-access-requests-list'), { - namespace: MEMBER_TYPES.accessRequest, - tableFields: SHARED_FIELDS.concat('requested'), - requestFormatter: projectMemberRequestFormatter, }); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 10105af3561..db7b3bad6ed 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -4,6 +4,7 @@ import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initVariableList from '~/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; +import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import initSettingsPanels from '~/settings_panels'; @@ -38,4 +39,5 @@ document.addEventListener('DOMContentLoaded', () => { initArtifactsSettings(); initSharedRunnersToggle(); initInstallRunner(); + initRunnerAwsDeployments(); }); diff --git a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js index 01ad87160c5..53068f72d3f 100644 --- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js @@ -1,7 +1,3 @@ import initIntegrationsList from '~/integrations/index'; -import PersistentUserCallout from '~/persistent_user_callout'; - -const callout = document.querySelector('.js-webhooks-moved-alert'); -PersistentUserCallout.factory(callout); initIntegrationsList(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index c110c1d4d62..9fb8be3fdb9 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -91,7 +91,7 @@ export default { label-position="hidden" @change="toggleFeature" /> - <div class="select-wrapper gl-flex-fill-1"> + <div class="select-wrapper gl-flex-grow-1"> <select :disabled="displaySelectInput" class="form-control project-repo-select select-control" 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 0b7b4c0ded1..11e6b4577e0 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 @@ -381,7 +381,7 @@ export default { :label="s__('ProjectSettings|Project visibility')" > <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"> - <div class="select-wrapper gl-flex-fill-1"> + <div class="select-wrapper gl-flex-grow-1"> <select v-model="visibilityLevel" :disabled="!canChangeVisibilityLevel" diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 83e43d7ac48..26f8018a968 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -3,6 +3,8 @@ import Activities from '~/activities'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BlobViewer from '~/blob/viewer/index'; import { initUploadForm } from '~/blob_edit/blob_bundle'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; import { initUploadFileTrigger } from '~/projects/upload_file_experiment'; @@ -44,3 +46,5 @@ initVueNotificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new initUploadFileTrigger(); +initInviteMembersModal(); +initInviteMembersTrigger(); diff --git a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js b/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js new file mode 100644 index 00000000000..f3807a33a2b --- /dev/null +++ b/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue'; + +export function initRunnerAwsDeployments(componentId = 'js-runner-aws-deployments') { + const el = document.getElementById(componentId); + + if (!el) { + return null; + } + + return new Vue({ + el, + render(createElement) { + return createElement(RunnerAwsDeployments); + }, + }); +} diff --git a/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js b/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js new file mode 100644 index 00000000000..79ce1a37d21 --- /dev/null +++ b/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js @@ -0,0 +1,44 @@ +function onSidebarLinkClick() { + const setDataTrackAction = (element, action) => { + element.setAttribute('data-track-action', action); + }; + + const setDataTrackExtra = (element, value) => { + const SIDEBAR_COLLAPSED = 'Collapsed'; + const SIDEBAR_EXPANDED = 'Expanded'; + const sidebarCollapsed = document + .querySelector('.nav-sidebar') + .classList.contains('js-sidebar-collapsed') + ? SIDEBAR_COLLAPSED + : SIDEBAR_EXPANDED; + + element.setAttribute( + 'data-track-extra', + JSON.stringify({ sidebar_display: sidebarCollapsed, menu_display: value }), + ); + }; + + const EXPANDED = 'Expanded'; + const FLY_OUT = 'Fly out'; + const CLICK_MENU_ACTION = 'click_menu'; + const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; + const parentElement = this.parentNode; + const subMenuList = parentElement.closest('.sidebar-sub-level-items'); + + if (subMenuList) { + const isFlyOut = subMenuList.classList.contains('fly-out-list') ? FLY_OUT : EXPANDED; + + setDataTrackExtra(parentElement, isFlyOut); + setDataTrackAction(parentElement, CLICK_MENU_ITEM_ACTION); + } else { + const isFlyOut = parentElement.classList.contains('is-showing-fly-out') ? FLY_OUT : EXPANDED; + + setDataTrackExtra(parentElement, isFlyOut); + setDataTrackAction(parentElement, CLICK_MENU_ACTION); + } +} +export const initSidebarTracking = () => { + document.querySelectorAll('.nav-sidebar li[data-track-label] > a').forEach((link) => { + link.addEventListener('click', onSidebarLinkClick); + }); +}; 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 43753926039..26f6d1d683a 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -14,8 +14,17 @@ import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { setUrlFragment } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + WIKI_CONTENT_EDITOR_TRACKING_LABEL, + CONTENT_EDITOR_LOADED_ACTION, + SAVED_USING_CONTENT_EDITOR_ACTION, +} from '../constants'; + +const trackingMixin = Tracking.mixin({ + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, +}); const MARKDOWN_LINK_TEXT = { markdown: '[Link Title](page-slug)', @@ -53,21 +62,30 @@ export default { ), primaryAction: s__('WikiPage|Retry'), }, - useNewEditor: s__('WikiPage|Use new editor'), + useNewEditor: { + primaryLabel: s__('WikiPage|Use the new editor'), + secondaryLabel: s__('WikiPage|Try this later'), + title: s__('WikiPage|Get a richer editing experience'), + text: s__( + "WikiPage|Try the new visual Markdown editor. Read the %{linkStart}documentation%{linkEnd} to learn what's currently supported.", + ), + }, switchToOldEditor: { - label: s__('WikiPage|Switch to old editor'), - helpText: s__("WikiPage|Switching will discard any changes you've made in the new editor."), + label: s__('WikiPage|Switch me back to the classic editor.'), + helpText: s__( + "WikiPage|This editor is in beta and may not display the page's contents properly. Switching back to the classic editor will discard changes you've made in the new editor.", + ), modal: { - title: s__('WikiPage|Are you sure you want to switch to the old editor?'), - primary: s__('WikiPage|Switch to old editor'), + title: s__('WikiPage|Are you sure you want to switch back to the classic editor?'), + primary: s__('WikiPage|Switch to classic editor'), cancel: s__('WikiPage|Keep editing'), text: s__( - "WikiPage|Switching to the old editor will discard any changes you've made in the new editor.", + "WikiPage|Switching to the classic editor will discard any changes you've made in the new editor.", ), }, }, - helpText: s__( - "WikiPage|This editor is in beta and may not display the page's contents properly.", + feedbackTip: s__( + 'Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}.', ), }, linksHelpText: s__( @@ -86,6 +104,7 @@ export default { }, cancel: s__('WikiPage|Cancel'), }, + contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629', components: { GlAlert, GlForm, @@ -104,13 +123,14 @@ export default { directives: { GlModalDirective, }, - mixins: [glFeatureFlagMixin()], + mixins: [trackingMixin], inject: ['formatOptions', 'pageInfo'], data() { return { title: this.pageInfo.title?.trim() || '', format: this.pageInfo.format || 'markdown', - content: this.pageInfo.content?.trim() || '', + content: this.pageInfo.content || '', + isContentEditorAlertDismissed: false, isContentEditorLoading: true, useContentEditor: false, commitMessage: '', @@ -120,6 +140,10 @@ export default { }; }, computed: { + noContent() { + if (this.isContentEditorActive) return this.contentEditor?.empty; + return !this.content.trim(); + }, csrfToken() { return csrf.token; }, @@ -157,14 +181,17 @@ export default { wikiSpecificMarkdownHelpPath() { return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown'); }, + contentEditorHelpPath() { + return setUrlFragment(this.pageInfo.helpPath, 'gitlab-flavored-markdown-support'); + }, isMarkdownFormat() { return this.format === 'markdown'; }, - showContentEditorButton() { - return this.isMarkdownFormat && !this.useContentEditor && this.glFeatures.wikiContentEditor; + showContentEditorAlert() { + return this.isMarkdownFormat && !this.useContentEditor && !this.isContentEditorAlertDismissed; }, disableSubmitButton() { - return !this.content || !this.title || this.contentEditorRenderFailed; + return this.noContent || !this.title || this.contentEditorRenderFailed; }, isContentEditorActive() { return this.isMarkdownFormat && this.useContentEditor; @@ -188,6 +215,8 @@ export default { handleFormSubmit() { if (this.useContentEditor) { this.content = this.contentEditor.getSerializedContent(); + + this.trackFormSubmit(); } this.isDirty = false; @@ -236,6 +265,8 @@ export default { try { await this.contentEditor.setSerializedContent(this.content); this.isContentEditorLoading = false; + + this.trackContentEditorLoaded(); } catch (e) { this.contentEditorRenderFailed = true; } @@ -258,6 +289,20 @@ export default { this.$refs.confirmSwitchToOldEditorModal.show(); } }, + + trackContentEditorLoaded() { + this.track(CONTENT_EDITOR_LOADED_ACTION); + }, + + trackFormSubmit() { + if (this.isContentEditorActive) { + this.track(SAVED_USING_CONTENT_EDITOR_ACTION); + } + }, + + dismissContentEditorAlert() { + this.isContentEditorAlertDismissed = true; + }, }, }; </script> @@ -275,11 +320,9 @@ export default { :dismissible="false" variant="danger" :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction" - @primaryAction="retryInitContentEditor()" + @primaryAction="retryInitContentEditor" > - <p> - {{ $options.i18n.contentEditor.renderFailed.message }} - </p> + {{ $options.i18n.contentEditor.renderFailed.message }} </gl-alert> <input :value="csrfToken" type="hidden" name="authenticity_token" /> @@ -299,7 +342,7 @@ export default { <div class="col-sm-10"> <input id="wiki_title" - v-model.trim="title" + v-model="title" name="wiki[title]" type="text" class="form-control" @@ -337,46 +380,50 @@ export default { {{ label }} </option> </select> - <div> - <gl-button - v-if="showContentEditorButton" - category="secondary" - variant="confirm" - class="gl-mt-4" - @click="initContentEditor" - >{{ $options.i18n.contentEditor.useNewEditor }}</gl-button - > - <div v-if="isContentEditorActive" class="gl-mt-4 gl-display-flex"> - <div class="gl-mr-4"> - <gl-button category="secondary" variant="confirm" @click="confirmSwitchToOldEditor">{{ - $options.i18n.contentEditor.switchToOldEditor.label - }}</gl-button> - </div> - <div class="gl-mt-2"> - <gl-icon name="warning" /> - {{ $options.i18n.contentEditor.switchToOldEditor.helpText }} - </div> - </div> - <gl-modal - ref="confirmSwitchToOldEditorModal" - modal-id="confirm-switch-to-old-editor" - :title="$options.i18n.contentEditor.switchToOldEditor.modal.title" - :action-primary="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.primary }" - :action-cancel="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.cancel }" - @primary="switchToOldEditor" - > - {{ $options.i18n.contentEditor.switchToOldEditor.modal.text }} - </gl-modal> - </div> </div> </div> - <div class="form-group row"> + <div class="form-group row" data-testid="wiki-form-content-fieldset"> <div class="col-sm-2 col-form-label"> <label class="control-label-full-width" for="wiki_content">{{ $options.i18n.content.label }}</label> </div> <div class="col-sm-10"> + <gl-alert + v-if="showContentEditorAlert" + class="gl-mb-6" + variant="info" + :primary-button-text="$options.i18n.contentEditor.useNewEditor.primaryLabel" + :secondary-button-text="$options.i18n.contentEditor.useNewEditor.secondaryLabel" + :dismiss-label="$options.i18n.contentEditor.useNewEditor.secondaryLabel" + :title="$options.i18n.contentEditor.useNewEditor.title" + @primaryAction="initContentEditor" + @secondaryAction="dismissContentEditorAlert" + @dismiss="dismissContentEditorAlert" + > + <gl-sprintf :message="$options.i18n.contentEditor.useNewEditor.text"> + <template + #link="// eslint-disable-next-line vue/no-template-shadow + { content }" + ><gl-link + :href="contentEditorHelpPath" + target="_blank" + data-testid="content-editor-help-link" + >{{ content }}</gl-link + ></template + > + </gl-sprintf> + </gl-alert> + <gl-modal + ref="confirmSwitchToOldEditorModal" + modal-id="confirm-switch-to-old-editor" + :title="$options.i18n.contentEditor.switchToOldEditor.modal.title" + :action-primary="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.primary }" + :action-cancel="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.cancel }" + @primary="switchToOldEditor" + > + {{ $options.i18n.contentEditor.switchToOldEditor.modal.text }} + </gl-modal> <markdown-field v-if="!isContentEditorActive" :markdown-preview-path="pageInfo.markdownPreviewPath" @@ -391,7 +438,7 @@ export default { <textarea id="wiki_content" ref="textarea" - v-model.trim="content" + v-model="content" name="wiki[content]" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" @@ -407,6 +454,20 @@ export default { </markdown-field> <div v-if="isContentEditorActive"> + <gl-alert class="gl-mb-6" variant="tip" :dismissable="false"> + <gl-sprintf :message="$options.i18n.contentEditor.feedbackTip"> + <template + #link="// eslint-disable-next-line vue/no-template-shadow + { content }" + ><gl-link + :href="$options.contentEditorFeedbackIssue" + target="_blank" + data-testid="wiki-markdown-help-link" + >{{ content }}</gl-link + ></template + > + </gl-sprintf> + </gl-alert> <gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" /> <content-editor v-else :content-editor="contentEditor" /> <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> @@ -432,7 +493,10 @@ export default { > </gl-sprintf> <span v-else> - {{ $options.i18n.contentEditor.helpText }} + {{ $options.i18n.contentEditor.switchToOldEditor.helpText }} + <gl-button variant="link" @click="confirmSwitchToOldEditor">{{ + $options.i18n.contentEditor.switchToOldEditor.label + }}</gl-button> </span> </div> </div> diff --git a/app/assets/javascripts/pages/shared/wikis/constants.js b/app/assets/javascripts/pages/shared/wikis/constants.js new file mode 100644 index 00000000000..b358ac9cf52 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/constants.js @@ -0,0 +1,4 @@ +export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor'; + +export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded'; +export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor'; diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index c416106fdd8..03dba699461 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -1,4 +1,3 @@ -import { scaleLinear, scaleThreshold } from 'd3-scale'; import { select } from 'd3-selection'; import dateFormat from 'dateformat'; import $ from 'jquery'; @@ -8,7 +7,7 @@ import axios from '~/lib/utils/axios_utils'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import { n__, s__, __ } from '~/locale'; -const d3 = { select, scaleLinear, scaleThreshold }; +const d3 = { select }; const firstDayOfWeekChoices = Object.freeze({ sunday: 0, @@ -16,6 +15,14 @@ const firstDayOfWeekChoices = Object.freeze({ saturday: 6, }); +const CONTRIB_LEGENDS = [ + { title: __('No contributions'), min: 0 }, + { title: __('1-9 contributions'), min: 1 }, + { title: __('10-19 contributions'), min: 10 }, + { title: __('20-29 contributions'), min: 20 }, + { title: __('30+ contributions'), min: 30 }, +]; + const LOADING_HTML = ` <div class="text-center"> <div class="spinner spinner-md"></div> @@ -42,7 +49,17 @@ function formatTooltipText({ date, count }) { return `${contribText}<br /><span class="gl-text-gray-300">${dateDayName} ${dateText}</span>`; } -const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); +// Return the contribution level from the number of contributions +export const getLevelFromContributions = (count) => { + if (count <= 0) { + return 0; + } + + const nextLevel = CONTRIB_LEGENDS.findIndex(({ min }) => count < min); + + // If there is no higher level, we are at the end + return nextLevel >= 0 ? nextLevel - 1 : CONTRIB_LEGENDS.length - 1; +}; export default class ActivityCalendar { constructor( @@ -111,10 +128,6 @@ export default class ActivityCalendar { innerArray.push({ count, date, day }); } - // Init color functions - this.colorKey = initColorKey(); - this.color = this.initColor(); - // Init the svg element this.svg = this.renderSvg(container, group); this.renderDays(); @@ -180,9 +193,7 @@ export default class ActivityCalendar { .attr('y', (stamp) => this.dayYPos(stamp.day)) .attr('width', this.daySize) .attr('height', this.daySize) - .attr('fill', (stamp) => - stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed', - ) + .attr('data-level', (stamp) => getLevelFromContributions(stamp.count)) .attr('title', (stamp) => formatTooltipText(stamp)) .attr('class', 'user-contrib-cell has-tooltip') .attr('data-html', true) @@ -246,50 +257,24 @@ export default class ActivityCalendar { } renderKey() { - const keyValues = [ - __('No contributions'), - __('1-9 contributions'), - __('10-19 contributions'), - __('20-29 contributions'), - __('30+ contributions'), - ]; - const keyColors = [ - '#ededed', - this.colorKey(0), - this.colorKey(1), - this.colorKey(2), - this.colorKey(3), - ]; - this.svg .append('g') .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`) .selectAll('rect') - .data(keyColors) + .data(CONTRIB_LEGENDS) .enter() .append('rect') .attr('width', this.daySize) .attr('height', this.daySize) - .attr('x', (color, i) => this.daySizeWithSpace * i) + .attr('x', (_, i) => this.daySizeWithSpace * i) .attr('y', 0) - .attr('fill', (color) => color) - .attr('class', 'has-tooltip') - .attr('title', (color, i) => keyValues[i]) + .attr('data-level', (_, i) => i) + .attr('class', 'user-contrib-cell has-tooltip contrib-legend') + .attr('title', (x) => x.title) .attr('data-container', 'body') .attr('data-html', true); } - initColor() { - const colorRange = [ - '#ededed', - this.colorKey(0), - this.colorKey(1), - this.colorKey(2), - this.colorKey(3), - ]; - return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange); - } - clickDay(stamp) { if (this.currentSelectedDate !== stamp.date) { this.currentSelectedDate = stamp.date; diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index c8a04eb72c4..6a64538abfe 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -24,6 +24,9 @@ export default { hasPDF() { return this.pdf && this.pdf.length > 0; }, + availablePages() { + return this.pages.filter(Boolean); + }, }, watch: { pdf: 'load' }, mounted() { @@ -61,13 +64,7 @@ export default { <template> <div v-if="hasPDF" class="pdf-viewer"> - <page - v-for="(page, index) in pages" - v-if="page" - :key="index" - :page="page" - :number="index + 1" - /> + <page v-for="(page, index) in availablePages" :key="index" :page="page" :number="index + 1" /> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index e5b26a00c4c..04efc459a21 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -214,7 +214,7 @@ export default { <div></div> </template> </gl-modal> - <span class="gl-text-white">{{ title }}</span> + {{ title }} <request-warning :html-id="htmlId" :warnings="warnings" /> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index ebe9c4eee2f..214e1729bf8 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -74,6 +74,11 @@ export default { keys: ['label', 'code', 'proxy', 'error'], }, { + metric: 'memory', + header: s__('PerformanceBar|Memory'), + keys: ['item_header', 'item_content'], + }, + { metric: 'total', header: s__('PerformanceBar|Frontend resources'), keys: ['name', 'size'], @@ -136,7 +141,7 @@ export default { <div id="peek-view-host" class="view"> <span v-if="hasHost" - class="current-host gl-text-white" + class="current-host" :class="{ canary: currentRequest.details.host.canary }" > <span v-html="birdEmoji"></span> diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js index 582fdfea6c9..e4fd423249b 100644 --- a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js +++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js @@ -2,10 +2,16 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from'; export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing'; -export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING]; +export const CODE_SNIPPET_SOURCE_DAST = 'dast'; + +export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING, CODE_SNIPPET_SOURCE_DAST]; export const CODE_SNIPPET_SOURCE_SETTINGS = { [CODE_SNIPPET_SOURCE_API_FUZZING]: { datasetKey: 'apiFuzzingConfigurationPath', docsPath: helpPagePath('user/application_security/api_fuzzing/index'), }, + [CODE_SNIPPET_SOURCE_DAST]: { + datasetKey: 'dastConfigurationPath', + docsPath: helpPagePath('user/application_security/dast/index'), + }, }; diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 567164cb0ee..8f4894a0bde 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -8,6 +8,8 @@ import { COMMIT_SUCCESS, } from '../../constants'; import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; +import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql'; +import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql'; import getCommitSha from '../../graphql/queries/client/commit_sha.graphql'; import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql'; @@ -113,6 +115,8 @@ export default { this.redirectToNewMergeRequest(targetBranch); } else { this.$emit('commit', { type: COMMIT_SUCCESS }); + this.updateLastCommitBranch(targetBranch); + this.updateCurrentBranch(targetBranch); } } catch (error) { this.$emit('showError', { type: COMMIT_FAILURE, reasons: [error?.message] }); @@ -123,6 +127,18 @@ export default { onCommitCancel() { this.$emit('resetContent'); }, + updateCurrentBranch(currentBranch) { + this.$apollo.mutate({ + mutation: updateCurrentBranchMutation, + variables: { currentBranch }, + }); + }, + updateLastCommitBranch(lastCommitBranch) { + this.$apollo.mutate({ + mutation: updateLastCommitBranchMutation, + variables: { lastCommitBranch }, + }); + }, }, }; </script> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index 22c1563350d..a8ad56ab6a5 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -1,20 +1,22 @@ <script> import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import PipelineVisualReference from '../ui/pipeline_visual_reference.vue'; export default { i18n: { title: s__('PipelineEditorTutorial|🚀 Run your first pipeline'), firstParagraph: s__( - 'PipelineEditorTutorial|A typical GitLab pipeline consists of three stages: build, test and deploy. Each stage can have one or more jobs.', - ), - secondParagraph: s__( - 'PipelineEditorTutorial|In the example below, %{codeStart}build%{codeEnd} and %{codeStart}deploy%{codeEnd} each contain one job, and %{codeStart}test%{codeEnd} contains two jobs. Your scripts run in jobs like these.', - ), - thirdParagraph: s__( - 'PipelineEditorTutorial|You can use %{linkStart}CI/CD examples and templates%{linkEnd} to get your first %{codeStart}.gitlab-ci.yml%{codeEnd} configuration file started. Your first pipeline runs when you commit the changes.', + 'PipelineEditorTutorial|This template creates a simple test pipeline. To use it:', ), + listItems: [ + s__( + 'PipelineEditorTutorial|Commit the file to your repository. The pipeline then runs automatically.', + ), + s__('PipelineEditorTutorial|The pipeline status is at the top of the page.'), + s__( + 'PipelineEditorTutorial|Select the pipeline ID to view the full details about your first pipeline run.', + ), + ], note: s__( 'PipelineEditorTutorial|If you’re using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}', ), @@ -23,9 +25,8 @@ export default { GlCard, GlLink, GlSprintf, - PipelineVisualReference, }, - inject: ['ciExamplesHelpPagePath', 'runnerHelpPagePath'], + inject: ['runnerHelpPagePath'], }; </script> <template> @@ -33,26 +34,9 @@ export default { <template #default> <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> - <p class="gl-mb-3"> - <gl-sprintf :message="$options.i18n.secondParagraph"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - <pipeline-visual-reference /> - <p class="gl-my-3"> - <gl-sprintf :message="$options.i18n.thirdParagraph"> - <template #link="{ content }"> - <gl-link :href="ciExamplesHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> + <ol class="gl-mb-3"> + <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li> + </ol> <p class="gl-mb-0"> <gl-sprintf :message="$options.i18n.note"> <template #link="{ content }"> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue deleted file mode 100644 index 1017237365b..00000000000 --- a/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import DemoJobPill from './demo_job_pill.vue'; - -export default { - i18n: { - stageNames: { - build: s__('StageName|Build'), - test: s__('StageName|Test'), - deploy: s__('StageName|Deploy'), - }, - jobNames: { - build: s__('JobName|build-job'), - test_1: s__('JobName|unit-test'), - test_2: s__('JobName|lint-test'), - deploy: s__('JobName|deploy-app'), - }, - }, - stageClasses: - 'gl-bg-blue-50 gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-4 gl-rounded-base', - titleClasses: 'gl-text-blue-600 gl-mb-4', - components: { - DemoJobPill, - }, -}; -</script> -<template> - <div class="gl-display-flex gl-justify-content-center"> - <div :class="$options.stageClasses" class="gl-mr-5"> - <div :class="$options.titleClasses">{{ $options.i18n.stageNames.build }}</div> - <demo-job-pill :job-name="$options.i18n.jobNames.build" /> - </div> - <div :class="$options.stageClasses" class="gl-mr-5"> - <div :class="$options.titleClasses">{{ $options.i18n.stageNames.test }}</div> - <demo-job-pill class="gl-mb-3" :job-name="$options.i18n.jobNames.test_1" /> - <demo-job-pill :job-name="$options.i18n.jobNames.test_2" /> - </div> - <div :class="$options.stageClasses"> - <div :class="$options.titleClasses">{{ $options.i18n.stageNames.deploy }}</div> - <demo-job-pill :job-name="$options.i18n.jobNames.deploy" /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index a3410d7b837..d373f74a5c4 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -2,13 +2,15 @@ import { EDITOR_READY_EVENT } from '~/editor/constants'; import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getCommitSha from '../../graphql/queries/client/commit_sha.graphql'; export default { components: { EditorLite, }, - inject: ['ciConfigPath', 'projectPath', 'projectNamespace'], + mixins: [glFeatureFlagMixin()], + inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'], inheritAttrs: false, data() { return { @@ -25,14 +27,16 @@ export default { this.$emit('updateCiConfig', content); }, registerCiSchema() { - const editorInstance = this.$refs.editor.getEditor(); + if (this.glFeatures.schemaLinting) { + const editorInstance = this.$refs.editor.getEditor(); - editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); - editorInstance.registerCiSchema({ - projectPath: this.projectPath, - projectNamespace: this.projectNamespace, - ref: this.commitSha, - }); + editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); + editorInstance.registerCiSchema({ + projectPath: this.projectPath, + projectNamespace: this.projectNamespace, + ref: this.commitSha || this.defaultBranch, + }); + } }, }, readyEvent: EDITOR_READY_EVENT, 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 1acf3a03e73..05b87abecd5 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 @@ -6,7 +6,10 @@ import { GlInfiniteScroll, GlLoadingIcon, GlSearchBoxByType, + GlTooltipDirective, } from '@gitlab/ui'; +import { produce } from 'immer'; +import { fetchPolicies } from '~/lib/graphql'; import { historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -15,12 +18,14 @@ import { BRANCH_SEARCH_DEBOUNCE, DEFAULT_FAILURE, } from '~/pipeline_editor/constants'; -import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql'; -import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; +import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql'; +import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql'; +import getCurrentBranchQuery from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; +import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; export default { i18n: { - dropdownHeader: s__('Switch Branch'), + dropdownHeader: s__('Switch branch'), title: s__('Branches'), fetchError: s__('Unable to fetch branch list for this project.'), }, @@ -33,6 +38,9 @@ export default { GlLoadingIcon, GlSearchBoxByType, }, + directives: { + GlTooltip: GlTooltipDirective, + }, inject: ['projectFullPath', 'totalBranches'], props: { paginationLimit: { @@ -43,101 +51,147 @@ export default { }, data() { return { - branches: [], - page: { - limit: this.paginationLimit, - offset: 0, - searchTerm: '', - }, + availableBranches: [], + filteredBranches: [], + isSearchingBranches: false, + pageLimit: this.paginationLimit, + pageCounter: 0, + searchTerm: '', + lastCommitBranch: '', }; }, apollo: { availableBranches: { - query: getAvailableBranches, + query: getAvailableBranchesQuery, variables() { return { - limit: this.page.limit, - offset: this.page.offset, + limit: this.paginationLimit, + offset: 0, projectFullPath: this.projectFullPath, - searchPattern: this.searchPattern, + searchPattern: '*', }; }, update(data) { return data.project?.repository?.branchNames || []; }, - result({ data }) { - const newBranches = data.project?.repository?.branchNames || []; - - // check that we're not re-concatenating existing fetch results - if (!this.branches.includes(newBranches[0])) { - this.branches = this.branches.concat(newBranches); - } + result() { + this.pageCounter += 1; }, error() { - this.$emit('showError', { - type: DEFAULT_FAILURE, - reasons: [this.$options.i18n.fetchError], - }); + this.showFetchError(); }, }, currentBranch: { - query: getCurrentBranch, + query: getCurrentBranchQuery, + }, + lastCommitBranch: { + query: getLastCommitBranchQuery, + result({ data: { lastCommitBranch } }) { + if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) { + return; + } + this.availableBranches.unshift(lastCommitBranch); + }, }, }, computed: { + branches() { + return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches; + }, isBranchesLoading() { - return this.$apollo.queries.availableBranches.loading; + return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; }, showBranchSwitcher() { - return this.branches.length > 0 || this.page.searchTerm.length > 0; + return this.branches.length > 0 || this.searchTerm.length > 0; }, - searchPattern() { - if (this.page.searchTerm === '') { - return '*'; + }, + methods: { + availableBranchesQueryVars(varsOverride = {}) { + if (this.searchTerm.length > 0) { + return { + limit: this.totalBranches, + offset: 0, + projectFullPath: this.projectFullPath, + searchPattern: `*${this.searchTerm}*`, + ...varsOverride, + }; } - return `*${this.page.searchTerm}*`; + return { + limit: this.paginationLimit, + offset: this.pageCounter * this.paginationLimit, + projectFullPath: this.projectFullPath, + searchPattern: '*', + ...varsOverride, + }; }, - }, - methods: { // if there is no searchPattern, paginate by {paginationLimit} branches fetchNextBranches() { if ( this.isBranchesLoading || - this.page.searchTerm.length > 0 || - this.branches.length === this.totalBranches + this.searchTerm.length > 0 || + this.branches.length >= this.totalBranches ) { return; } - this.page = { - ...this.page, - limit: this.paginationLimit, - offset: this.page.offset + this.paginationLimit, - }; + this.$apollo.queries.availableBranches + .fetchMore({ + variables: this.availableBranchesQueryVars(), + updateQuery(previousResult, { fetchMoreResult }) { + const previousBranches = previousResult.project.repository.branchNames; + const newBranches = fetchMoreResult.project.repository.branchNames; + + return produce(fetchMoreResult, (draftData) => { + draftData.project.repository.branchNames = previousBranches.concat(newBranches); + }); + }, + }) + .catch(this.showFetchError); }, async selectBranch(newBranch) { if (newBranch === this.currentBranch) { return; } - await this.$apollo.getClient().writeQuery({ - query: getCurrentBranch, - data: { currentBranch: newBranch }, - }); - + this.updateCurrentBranch(newBranch); const updatedPath = setUrlParams({ branch_name: newBranch }); historyPushState(updatedPath); this.$emit('refetchContent'); }, - setSearchTerm(newSearchTerm) { - this.branches = []; - this.page = { - limit: newSearchTerm.trim() === '' ? this.paginationLimit : this.totalBranches, - offset: 0, - searchTerm: newSearchTerm.trim(), - }; + async setSearchTerm(newSearchTerm) { + this.pageCounter = 0; + this.searchTerm = newSearchTerm.trim(); + + if (this.searchTerm === '') { + this.pageLimit = this.paginationLimit; + return; + } + + this.isSearchingBranches = true; + const fetchResults = await this.$apollo + .query({ + query: getAvailableBranchesQuery, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + variables: this.availableBranchesQueryVars(), + }) + .catch(this.showFetchError); + + this.isSearchingBranches = false; + this.filteredBranches = fetchResults?.data?.project?.repository?.branchNames || []; + }, + showFetchError() { + this.$emit('showError', { + type: DEFAULT_FAILURE, + reasons: [this.$options.i18n.fetchError], + }); + }, + updateCurrentBranch(currentBranch) { + this.$apollo.mutate({ + mutation: updateCurrentBranchMutation, + variables: { currentBranch }, + }); }, }, }; @@ -146,7 +200,8 @@ export default { <template> <gl-dropdown v-if="showBranchSwitcher" - class="gl-ml-2" + v-gl-tooltip.hover + :title="$options.i18n.dropdownHeader" :header-text="$options.i18n.dropdownHeader" :text="currentBranch" icon="branch" @@ -158,7 +213,6 @@ export default { <gl-infinite-scroll :fetched-items="branches.length" - :total-items="totalBranches" :max-list-height="250" @bottomReached="fetchNextBranches" > diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index a945fc542a5..ebe73bdcec3 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -15,7 +15,7 @@ export default { }; </script> <template> - <div class="gl-mb-5"> + <div class="gl-mb-4"> <branch-switcher v-if="showBranchSwitcher" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 4e2f26af51d..c3dcc00af6e 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -22,7 +22,7 @@ import EditorTab from './ui/editor_tab.vue'; export default { i18n: { - tabEdit: s__('Pipelines|Write pipeline configuration'), + tabEdit: s__('Pipelines|Edit'), tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), tabMergedYaml: s__('Pipelines|View merged YAML'), @@ -114,6 +114,7 @@ export default { :empty-message="$options.i18n.empty.visualization" :is-empty="isEmpty" :is-invalid="isInvalid" + :keep-component-mounted="false" :title="$options.i18n.tabGraph" lazy data-testid="visualization-tab" diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index f0a24e0c061..1467abd7289 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -31,3 +31,5 @@ export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded'; export const BRANCH_PAGINATION_LIMIT = 20; export const BRANCH_SEARCH_DEBOUNCE = '500'; + +export const STARTER_TEMPLATE_NAME = 'Getting-Started'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql new file mode 100644 index 00000000000..b722c147f5f --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCurrentBranch($currentBranch: String) { + updateCurrentBranch(currentBranch: $currentBranch) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql new file mode 100644 index 00000000000..9561312f2b6 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateLastCommitBranch($lastCommitBranch: String) { + updateLastCommitBranch(lastCommitBranch: $lastCommitBranch) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql new file mode 100644 index 00000000000..e8a32d728d5 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql @@ -0,0 +1,3 @@ +query getLastCommitBranchQuery { + lastCommitBranch @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql new file mode 100644 index 00000000000..88825718f7b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql @@ -0,0 +1,7 @@ +query getTemplate($projectPath: ID!, $templateName: String!) { + project(fullPath: $projectPath) { + ciTemplate(name: $templateName) { + content + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 81e75c32846..8cead7f3315 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,5 +1,8 @@ +import produce from 'immer'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import getCurrentBranchQuery from './queries/client/current_branch.graphql'; +import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; export const resolvers = { Query: { @@ -39,5 +42,21 @@ export const resolvers = { __typename: 'CiLintContent', })); }, + updateCurrentBranch: (_, { currentBranch = undefined }, { cache }) => { + cache.writeQuery({ + query: getCurrentBranchQuery, + data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => { + draftData.currentBranch = currentBranch; + }), + }); + }, + updateLastCommitBranch: (_, { lastCommitBranch = undefined }, { cache }) => { + cache.writeQuery({ + query: getLastCommitBranchQuery, + data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => { + draftData.lastCommitBranch = lastCommitBranch; + }), + }); + }, }, }; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 66158bdba88..e0f8d889cad 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -6,6 +6,7 @@ import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; import getCommitSha from './graphql/queries/client/commit_sha.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; +import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql'; import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql'; import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; @@ -82,6 +83,13 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { }, }); + cache.writeQuery({ + query: getLastCommitBranchQuery, + data: { + lastCommitBranch: '', + }, + }); + return new Vue({ el, apolloProvider, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 79a2a51cebc..c24e6523352 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -14,12 +14,14 @@ import { EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_LOADING, LOAD_FAILURE_UNKNOWN, + STARTER_TEMPLATE_NAME, } from './constants'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; import getAppStatus from './graphql/queries/client/app_status.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql'; +import getTemplate from './graphql/queries/get_starter_template.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; export default { @@ -51,12 +53,13 @@ export default { showStartScreen: false, showSuccess: false, showFailure: false, + starterTemplate: '', }; }, apollo: { initialCiFileContent: { - fetchPolicy: fetchPolicies.NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, query: getBlobContent, // If it's a brand new file, we don't want to fetch the content. // Then when the user commits the first time, the query would run @@ -135,6 +138,27 @@ export default { isNewCiConfigFile: { query: getIsNewCiConfigFile, }, + starterTemplate: { + query: getTemplate, + variables() { + return { + projectPath: this.projectFullPath, + templateName: STARTER_TEMPLATE_NAME, + }; + }, + skip({ isNewCiConfigFile }) { + return !isNewCiConfigFile; + }, + update(data) { + return data.project?.ciTemplate?.content || ''; + }, + result({ data }) { + this.updateCiConfig(data.project?.ciTemplate?.content || ''); + }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + }, + }, }, computed: { hasUnsavedChanges() { @@ -151,7 +175,7 @@ export default { }, }, i18n: { - tabEdit: s__('Pipelines|Write pipeline configuration'), + tabEdit: s__('Pipelines|Edit'), tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), }, @@ -228,7 +252,8 @@ export default { .getClient() .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: false } }); } - // Keep track of the latest commited content to know + + // Keep track of the latest committed content to know // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; }, 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 9329a35ba99..fb45738f8d1 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -1,12 +1,12 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; -import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; import { reportToSentry, reportMessageToSentry } from '../../utils'; import { listByLayers } from '../parsing_utils'; import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; 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 1435276edd3..3c78b655dc7 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,6 +1,6 @@ <script> import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; export default { @@ -36,20 +36,20 @@ export default { }, i18n: { hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'), - linksLabelText: __('Show dependencies'), + linksLabelText: s__('GraphViewType|Show dependencies'), viewLabelText: __('Group jobs by'), }, views: { [STAGE_VIEW]: { type: STAGE_VIEW, text: { - primary: __('Stage'), + primary: s__('GraphViewType|Stage'), }, }, [LAYER_VIEW]: { type: LAYER_VIEW, text: { - primary: __('Job dependencies'), + primary: s__('GraphViewType|Job dependencies'), }, }, }, diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 3f746731e34..b3c5af5418f 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -58,7 +58,7 @@ export default { }, computed: { tooltipText() { - return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} + return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - ${this.sourceJobInfo}`; }, buttonId() { @@ -71,7 +71,7 @@ export default { return this.pipeline.project.name; }, downstreamTitle() { - return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name; + return this.childPipeline ? this.sourceJobName : this.pipeline.project.name; }, parentPipeline() { return this.isUpstream && this.isSameProject; @@ -163,7 +163,7 @@ export default { /> <div v-else class="gl-pr-2"><gl-loading-icon inline /></div> <div class="gl-display-flex gl-flex-direction-column gl-w-13"> - <span class="gl-text-truncate"> + <span class="gl-text-truncate" data-testid="downstream-title"> {{ downstreamTitle }} </span> <div class="gl-text-truncate"> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 7c306683305..7c62acbe8de 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -60,8 +60,16 @@ export const generateLinksData = ({ links }, containerID, modifier = '') => { paddingTop + sourceNodeCoordinates.height / 2; - // Start point - path.moveTo(sourceNodeX, sourceNodeY); + const sourceNodeLeftX = sourceNodeCoordinates.left - containerCoordinates.x - paddingLeft; + + // If the source and target X values are the same, + // it means the nodes are in the same column so we + // want to start the line on the left of the pill + // instead of the right to have a nice curve. + const firstPointCoordinateX = sourceNodeLeftX === targetNodeX ? sourceNodeLeftX : sourceNodeX; + + // First point + path.moveTo(firstPointCoordinateX, sourceNodeY); // Make cross-stages lines a straight line all the way // until we can safely draw the bezier to look nice. diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index d8e7b83a8c1..b7500ef00b0 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -132,6 +132,16 @@ export default { }; } }, + canRetryPipeline() { + const { retryable, userPermissions } = this.pipeline; + + return retryable && userPermissions.updatePipeline; + }, + canCancelPipeline() { + const { cancelable, userPermissions } = this.pipeline; + + return cancelable && userPermissions.updatePipeline; + }, }, watch: { isFinished(finished) { @@ -219,7 +229,7 @@ export default { item-name="Pipeline" > <gl-button - v-if="pipeline.retryable" + v-if="canRetryPipeline" :loading="isRetrying" :disabled="isRetrying" category="secondary" @@ -232,7 +242,7 @@ export default { </gl-button> <gl-button - v-if="pipeline.cancelable" + v-if="canCancelPipeline" :loading="isCanceling" :disabled="isCanceling" class="gl-ml-3" diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 3972c126673..d19215e7895 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { dasherize } from '~/lib/utils/text_utility'; @@ -81,7 +81,9 @@ export default { reportToSentry('action_component', err); - createFlash(__('An error occurred while making the request.')); + createFlash({ + message: __('An error occurred while making the request.'), + }); }); }, }, diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue deleted file mode 100644 index 6dff3828a34..00000000000 --- a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue +++ /dev/null @@ -1,90 +0,0 @@ -<script> -import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { __ } from '~/locale'; -import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; -import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; - -const featureName = 'pipeline_needs_banner'; -const enumFeatureName = featureName.toUpperCase(); - -export default { - i18n: { - title: __('View job dependencies in the pipeline graph!'), - description: __( - 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}', - ), - buttonText: __('Provide feedback'), - }, - components: { - GlBanner, - GlLink, - GlSprintf, - }, - apollo: { - callouts: { - query: getUserCallouts, - update(data) { - return data?.currentUser?.callouts?.nodes.map((c) => c.featureName); - }, - error() { - this.hasError = true; - }, - }, - }, - inject: ['dagDocPath'], - data() { - return { - callouts: [], - dismissedAlert: false, - hasError: false, - }; - }, - computed: { - showBanner() { - return ( - !this.$apollo.queries.callouts?.loading && - !this.hasError && - !this.dismissedAlert && - !this.callouts.includes(enumFeatureName) - ); - }, - }, - methods: { - handleClose() { - this.dismissedAlert = true; - try { - this.$apollo.mutate({ - mutation: DismissPipelineGraphCallout, - variables: { - featureName, - }, - }); - } catch { - createFlash(__('There was a problem dismissing this notification.')); - } - }, - }, -}; -</script> -<template> - <gl-banner - v-if="showBanner" - :title="$options.i18n.title" - :button-text="$options.i18n.buttonText" - button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688" - variant="introduction" - @close="handleClose" - > - <p> - <gl-sprintf :message="$options.i18n.description"> - <template #link="{ content }"> - <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link> - </template> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - </gl-banner> -</template> diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index 9d886e0e379..f1d9ced807b 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -55,28 +55,32 @@ export const createNodeDict = (nodes) => { export const makeLinksFromNodes = (nodes, nodeDict) => { const constantLinkValue = 10; // all links are the same weight return nodes - .map((group) => { - return group.jobs.map((job) => { - if (!job.needs) { - return []; - } - - return job.needs.map((needed) => { - return { - source: nodeDict[needed]?.name, - target: group.name, - value: constantLinkValue, - }; - }); - }); - }) + .map(({ jobs, name: groupName }) => + jobs.map(({ needs = [] }) => + needs.reduce((acc, needed) => { + // It's possible that we have an optional job, which + // is being needed by another job. In that scenario, + // the needed job doesn't exist, so we don't want to + // create link for it. + if (nodeDict[needed]?.name) { + acc.push({ + source: nodeDict[needed].name, + target: groupName, + value: constantLinkValue, + }); + } + + return acc; + }, []), + ), + ) .flat(2); }; export const getAllAncestors = (nodes, nodeDict) => { const needs = nodes .map((node) => { - return nodeDict[node].needs || ''; + return nodeDict[node]?.needs || ''; }) .flat() .filter(Boolean); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index e9773f055a7..104a3caab4c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -2,6 +2,7 @@ import { GlEmptyState, GlButton } from '@gitlab/ui'; import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils'; import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; import { getExperimentData } from '~/experimentation/utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; @@ -13,7 +14,9 @@ export default { description: s__(`Pipelines|GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time consuming tasks, so you can spend more time creating.`), - btnText: s__('Pipelines|Get started with CI/CD'), + aboutRunnersBtnText: s__('Pipelines|Learn about Runners'), + installRunnersBtnText: s__('Pipelines|Install GitLab Runners'), + getStartedBtnText: s__('Pipelines|Get started with CI/CD'), codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'), codeQualityDescription: s__(`Pipelines|To keep your codebase simple, readable, and accessible to contributors, use GitLab CI/CD @@ -42,6 +45,11 @@ export default { required: false, default: null, }, + ciRunnerSettingsPath: { + type: String, + required: false, + default: null, + }, }, computed: { ciHelpPagePath() { @@ -50,6 +58,12 @@ export default { isPipelineEmptyStateTemplatesExperimentActive() { return this.canSetCi && Boolean(getExperimentData('pipeline_empty_state_templates')); }, + isCodeQualityExperimentActive() { + return this.canSetCi && Boolean(getExperimentData('code_quality_walkthrough')); + }, + isCiRunnerTemplatesExperimentActive() { + return this.canSetCi && Boolean(getExperimentData('ci_runner_templates')); + }, }, mounted() { startCodeQualityWalkthrough(); @@ -58,6 +72,10 @@ export default { trackClick() { track('cta_clicked'); }, + trackCiRunnerTemplatesClick(action) { + const tracking = new ExperimentTracking('ci_runner_templates'); + tracking.event(action); + }, }, }; </script> @@ -72,7 +90,7 @@ export default { :title="$options.i18n.title" :svg-path="emptyStateSvgPath" :description="$options.i18n.description" - :primary-button-text="$options.i18n.btnText" + :primary-button-text="$options.i18n.getStartedBtnText" :primary-button-link="ciHelpPagePath" /> </template> @@ -80,7 +98,7 @@ export default { <pipelines-ci-templates /> </template> </gitlab-experiment> - <gitlab-experiment v-else-if="canSetCi" name="code_quality_walkthrough"> + <gitlab-experiment v-else-if="isCodeQualityExperimentActive" name="code_quality_walkthrough"> <template #control> <gl-empty-state :title="$options.i18n.title" @@ -89,7 +107,7 @@ export default { > <template #actions> <gl-button :href="ciHelpPagePath" variant="confirm" @click="trackClick()"> - {{ $options.i18n.btnText }} + {{ $options.i18n.getStartedBtnText }} </gl-button> </template> </gl-empty-state> @@ -108,6 +126,57 @@ export default { </gl-empty-state> </template> </gitlab-experiment> + <gitlab-experiment v-else-if="isCiRunnerTemplatesExperimentActive" name="ci_runner_templates"> + <template #control> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.description" + > + <template #actions> + <gl-button + :href="ciHelpPagePath" + variant="confirm" + @click="trackCiRunnerTemplatesClick('get_started_button_clicked')" + > + {{ $options.i18n.getStartedBtnText }} + </gl-button> + </template> + </gl-empty-state> + </template> + <template #candidate> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.description" + > + <template #actions> + <gl-button + :href="ciRunnerSettingsPath" + variant="confirm" + @click="trackCiRunnerTemplatesClick('install_runners_button_clicked')" + > + {{ $options.i18n.installRunnersBtnText }} + </gl-button> + <gl-button + :href="ciHelpPagePath" + variant="default" + @click="trackCiRunnerTemplatesClick('learn_button_clicked')" + > + {{ $options.i18n.aboutRunnersBtnText }} + </gl-button> + </template> + </gl-empty-state> + </template> + </gitlab-experiment> + <gl-empty-state + v-else-if="canSetCi" + :title="$options.i18n.title" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.description" + :primary-button-text="$options.i18n.getStartedBtnText" + :primary-button-link="ciHelpPagePath" + /> <gl-empty-state v-else title="" 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 d39e120dc6c..52c8ef2cf26 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -56,6 +56,7 @@ export default { <div class="pipeline-tags" data-testid="pipeline-url-table-cell"> <gl-link :href="pipeline.path" + class="gl-text-decoration-underline" data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 0218cb2e1b8..8bb2657c161 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,7 +1,7 @@ <script> import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { getParameterByName } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; @@ -99,6 +99,11 @@ export default { required: false, default: null, }, + ciRunnerSettingsPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -249,11 +254,16 @@ export default { .postAction(endpoint) .then(() => { this.isResetCacheButtonLoading = false; - createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); + createFlash({ + message: s__('Pipelines|Project cache successfully reset.'), + type: 'notice', + }); }) .catch(() => { this.isResetCacheButtonLoading = false; - createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); + createFlash({ + message: s__('Pipelines|Something went wrong while cleaning runners cache.'), + }); }); }, resetRequestData() { @@ -278,7 +288,10 @@ export default { } if (!filter.type) { - createFlash(RAW_TEXT_WARNING, 'warning'); + createFlash({ + message: RAW_TEXT_WARNING, + type: 'warning', + }); } }); @@ -337,6 +350,7 @@ export default { :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" :code-quality-page-path="codeQualityPagePath" + :ci-runner-settings-path="ciRunnerSettingsPath" /> <gl-empty-state diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index 20a232beb83..15ff7da35e1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; export default { @@ -38,7 +38,9 @@ export default { this.loading = false; }) .catch((err) => { - createFlash(FETCH_BRANCH_ERROR_MESSAGE); + createFlash({ + message: FETCH_BRANCH_ERROR_MESSAGE, + }); this.loading = false; throw err; }); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index 4a8d89ebe37..af62c492748 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; export default { @@ -38,7 +38,9 @@ export default { this.loading = false; }) .catch((err) => { - createFlash(FETCH_TAG_ERROR_MESSAGE); + createFlash({ + message: FETCH_TAG_ERROR_MESSAGE, + }); this.loading = false; throw err; }); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index 3db5893b565..bc661f37493 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -8,7 +8,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { ANY_TRIGGER_AUTHOR, FETCH_AUTHOR_ERROR_MESSAGE, @@ -61,7 +61,9 @@ export default { this.loading = false; }) .catch((err) => { - createFlash(FETCH_AUTHOR_ERROR_MESSAGE); + createFlash({ + message: FETCH_AUTHOR_ERROR_MESSAGE, + }); this.loading = false; throw err; }); diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index 1b3f80b1f18..de8de651eea 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -8,6 +8,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { cancelable userPermissions { destroyPipeline + updatePipeline } detailedStatus { detailsPath diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index d9c9289f66e..082d67c938c 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -1,5 +1,5 @@ import Visibility from 'visibilityjs'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; @@ -169,7 +169,11 @@ export default { this.service .postAction(endpoint) .then(() => this.updateTable()) - .catch(() => createFlash(__('An error occurred while making the request.'))); + .catch(() => + createFlash({ + message: __('An error occurred while making the request.'), + }), + ); }, /** @@ -189,9 +193,11 @@ export default { .runMRPipeline(options) .then(() => this.updateTable()) .catch(() => { - createFlash( - __('An error occurred while trying to run a new pipeline for this merge request.'), - ); + createFlash({ + message: __( + 'An error occurred while trying to run a new pipeline for this merge request.', + ), + }); }) .finally(() => this.store.toggleIsRunningPipeline(false)); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 911f40f4db3..9ab4753fec8 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -9,7 +9,6 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; -import { createPipelineNotificationApp } from './pipeline_details_notification'; import { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; import { reportToSentry } from './utils'; @@ -20,7 +19,6 @@ const SELECTORS = { PIPELINE_DETAILS: '.js-pipeline-details-vue', PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', - PIPELINE_NOTIFICATION: '#js-pipeline-notification', PIPELINE_TESTS: '#js-pipeline-tests-detail', }; @@ -101,14 +99,6 @@ export default async function initPipelineDetailsBundle() { Flash(__('An error occurred while loading a section of this page.')); } - if (gon.features.pipelineGraphLayersView) { - try { - createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider); - } catch { - Flash(__('An error occurred while loading a section of this page.')); - } - } - if (canShowNewPipelineDetails) { try { createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js deleted file mode 100644 index be234e8972d..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_notification.js +++ /dev/null @@ -1,29 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import PipelineNotification from './components/notification/pipeline_notification.vue'; - -Vue.use(VueApollo); - -export const createPipelineNotificationApp = (elSelector, apolloProvider) => { - const el = document.querySelector(elSelector); - - if (!el) { - return; - } - - const { dagDocPath } = el?.dataset; - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - PipelineNotification, - }, - provide: { - dagDocPath, - }, - apolloProvider, - render(createElement) { - return createElement('pipeline-notification'); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index c892311782c..925a96ea1aa 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -38,6 +38,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { projectId, params, codeQualityPagePath, + ciRunnerSettingsPath, } = el.dataset; return new Vue({ @@ -76,6 +77,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { projectId, params: JSON.parse(params), codeQualityPagePath, + ciRunnerSettingsPath, }, }); }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index 6de345233ae..7b28d48b5b6 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import * as types from './mutation_types'; @@ -12,7 +12,9 @@ export const fetchSummary = ({ state, commit, dispatch }) => { commit(types.SET_SUMMARY, data); }) .catch(() => { - createFlash(s__('TestReports|There was an error fetching the summary.')); + createFlash({ + message: s__('TestReports|There was an error fetching the summary.'), + }); }) .finally(() => { dispatch('toggleLoading'); @@ -36,7 +38,9 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { .get(state.suiteEndpoint, { params: { build_ids } }) .then(({ data }) => commit(types.SET_SUITE, { suite: data, index })) .catch(() => { - createFlash(s__('TestReports|There was an error fetching the test suite.')); + createFlash({ + message: s__('TestReports|There was an error fetching the test suite.'), + }); }) .finally(() => { dispatch('toggleLoading'); diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 800a363cada..02a9e5b7fc6 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -39,7 +39,13 @@ export const generateJobNeedsDict = (jobs = {}) => { } return jobs[jobName].needs - .map((job) => { + .reduce((needsAcc, job) => { + // It's possible that a needs refer to an optional job + // that is not defined in which case we don't add that entry + if (!jobs[job]) { + return needsAcc; + } + // If we already have the needs of a job in the accumulator, // then we use the memoized data instead of the recursive call // to save some performance. @@ -50,11 +56,11 @@ export const generateJobNeedsDict = (jobs = {}) => { // to the list of `needs` to ensure we can properly reference it. const group = jobs[job]; if (group.size > 1) { - return [job, group.name, newNeeds]; + return [...needsAcc, job, group.name, newNeeds]; } - return [job, newNeeds]; - }) + return [...needsAcc, job, newNeeds]; + }, []) .flat(Infinity); }; diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index 2336cb18cb5..17cbcabeedb 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -1,12 +1,12 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSprintf } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; -import { __, s__, sprintf } from '~/locale'; +import { __, s__ } from '~/locale'; export default { components: { GlModal, + GlSprintf, }, props: { actionUrl: { @@ -32,33 +32,8 @@ export default { csrfToken() { return csrf.token; }, - inputLabel() { - let confirmationValue; - if (this.confirmWithPassword) { - confirmationValue = __('password'); - } else { - confirmationValue = __('username'); - } - - confirmationValue = `<code>${confirmationValue}</code>`; - - return sprintf( - s__('Profiles|Type your %{confirmationValue} to confirm:'), - { confirmationValue }, - false, - ); - }, - text() { - return sprintf( - s__(`Profiles| -You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account. -Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), - { - yourAccount: `<strong>${s__('Profiles|your account')}</strong>`, - deleteAccount: `<strong>${s__('Profiles|Delete account')}</strong>`, - }, - false, - ); + confirmationValue() { + return this.confirmWithPassword ? __('password') : __('username'); }, primaryProps() { return { @@ -90,6 +65,12 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), this.$refs.form.submit(); }, }, + i18n: { + text: s__(`Profiles| +You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account. +Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), + inputLabel: s__('Profiles|Type your %{confirmationValue} to confirm:'), + }, }; </script> @@ -102,13 +83,29 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), :ok-disabled="!canSubmit" @primary="onSubmit" > - <p v-html="text"></p> + <p> + <gl-sprintf :message="$options.i18n.text"> + <template #yourAccount> + <strong>{{ s__('Profiles|your account') }}</strong> + </template> + + <template #deleteAccount> + <strong>{{ s__('Profiles|Delete account') }}</strong> + </template> + </gl-sprintf> + </p> <form ref="form" :action="actionUrl" method="post"> <input type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <p id="input-label" v-html="inputLabel"></p> + <p id="input-label"> + <gl-sprintf :message="$options.i18n.inputLabel"> + <template #confirmationValue> + <code>{{ confirmationValue }}</code> + </template> + </gl-sprintf> + </p> <input v-if="confirmWithPassword" diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index f18c4d8f03e..7917a9a75e0 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,7 +1,7 @@ <script> import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { escape } from 'lodash'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__, sprintf } from '~/locale'; @@ -85,15 +85,16 @@ Please update your Git repository remotes as soon as possible.`), return axios .put(this.actionUrl, putData) .then((result) => { - Flash(result.data.message, 'notice'); + createFlash({ message: result.data.message, type: 'notice' }); this.username = username; this.isRequestPending = false; }) .catch((error) => { - Flash( - error?.response?.data?.message || + createFlash({ + message: + error?.response?.data?.message || s__('Profiles|An error occurred while updating your username, please try again.'), - ); + }); this.isRequestPending = false; throw error; }); diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index afc78cbe78a..722f7d467a2 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -114,7 +114,9 @@ import { loadCSSFile } from '../lib/utils/css_utils'; } onModalHide() { - return this.modalCropImg.attr('src', '').cropper('destroy'); + this.modalCropImg.attr('src', '').cropper('destroy'); + const modalElement = document.querySelector('.modal-profile-crop'); + if (modalElement) modalElement.remove(); } onUploadImageBtnClick(e) { diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 6eefa5f55e4..ec7d37644a8 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -3,7 +3,6 @@ import { GlModal, GlForm, GlFormCheckbox, GlSprintf, GlFormGroup } from '@gitlab import { mapActions, mapState } from 'vuex'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import csrf from '~/lib/utils/csrf'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import BranchesDropdown from './branches_dropdown.vue'; import ProjectsDropdown from './projects_dropdown.vue'; @@ -18,7 +17,6 @@ export default { GlSprintf, GlFormGroup, }, - mixins: [glFeatureFlagsMixin()], inject: { prependedText: { default: '', @@ -116,7 +114,7 @@ export default { <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> <gl-form-group - v-if="glFeatures.pickIntoProject && isCherryPick" + v-if="isCherryPick" :label="i18n.projectLabel" label-for="start_project" data-testid="dropdown-group" diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 741dc20b1f1..795c293d14b 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/browser'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -13,7 +13,9 @@ export default { commit(types.COMMITS_AUTHORS, authors); }, receiveAuthorsError() { - createFlash(__('An error occurred fetching the project authors.')); + createFlash({ + message: __('An error occurred fetching the project authors.'), + }); }, fetchAuthors({ dispatch, state }, author = null) { const { projectId } = state; diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue index 1a07f5495a1..c1dae75801e 100644 --- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue +++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -65,7 +65,9 @@ export default { this.authorizationKey = res.data.token; }) .catch(() => { - createFlash(__('Failed to reset key. Please try again.')); + createFlash({ + message: __('Failed to reset key. Please try again.'), + }); }); }, }, diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index bd2694e0cf7..86273cfdda6 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -1,8 +1,8 @@ import { find } from 'lodash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import AccessDropdown from '~/projects/settings/access_dropdown'; -import { deprecatedCreateFlash as flash } from '../flash'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchEdit { @@ -64,7 +64,7 @@ export default class ProtectedBranchEdit { }) .then(callback) .catch(() => { - flash(__('Failed to update branch!')); + createFlash({ message: __('Failed to update branch!') }); }); } @@ -131,7 +131,7 @@ export default class ProtectedBranchEdit { .catch(() => { this.$allowedToMergeDropdown.enable(); this.$allowedToPushDropdown.enable(); - flash(__('Failed to update branch!')); + createFlash({ message: __('Failed to update branch!') }); }); } diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js index f66839a74bf..1f82fd7f238 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/registry/explorer/index.js @@ -34,6 +34,7 @@ export default () => { expirationPolicy, isGroupPage, isAdmin, + showCleanupPolicyOnAlert, showUnfinishedTagCleanupCallout, ...config } = el.dataset; @@ -64,6 +65,7 @@ export default () => { expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined, isGroupPage: parseBoolean(isGroupPage), isAdmin: parseBoolean(isAdmin), + showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert), showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout), }, /* eslint-disable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 589b88d7bbe..3c8790fa6e5 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -11,6 +11,7 @@ import { import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import createFlash from '~/flash'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import Tracking from '~/tracking'; @@ -61,6 +62,7 @@ export default { RegistryHeader, DeleteImage, RegistrySearch, + CleanupPolicyEnabledAlert, }, directives: { GlTooltip: GlTooltipDirective, @@ -283,6 +285,12 @@ export default { </gl-sprintf> </gl-alert> + <cleanup-policy-enabled-alert + v-if="config.showCleanupPolicyOnAlert" + :project-path="config.projectPath" + :cleanup-policies-settings-path="config.cleanupPoliciesSettingsPath" + /> + <gl-empty-state v-if="config.characterError" :title="$options.i18n.CONNECTION_ERROR_TITLE" 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 c35a1ff0b63..7e2fda8495c 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -23,7 +23,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: and hide the `AddIssuableForm` area. */ -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { relatedIssuesRemoveErrorMap, @@ -122,11 +122,11 @@ export default { }) .catch((res) => { if (res && res.status !== 404) { - Flash(relatedIssuesRemoveErrorMap[this.issuableType]); + createFlash({ message: relatedIssuesRemoveErrorMap[this.issuableType] }); } }); } else { - Flash(pathIndeterminateErrorMap[this.issuableType]); + createFlash({ message: pathIndeterminateErrorMap[this.issuableType] }); } }, onToggleAddRelatedIssuesForm() { @@ -155,7 +155,7 @@ export default { if (response && response.data && response.data.message) { errorMessage = response.data.message; } - Flash(errorMessage); + createFlash({ message: errorMessage }); }) .finally(() => { this.isSubmitting = false; @@ -176,7 +176,7 @@ export default { }) .catch(() => { this.store.setRelatedIssues([]); - Flash(__('An error occurred while fetching issues.')); + createFlash({ message: __('An error occurred while fetching issues.') }); }) .finally(() => { this.isFetching = false; @@ -197,7 +197,7 @@ export default { } }) .catch(() => { - Flash(__('An error occurred while reordering issues.')); + createFlash({ message: __('An error occurred while reordering issues.') }); }); } }, diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js index e9f0793a350..652d03a0fd0 100644 --- a/app/assets/javascripts/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/related_merge_requests/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; @@ -29,6 +29,8 @@ export const fetchMergeRequests = ({ state, dispatch }) => { }) .catch(() => { dispatch('receiveDataError'); - createFlash(s__('Something went wrong while fetching related merge requests.')); + createFlash({ + message: s__('Something went wrong while fetching related merge requests.'), + }); }); }; diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue new file mode 100644 index 00000000000..ea0aa409577 --- /dev/null +++ b/app/assets/javascripts/releases/components/app_index_apollo_client.vue @@ -0,0 +1,275 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { historyPushState, getParameterByName } from '~/lib/utils/common_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; +import { convertAllReleasesGraphQLResponse } from '~/releases/util'; +import ReleaseBlock from './release_block.vue'; +import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; +import ReleasesEmptyState from './releases_empty_state.vue'; +import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue'; +import ReleasesSortApolloClient from './releases_sort_apollo_client.vue'; + +export default { + name: 'ReleasesIndexApolloClientApp', + components: { + GlButton, + ReleaseBlock, + ReleaseSkeletonLoader, + ReleasesEmptyState, + ReleasesPaginationApolloClient, + ReleasesSortApolloClient, + }, + inject: { + projectPath: { + default: '', + }, + newReleasePath: { + default: '', + }, + }, + apollo: { + /** + * The same query as `fullGraphqlResponse`, except that it limits its + * results to a single item. This causes this request to complete much more + * quickly than `fullGraphqlResponse`, which allows the page to show + * meaningful content to the user much earlier. + */ + singleGraphqlResponse: { + query: allReleasesQuery, + // This trick only works when paginating _forward_. + // When paginating backwards, limiting the query to a single item loads + // the _last_ item in the page, which is not useful for our purposes. + skip() { + return !this.includeSingleQuery; + }, + variables() { + return { + ...this.queryVariables, + first: 1, + }; + }, + update(data) { + return { data }; + }, + error() { + this.singleRequestError = true; + }, + }, + fullGraphqlResponse: { + query: allReleasesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return { data }; + }, + error(error) { + this.fullRequestError = true; + + createFlash({ + message: this.$options.i18n.errorMessage, + captureError: true, + error, + }); + }, + }, + }, + data() { + return { + singleRequestError: false, + fullRequestError: false, + cursors: { + before: getParameterByName('before'), + after: getParameterByName('after'), + }, + sort: DEFAULT_SORT, + }; + }, + computed: { + queryVariables() { + let paginationParams = { first: PAGE_SIZE }; + if (this.cursors.after) { + paginationParams = { + after: this.cursors.after, + first: PAGE_SIZE, + }; + } else if (this.cursors.before) { + paginationParams = { + before: this.cursors.before, + last: PAGE_SIZE, + }; + } + + return { + fullPath: this.projectPath, + ...paginationParams, + sort: this.sort, + }; + }, + /** + * @returns {Boolean} Whether or not to request/include + * the results of the single-item query + */ + includeSingleQuery() { + return Boolean(!this.cursors.before || this.cursors.after); + }, + isSingleRequestLoading() { + return this.$apollo.queries.singleGraphqlResponse.loading; + }, + isFullRequestLoading() { + return this.$apollo.queries.fullGraphqlResponse.loading; + }, + /** + * @returns {Boolean} `true` if the `singleGraphqlResponse` + * query has finished loading without errors + */ + isSingleRequestLoaded() { + return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project); + }, + /** + * @returns {Boolean} `true` if the `fullGraphqlResponse` + * query has finished loading without errors + */ + isFullRequestLoaded() { + return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project); + }, + releases() { + if (this.isFullRequestLoaded) { + return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data; + } + + if (this.isSingleRequestLoaded && this.includeSingleQuery) { + return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data; + } + + return []; + }, + pageInfo() { + if (!this.isFullRequestLoaded) { + return { + hasPreviousPage: false, + hasNextPage: false, + }; + } + + return this.fullGraphqlResponse.data.project.releases.pageInfo; + }, + shouldRenderEmptyState() { + return this.isFullRequestLoaded && this.releases.length === 0; + }, + shouldRenderLoadingIndicator() { + return ( + (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) || + (this.isFullRequestLoading && !this.fullRequestError) + ); + }, + shouldRenderPagination() { + return this.isFullRequestLoaded && !this.shouldRenderEmptyState; + }, + }, + created() { + this.updateQueryParamsFromUrl(); + + window.addEventListener('popstate', this.updateQueryParamsFromUrl); + }, + destroyed() { + window.removeEventListener('popstate', this.updateQueryParamsFromUrl); + }, + methods: { + getReleaseKey(release, index) { + return [release.tagName, release.name, index].join('|'); + }, + updateQueryParamsFromUrl() { + this.cursors.before = getParameterByName('before'); + this.cursors.after = getParameterByName('after'); + }, + onPaginationButtonPress() { + this.updateQueryParamsFromUrl(); + + // In some cases, Apollo Client is able to pull its results from the cache instead of making + // a new network request. In these cases, the page's content gets swapped out immediately without + // changing the page's scroll, leaving the user looking at the bottom of the new page. + // To make the experience consistent, regardless of how the data is sourced, we manually + // scroll to the top of the page every time a pagination button is pressed. + scrollUp(); + }, + onSortChanged(newSort) { + if (this.sort === newSort) { + return; + } + + // Remove the "before" and "after" query parameters from the URL, + // effectively placing the user back on page 1 of the results. + // This prevents the frontend from requesting the results sorted + // by one field (e.g. `released_at`) while using a pagination cursor + // intended for a different field (e.g.) `created_at`). + // For more details, see the MR that introduced this change: + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434 + historyPushState( + setUrlParams({ + before: null, + after: null, + }), + ); + + this.updateQueryParamsFromUrl(); + + this.sort = newSort; + }, + }, + i18n: { + newRelease: __('New release'), + errorMessage: __('An error occurred while fetching the releases. Please try again.'), + }, +}; +</script> +<template> + <div class="flex flex-column mt-2"> + <div class="gl-align-self-end gl-mb-3"> + <releases-sort-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" /> + + <gl-button + v-if="newReleasePath" + :href="newReleasePath" + :aria-describedby="shouldRenderEmptyState && 'releases-description'" + category="primary" + variant="success" + >{{ $options.i18n.newRelease }}</gl-button + > + </div> + + <releases-empty-state v-if="shouldRenderEmptyState" /> + + <release-block + v-for="(release, index) in releases" + :key="getReleaseKey(release, index)" + :release="release" + :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" + /> + + <release-skeleton-loader v-if="shouldRenderLoadingIndicator" /> + + <releases-pagination-apollo-client + v-if="shouldRenderPagination" + :page-info="pageInfo" + @prev="onPaginationButtonPress" + @next="onPaginationButtonPress" + /> + </div> +</template> +<style> +.linked-card::after { + width: 1px; + content: ' '; + border: 1px solid #e5e5e5; + height: 17px; + top: 100%; + position: absolute; + left: 32px; +} +</style> diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue new file mode 100644 index 00000000000..800497c186a --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_empty_state.vue @@ -0,0 +1,44 @@ +<script> +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'ReleasesEmptyState', + components: { + GlEmptyState, + GlLink, + }, + inject: { + documentationPath: { + default: '', + }, + illustrationPath: { + default: '', + }, + }, + i18n: { + emptyStateTitle: __('Getting started with releases'), + emptyStateText: __( + "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", + ), + releasesDocumentation: __('Releases documentation'), + moreInformation: __('More information'), + }, +}; +</script> +<template> + <gl-empty-state :title="$options.i18n.emptyStateTitle" :svg-path="illustrationPath"> + <template #description> + <span id="releases-description"> + {{ $options.i18n.emptyStateText }} + <gl-link + :href="documentationPath" + :aria-label="$options.i18n.releasesDocumentation" + target="_blank" + > + {{ $options.i18n.moreInformation }} + </gl-link> + </span> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue new file mode 100644 index 00000000000..73339677a4b --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue @@ -0,0 +1,37 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { isBoolean } from 'lodash'; +import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; + +export default { + name: 'ReleasesPaginationApolloClient', + components: { GlKeysetPagination }, + props: { + pageInfo: { + type: Object, + required: true, + validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage), + }, + }, + methods: { + onPrev(before) { + historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); + }, + onNext(after) { + historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="__('Prev')" + :next-text="__('Next')" + v-on="$listeners" + @prev="onPrev($event)" + @next="onNext($event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue index 4988904a2cd..d4210dad19c 100644 --- a/app/assets/javascripts/releases/components/releases_sort.vue +++ b/app/assets/javascripts/releases/components/releases_sort.vue @@ -1,7 +1,7 @@ <script> import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; +import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; export default { name: 'ReleasesSort', @@ -22,13 +22,13 @@ export default { return option.label; }, isSortAscending() { - return this.sort === ASCENDING_ODER; + return this.sort === ASCENDING_ORDER; }, }, methods: { ...mapActions('index', ['setSorting']), onDirectionChange() { - const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; + const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; this.setSorting({ sort }); this.$emit('sort:changed'); }, diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue new file mode 100644 index 00000000000..7257b34bbf6 --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue @@ -0,0 +1,91 @@ +<script> +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { + ASCENDING_ORDER, + DESCENDING_ORDER, + SORT_OPTIONS, + RELEASED_AT, + CREATED_AT, + RELEASED_AT_ASC, + RELEASED_AT_DESC, + CREATED_ASC, + ALL_SORTS, + SORT_MAP, +} from '../constants'; + +export default { + name: 'ReleasesSortApolloclient', + components: { + GlSorting, + GlSortingItem, + }, + props: { + value: { + type: String, + required: true, + validator: (sort) => ALL_SORTS.includes(sort), + }, + }, + computed: { + orderBy() { + if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) { + return RELEASED_AT; + } + + return CREATED_AT; + }, + direction() { + if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) { + return ASCENDING_ORDER; + } + + return DESCENDING_ORDER; + }, + sortOptions() { + return SORT_OPTIONS; + }, + sortText() { + return this.sortOptions.find((s) => s.orderBy === this.orderBy).label; + }, + isDirectionAscending() { + return this.direction === ASCENDING_ORDER; + }, + }, + methods: { + onDirectionChange() { + const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER; + this.emitInputEventIfChanged(this.orderBy, direction); + }, + onSortItemClick(item) { + this.emitInputEventIfChanged(item.orderBy, this.direction); + }, + isActiveSortItem(item) { + return this.orderBy === item.orderBy; + }, + emitInputEventIfChanged(orderBy, direction) { + const newSort = SORT_MAP[orderBy][direction]; + if (newSort !== this.value) { + this.$emit('input', SORT_MAP[orderBy][direction]); + } + }, + }, +}; +</script> + +<template> + <gl-sorting + :text="sortText" + :is-ascending="isDirectionAscending" + data-testid="releases-sort" + @sortDirectionChange="onDirectionChange" + > + <gl-sorting-item + v-for="item of sortOptions" + :key="item.orderBy" + :active="isActiveSortItem(item)" + @click="onSortItemClick(item)" + > + {{ item.label }} + </gl-sorting-item> + </gl-sorting> +</template> diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js index f9653e0befa..4f862741e11 100644 --- a/app/assets/javascripts/releases/constants.js +++ b/app/assets/javascripts/releases/constants.js @@ -15,7 +15,7 @@ export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; export const PAGE_SIZE = 10; -export const ASCENDING_ODER = 'asc'; +export const ASCENDING_ORDER = 'asc'; export const DESCENDING_ORDER = 'desc'; export const RELEASED_AT = 'released_at'; export const CREATED_AT = 'created_at'; @@ -30,3 +30,22 @@ export const SORT_OPTIONS = [ label: __('Created date'), }, ]; + +export const RELEASED_AT_ASC = 'RELEASED_AT_ASC'; +export const RELEASED_AT_DESC = 'RELEASED_AT_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CREATED_DESC = 'CREATED_DESC'; +export const ALL_SORTS = [RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC]; + +export const SORT_MAP = { + [RELEASED_AT]: { + [ASCENDING_ORDER]: RELEASED_AT_ASC, + [DESCENDING_ORDER]: RELEASED_AT_DESC, + }, + [CREATED_AT]: { + [ASCENDING_ORDER]: CREATED_ASC, + [DESCENDING_ORDER]: CREATED_DESC, + }, +}; + +export const DEFAULT_SORT = RELEASED_AT_DESC; diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index bb21ec7c43f..59f6ebfc928 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -1,14 +1,44 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; +import createDefaultClient from '~/lib/graphql'; import ReleaseIndexApp from './components/app_index.vue'; +import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue'; import createStore from './stores'; import createIndexModule from './stores/modules/index'; -Vue.use(Vuex); - export default () => { const el = document.getElementById('js-releases-page'); + if (window.gon?.features?.releasesIndexApolloClient) { + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + // This page attempts to decrease the perceived loading time + // by sending two requests: one request for the first item only (which + // completes relatively quickly), and one for all the items (which is slower). + // By default, Apollo Client batches these requests together, which defeats + // the purpose of making separate requests. So we explicitly + // disable batching on this page. + batchMax: 1, + assumeImmutableResults: true, + }, + ), + }); + + return new Vue({ + el, + apolloProvider, + provide: { ...el.dataset }, + render: (h) => h(ReleaseIndexApollopClientApp), + }); + } + + Vue.use(Vuex); + return new Vue({ el, store: createStore({ 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 b312c2a7506..5955ec3352e 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; @@ -39,7 +39,9 @@ export const fetchRelease = async ({ commit, state }) => { commit(types.RECEIVE_RELEASE_SUCCESS, release); } catch (error) { commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details.')); + createFlash({ + message: s__('Release|Something went wrong while getting the release details.'), + }); } }; @@ -124,7 +126,9 @@ export const createRelease = async ({ commit, dispatch, state, getters }) => { dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl); } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while creating a new release.')); + createFlash({ + message: s__('Release|Something went wrong while creating a new release.'), + }); } }; @@ -214,6 +218,8 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => { dispatch('receiveSaveReleaseSuccess', state.release._links.self); } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while saving the release details.')); + createFlash({ + message: s__('Release|Something went wrong while saving the release details.'), + }); } }; diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js index 00be25f089b..d3bb11cab30 100644 --- a/app/assets/javascripts/releases/stores/modules/index/actions.js +++ b/app/assets/javascripts/releases/stores/modules/index/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { PAGE_SIZE } from '~/releases/constants'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; @@ -57,7 +57,9 @@ export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); - createFlash(__('An error occurred while fetching the releases. Please try again.')); + createFlash({ + message: __('An error occurred while fetching the releases. Please try again.'), + }); }; export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue index dabfb623f43..736c8668a34 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -61,7 +61,7 @@ export default { <span :class="severityClass" class="gl-mr-5" data-testid="codequality-severity-icon"> <gl-icon v-tooltip="severityLabel" :name="severityIcon" :size="12" /> </span> - <div class="gl-flex-fill-1"> + <div class="gl-flex-grow-1"> <div> <strong v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</strong> {{ issueName }} diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index 3287ba691bf..e568950380e 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -12,11 +12,24 @@ export default { ReportSection, }, props: { + headPath: { + type: String, + required: true, + }, + headBlobPath: { + type: String, + required: true, + }, basePath: { type: String, required: false, default: null, }, + baseBlobPath: { + type: String, + required: false, + default: null, + }, codequalityReportsPath: { type: String, required: false, @@ -40,6 +53,9 @@ export default { created() { this.setPaths({ basePath: this.basePath, + headPath: this.headPath, + baseBlobPath: this.baseBlobPath, + headBlobPath: this.headBlobPath, reportsPath: this.codequalityReportsPath, helpPath: this.codequalityHelpPath, }); diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js index 8edeb6cc976..095e6637966 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutations.js +++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js @@ -3,6 +3,9 @@ import * as types from './mutation_types'; export default { [types.SET_PATHS](state, paths) { state.basePath = paths.basePath; + state.headPath = paths.headPath; + state.baseBlobPath = paths.baseBlobPath; + state.headBlobPath = paths.headBlobPath; state.reportsPath = paths.reportsPath; state.helpPath = paths.helpPath; }, diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index a9701c8f8aa..7fbf331d585 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -8,11 +8,13 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import BlobHeaderEdit from './blob_header_edit.vue'; +import BlobReplace from './blob_replace.vue'; export default { components: { BlobHeader, BlobHeaderEdit, + BlobReplace, BlobContent, GlLoadingIcon, }, @@ -87,6 +89,9 @@ export default { }; }, computed: { + isLoggedIn() { + return Boolean(gon.current_user_id); + }, isLoading() { return this.$apollo.queries.project.loading; }, @@ -126,7 +131,17 @@ export default { @viewer-changed="switchViewer" > <template #actions> - <blob-header-edit :edit-path="blobInfo.editBlobPath" /> + <blob-header-edit + :edit-path="blobInfo.editBlobPath" + :web-ide-path="blobInfo.ideEditPath" + /> + <blob-replace + v-if="isLoggedIn" + :path="path" + :name="blobInfo.name" + :replace-path="blobInfo.replacePath" + :can-push-code="blobInfo.canModifyBlob" + /> </template> </blob-header> <blob-content diff --git a/app/assets/javascripts/repository/components/blob_header_edit.vue b/app/assets/javascripts/repository/components/blob_header_edit.vue index f3649895736..3d97ebe89e4 100644 --- a/app/assets/javascripts/repository/components/blob_header_edit.vue +++ b/app/assets/javascripts/repository/components/blob_header_edit.vue @@ -1,25 +1,47 @@ <script> import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; +import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { i18n: { edit: __('Edit'), + webIde: __('Web IDE'), }, components: { GlButton, + WebIdeLink, }, + mixins: [glFeatureFlagsMixin()], props: { editPath: { type: String, required: true, }, + webIdePath: { + type: String, + required: true, + }, }, }; </script> <template> - <gl-button category="primary" variant="confirm" class="gl-mr-3" :href="editPath"> - {{ $options.i18n.edit }} - </gl-button> + <web-ide-link + v-if="glFeatures.consolidatedEditButton" + class="gl-mr-3" + :edit-url="editPath" + :web-ide-url="webIdePath" + :is-blob="true" + /> + <div v-else> + <gl-button class="gl-mr-2" category="primary" variant="confirm" :href="editPath"> + {{ $options.i18n.edit }} + </gl-button> + + <gl-button class="gl-mr-3" category="primary" variant="confirm" :href="webIdePath"> + {{ $options.i18n.webIde }} + </gl-button> + </div> </template> diff --git a/app/assets/javascripts/repository/components/blob_replace.vue b/app/assets/javascripts/repository/components/blob_replace.vue new file mode 100644 index 00000000000..91d7811eb6d --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_replace.vue @@ -0,0 +1,75 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { sprintf, __ } from '~/locale'; +import getRefMixin from '../mixins/get_ref'; +import UploadBlobModal from './upload_blob_modal.vue'; + +export default { + i18n: { + replace: __('Replace'), + replacePrimaryBtnText: __('Replace file'), + }, + components: { + GlButton, + UploadBlobModal, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [getRefMixin], + inject: { + targetBranch: { + default: '', + }, + originalBranch: { + default: '', + }, + }, + props: { + name: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + replacePath: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: true, + }, + }, + computed: { + replaceModalId() { + return uniqueId('replace-modal'); + }, + title() { + return sprintf(__('Replace %{name}'), { name: this.name }); + }, + }, +}; +</script> + +<template> + <div class="gl-mr-3"> + <gl-button v-gl-modal="replaceModalId"> + {{ $options.i18n.replace }} + </gl-button> + <upload-blob-modal + :modal-id="replaceModalId" + :modal-title="title" + :commit-message="title" + :target-branch="targetBranch || ref" + :original-branch="originalBranch || ref" + :can-push-code="canPushCode" + :path="path" + :replace-path="replacePath" + :primary-btn-text="$options.i18n.replacePrimaryBtnText" + /> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 22dffb7d2db..ca5711de49c 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -51,6 +51,9 @@ export default { }; }, computed: { + totalEntries() { + return Object.values(this.entries).flat().length; + }, tableCaption() { if (this.isLoading) { return sprintf( @@ -111,6 +114,7 @@ export default { :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" :loading-path="loadingPath" + :total-entries="totalEntries" /> </template> <template v-if="isLoading"> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 8ea5fce92fa..62f863db871 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -7,13 +7,17 @@ import { GlTooltipDirective, GlLoadingIcon, GlIcon, + GlHoverLoadDirective, } from '@gitlab/ui'; import { escapeRegExp } from 'lodash'; +import filesQuery from 'shared_queries/repository/files.query.graphql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; +import { TREE_PAGE_SIZE } 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 getRefMixin from '../../mixins/get_ref'; +import blobInfoQuery from '../../queries/blob_info.query.graphql'; import commitQuery from '../../queries/commit.query.graphql'; export default { @@ -28,6 +32,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + GlHoverLoad: GlHoverLoadDirective, }, apollo: { commit: { @@ -38,12 +43,17 @@ export default { type: this.type, path: this.currentPath, projectPath: this.projectPath, + maxOffset: this.totalEntries, }; }, }, }, mixins: [getRefMixin, glFeatureFlagMixin()], props: { + totalEntries: { + type: Number, + required: true, + }, id: { type: String, required: true, @@ -139,6 +149,33 @@ export default { return this.commit && this.commit.lockLabel; }, }, + methods: { + handlePreload() { + return this.isFolder ? this.loadFolder() : this.loadBlob(); + }, + loadFolder() { + this.apolloQuery(filesQuery, { + projectPath: this.projectPath, + ref: this.ref, + path: this.path, + nextPageCursor: '', + pageSize: TREE_PAGE_SIZE, + }); + }, + loadBlob() { + if (!this.refactorBlobViewerEnabled) { + return; + } + + this.apolloQuery(blobInfoQuery, { + projectPath: this.projectPath, + filePath: this.path, + }); + }, + apolloQuery(query, variables) { + this.$apollo.query({ query, variables }); + }, + }, }; </script> @@ -148,6 +185,7 @@ export default { <component :is="linkComponent" ref="link" + v-gl-hover-load="handlePreload" :to="routerLinkTo" :href="url" :class="{ diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 336237abd8a..794a8a85cc5 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,17 +1,14 @@ <script> import filesQuery from 'shared_queries/repository/files.query.graphql'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '../../locale'; +import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT } from '../constants'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import { readmeFile } from '../utils/readme'; import FilePreview from './preview/index.vue'; import FileTable from './table/index.vue'; -const LIMIT = 1000; -const PAGE_SIZE = 100; -export const INITIAL_FETCH_COUNT = LIMIT / PAGE_SIZE; - export default { components: { FileTable, @@ -47,7 +44,7 @@ export default { isLoadingFiles: false, isOverLimit: false, clickedShowMore: false, - pageSize: PAGE_SIZE, + pageSize: TREE_PAGE_SIZE, fetchCounter: 0, }; }, @@ -56,7 +53,7 @@ export default { return readmeFile(this.entries.blobs); }, hasShowMore() { - return !this.clickedShowMore && this.fetchCounter === INITIAL_FETCH_COUNT; + return !this.clickedShowMore && this.fetchCounter === TREE_INITIAL_FETCH_COUNT; }, }, @@ -107,14 +104,16 @@ export default { if (pageInfo?.hasNextPage) { this.nextPageCursor = pageInfo.endCursor; this.fetchCounter += 1; - if (this.fetchCounter < INITIAL_FETCH_COUNT || this.clickedShowMore) { + if (this.fetchCounter < TREE_INITIAL_FETCH_COUNT || this.clickedShowMore) { this.fetchFiles(); this.clickedShowMore = false; } } }) .catch((error) => { - createFlash(__('An error occurred while fetching folder content.')); + createFlash({ + message: __('An error occurred while fetching folder content.'), + }); throw error; }); }, diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index aa087d4c631..7f065dbdf6d 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -43,7 +43,6 @@ export default { GlAlert, }, i18n: { - MODAL_TITLE, COMMIT_LABEL, TARGET_BRANCH_LABEL, TOGGLE_CREATE_MR_LABEL, @@ -51,6 +50,16 @@ export default { NEW_BRANCH_IN_FORK, }, props: { + modalTitle: { + type: String, + default: MODAL_TITLE, + required: false, + }, + primaryBtnText: { + type: String, + default: PRIMARY_OPTIONS_TEXT, + required: false, + }, modalId: { type: String, required: true, @@ -75,6 +84,11 @@ export default { type: String, required: true, }, + replacePath: { + type: String, + default: null, + required: false, + }, }, data() { return { @@ -90,7 +104,7 @@ export default { computed: { primaryOptions() { return { - text: PRIMARY_OPTIONS_TEXT, + text: this.primaryBtnText, attributes: [ { variant: 'confirm', @@ -136,6 +150,45 @@ export default { this.file = null; this.filePreviewURL = null; }, + submitForm() { + return this.replacePath ? this.replaceFile() : this.uploadFile(); + }, + submitRequest(method, url) { + return axios({ + method, + url, + data: this.formData(), + headers: { + ...ContentTypeMultipartFormData, + }, + }) + .then((response) => { + if (!this.replacePath) { + trackFileUploadEvent('click_upload_modal_form_submit'); + } + visitUrl(response.data.filePath); + }) + .catch(() => { + this.loading = false; + createFlash(ERROR_MESSAGE); + }); + }, + formData() { + const formData = new FormData(); + formData.append('branch_name', this.target); + formData.append('create_merge_request', this.createNewMr); + formData.append('commit_message', this.commit); + formData.append('file', this.file); + + return formData; + }, + replaceFile() { + this.loading = true; + + // The PUT path can be geneated from $route (similar to "uploadFile") once router is connected + // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/332736 + return this.submitRequest('put', this.replacePath); + }, uploadFile() { this.loading = true; @@ -146,26 +199,7 @@ export default { } = this; const uploadPath = joinPaths(this.path, path); - const formData = new FormData(); - formData.append('branch_name', this.target); - formData.append('create_merge_request', this.createNewMr); - formData.append('commit_message', this.commit); - formData.append('file', this.file); - - return axios - .post(uploadPath, formData, { - headers: { - ...ContentTypeMultipartFormData, - }, - }) - .then((response) => { - trackFileUploadEvent('click_upload_modal_form_submit'); - visitUrl(response.data.filePath); - }) - .catch(() => { - this.loading = false; - createFlash(ERROR_MESSAGE); - }); + return this.submitRequest('post', uploadPath); }, }, validFileMimetypes: [], @@ -175,10 +209,10 @@ export default { <gl-form> <gl-modal :modal-id="modalId" - :title="$options.i18n.MODAL_TITLE" + :title="modalTitle" :action-primary="primaryOptions" :action-cancel="cancelOptions" - @primary.prevent="uploadFile" + @primary.prevent="submitForm" > <upload-dropzone class="gl-h-200! gl-mb-4" diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js new file mode 100644 index 00000000000..62d5d3db445 --- /dev/null +++ b/app/assets/javascripts/repository/constants.js @@ -0,0 +1,4 @@ +const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page + +export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request +export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 4a4b9d115b7..4892e54ebef 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -17,15 +17,21 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ const defaultClient = createDefaultClient( { Query: { - commit(_, { path, fileName, type }) { + commit(_, { path, fileName, type, maxOffset }) { return new Promise((resolve) => { - fetchLogsTree(defaultClient, path, '0', { - resolve, - entry: { - name: fileName, - type, + fetchLogsTree( + defaultClient, + path, + '0', + { + resolve, + entry: { + name: fileName, + type, + }, }, - }); + maxOffset, + ); }); }, readme(_, { url }) { diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 501ae7e9f2f..60a1a0443f7 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -141,6 +141,9 @@ export default function setupVueRepositoryList() { href: `${historyLink}/${ this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' }`, + // Ideally passing this class to `props` should work + // But it doesn't work here. :( + class: 'btn btn-default btn-md gl-button ml-sm-0', }, }, [__('History')], diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 9001bcd8fc3..ac02392d60f 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -7,6 +7,13 @@ import refQuery from './queries/ref.query.graphql'; const fetchpromises = {}; const resolvers = {}; +let maxOffset; +let nextOffset; +let currentPath; + +function setNextOffset(offset) { + nextOffset = offset || null; +} export function resolveCommit(commits, path, { resolve, entry }) { const commit = commits.find( @@ -18,7 +25,25 @@ export function resolveCommit(commits, path, { resolve, entry }) { } } -export function fetchLogsTree(client, path, offset, resolver = null) { +export function fetchLogsTree(client, path, offset, resolver = null, _maxOffset = null) { + if (_maxOffset) { + maxOffset = _maxOffset; + } + + if (!currentPath || currentPath !== path) { + // ensures the nextOffset is reset if the user changed directories + setNextOffset(null); + } + + currentPath = path; + + const offsetNumber = Number(offset); + + if (!nextOffset && offsetNumber > maxOffset) { + setNextOffset(offsetNumber - 25); // ensures commit data is fetched for newly added rows that need data from the previous request (requests are made in batches of 25). + return Promise.resolve(); + } + if (resolver) { if (!resolvers[path]) { resolvers[path] = [resolver]; @@ -38,7 +63,7 @@ export function fetchLogsTree(client, path, offset, resolver = null) { path.replace(/^\//, ''), )}`, { - params: { format: 'json', offset }, + params: { format: 'json', offset: nextOffset || offset }, }, ) .then(({ data: newData, headers }) => { @@ -57,9 +82,12 @@ export function fetchLogsTree(client, path, offset, resolver = null) { delete fetchpromises[path]; if (headerLogsOffset) { + setNextOffset(null); fetchLogsTree(client, path, headerLogsOffset); } else { delete resolvers[path]; + maxOffset = null; + setNextOffset(null); } }); diff --git a/app/assets/javascripts/repository/mixins/get_ref.js b/app/assets/javascripts/repository/mixins/get_ref.js index 1f1880a48c7..3247938f999 100644 --- a/app/assets/javascripts/repository/mixins/get_ref.js +++ b/app/assets/javascripts/repository/mixins/get_ref.js @@ -6,7 +6,7 @@ export default { query: refQuery, manual: true, result({ data, loading }) { - if (!loading) { + if (data && !loading) { this.ref = data.ref; this.escapedRef = data.escapedRef; } diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 07c076af54b..bfd9447d260 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -1,6 +1,5 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { project(fullPath: $projectPath) { - id repository { blobs(paths: [$filePath]) { nodes { @@ -12,9 +11,11 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { fileType path editBlobPath + ideEditPath storedExternally rawPath replacePath + canModifyBlob simpleViewer { fileType tooLarge diff --git a/app/assets/javascripts/repository/queries/commit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql index e4aeaaff8fe..7ae4a3b984a 100644 --- a/app/assets/javascripts/repository/queries/commit.query.graphql +++ b/app/assets/javascripts/repository/queries/commit.query.graphql @@ -1,7 +1,7 @@ #import "ee_else_ce/repository/queries/commit.fragment.graphql" -query getCommit($fileName: String!, $type: String!, $path: String!) { - commit(path: $path, fileName: $fileName, type: $type) @client { +query getCommit($fileName: String!, $type: String!, $path: String!, $maxOffset: Number!) { + commit(path: $path, fileName: $fileName, type: $type, maxOffset: $maxOffset) @client { ...TreeEntryCommit } } diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index ea8f87001f0..3e9e3e6f265 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -1,6 +1,7 @@ export * from './api/groups_api'; export * from './api/projects_api'; export * from './api/user_api'; +export * from './api/markdown_api'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. See https://stackoverflow.com/a/53307822/1063392. diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue new file mode 100644 index 00000000000..7f9f796bdee --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -0,0 +1,171 @@ +<script> +import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; +import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; + +const i18n = { + I18N_EDIT: __('Edit'), + I18N_PAUSE: __('Pause'), + I18N_RESUME: __('Resume'), + I18N_REMOVE: __('Remove'), + I18N_REMOVE_CONFIRMATION: s__('Runners|Are you sure you want to delete this runner?'), +}; + +export default { + components: { + GlButton, + GlButtonGroup, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + updating: false, + deleting: false, + }; + }, + computed: { + runnerNumericalId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerUrl() { + // TODO implement using webUrl from the API + return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`; + }, + isActive() { + return this.runner.active; + }, + toggleActiveIcon() { + return this.isActive ? 'pause' : 'play'; + }, + toggleActiveTitle() { + if (this.updating) { + // Prevent a "sticky" tooltip: If this button is disabled, + // mouseout listeners don't run leaving the tooltip stuck + return ''; + } + return this.isActive ? i18n.I18N_PAUSE : i18n.I18N_RESUME; + }, + deleteTitle() { + // Prevent a "sticky" tooltip: If element gets removed, + // mouseout listeners don't run and leaving the tooltip stuck + return this.deleting ? '' : i18n.I18N_REMOVE; + }, + }, + methods: { + async onToggleActive() { + this.updating = true; + // TODO In HAML iteration we had a confirmation modal via: + // data-confirm="_('Are you sure?')" + // this may not have to ported, this is an easily reversible operation + + try { + const toggledActive = !this.runner.active; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerUpdateMutation, + variables: { + input: { + id: this.runner.id, + active: toggledActive, + }, + }, + }); + + if (errors && errors.length) { + this.onError(new Error(errors[0])); + } + } catch (e) { + this.onError(e); + } finally { + this.updating = false; + } + }, + + async onDelete() { + // TODO Replace confirmation with gl-modal + // eslint-disable-next-line no-alert + if (!window.confirm(i18n.I18N_REMOVE_CONFIRMATION)) { + return; + } + + this.deleting = true; + try { + const { + data: { + runnerDelete: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deleteRunnerMutation, + variables: { + input: { + id: this.runner.id, + }, + }, + awaitRefetchQueries: true, + refetchQueries: ['getRunners'], + }); + if (errors && errors.length) { + this.onError(new Error(errors[0])); + } + } catch (e) { + this.onError(e); + } finally { + this.deleting = false; + } + }, + + onError(error) { + // TODO Render errors when "delete" action is done + // `active` toggle would not fail due to user input. + throw error; + }, + }, + i18n, +}; +</script> + +<template> + <gl-button-group> + <gl-button + v-gl-tooltip.hover.viewport + :title="$options.i18n.I18N_EDIT" + :aria-label="$options.i18n.I18N_EDIT" + icon="pencil" + :href="runnerUrl" + data-testid="edit-runner" + /> + <gl-button + v-gl-tooltip.hover.viewport + :title="toggleActiveTitle" + :aria-label="toggleActiveTitle" + :icon="toggleActiveIcon" + :loading="updating" + data-testid="toggle-active-runner" + @click="onToggleActive" + /> + <gl-button + v-gl-tooltip.hover.viewport + :title="deleteTitle" + :aria-label="deleteTitle" + icon="close" + :loading="deleting" + variant="danger" + data-testid="delete-runner" + @click="onDelete" + /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue b/app/assets/javascripts/runner/components/cells/runner_name_cell.vue new file mode 100644 index 00000000000..797a3359147 --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_name_cell.vue @@ -0,0 +1,44 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; + +export default { + components: { + GlLink, + TooltipOnTruncate, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + runnerNumericalId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerUrl() { + // TODO implement using webUrl from the API + return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`; + }, + description() { + return this.runner.description; + }, + shortSha() { + return this.runner.shortSha; + }, + }, +}; +</script> + +<template> + <div> + <gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link> + <tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child"> + <div class="gl-text-truncate"> + {{ description }} + </div> + </tooltip-on-truncate> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue new file mode 100644 index 00000000000..b3ebdfd82e3 --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue @@ -0,0 +1,42 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import RunnerTypeBadge from '../runner_type_badge.vue'; + +export default { + components: { + GlBadge, + RunnerTypeBadge, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + runnerType() { + return this.runner.runnerType; + }, + locked() { + return this.runner.locked; + }, + paused() { + return !this.runner.active; + }, + }, +}; +</script> + +<template> + <div> + <runner-type-badge :type="runnerType" size="sm" /> + + <gl-badge v-if="locked" variant="warning" size="sm"> + {{ __('locked') }} + </gl-badge> + + <gl-badge v-if="paused" variant="danger" size="sm"> + {{ __('paused') }} + </gl-badge> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue new file mode 100644 index 00000000000..bec33ce2f44 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -0,0 +1,145 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; +import { __, s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { + STATUS_ACTIVE, + STATUS_PAUSED, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_NOT_CONNECTED, + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + CREATED_DESC, + CREATED_ASC, + CONTACTED_DESC, + CONTACTED_ASC, + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, +} from '../constants'; + +const searchTokens = [ + { + icon: 'status', + title: __('Status'), + type: PARAM_KEY_STATUS, + token: GlFilteredSearchToken, + // TODO Get more than one value when GraphQL API supports OR for "status" + unique: true, + options: [ + { value: STATUS_ACTIVE, title: s__('Runners|Active') }, + { value: STATUS_PAUSED, title: s__('Runners|Paused') }, + { value: STATUS_ONLINE, title: s__('Runners|Online') }, + { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, + + // Added extra quotes in this title to avoid splitting this value: + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` }, + ], + // TODO In principle we could support more complex search rules, + // this can be added to a separate issue. + operators: OPERATOR_IS_ONLY, + }, + + { + icon: 'file-tree', + title: __('Type'), + type: PARAM_KEY_RUNNER_TYPE, + token: GlFilteredSearchToken, + // TODO Get more than one value when GraphQL API supports OR for "status" + unique: true, + options: [ + { value: INSTANCE_TYPE, title: s__('Runners|shared') }, + { value: GROUP_TYPE, title: s__('Runners|group') }, + { value: PROJECT_TYPE, title: s__('Runners|specific') }, + ], + // TODO We should support more complex search rules, + // search for multiple states (OR) or have NOT operators + operators: OPERATOR_IS_ONLY, + }, + + // TODO Support tags +]; + +const sortOptions = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: CREATED_DESC, + ascending: CREATED_ASC, + }, + }, + { + id: 2, + title: __('Last contact'), + sortDirection: { + descending: CONTACTED_DESC, + ascending: CONTACTED_ASC, + }, + }, +]; + +export default { + components: { + FilteredSearch, + }, + props: { + value: { + type: Object, + required: true, + validator(val) { + return Array.isArray(val?.filters) && typeof val?.sort === 'string'; + }, + }, + }, + data() { + // filtered_search_bar_root.vue may mutate the inital + // filters. Use `cloneDeep` to prevent those mutations + // from affecting this component + const { filters, sort } = cloneDeep(this.value); + return { + initialFilterValue: filters, + initialSortBy: sort, + }; + }, + methods: { + onFilter(filters) { + const { sort } = this.value; + + this.$emit('input', { + filters, + sort, + pagination: { page: 1 }, + }); + }, + onSort(sort) { + const { filters } = this.value; + + this.$emit('input', { + filters, + sort, + pagination: { page: 1 }, + }); + }, + }, + sortOptions, + searchTokens, +}; +</script> +<template> + <filtered-search + v-bind="$attrs" + recent-searches-storage-key="runners-search" + :sort-options="$options.sortOptions" + :initial-filter-value="initialFilterValue" + :initial-sort-by="initialSortBy" + :tokens="$options.searchTokens" + :search-input-placeholder="__('Search or filter results...')" + @onFilter="onFilter" + @onSort="onSort" + /> +</template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue new file mode 100644 index 00000000000..41adbbb55f6 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -0,0 +1,142 @@ +<script> +import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { formatNumber, sprintf, __, s__ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerActionsCell from './cells/runner_actions_cell.vue'; +import RunnerNameCell from './cells/runner_name_cell.vue'; +import RunnerTypeCell from './cells/runner_type_cell.vue'; +import RunnerTags from './runner_tags.vue'; + +const tableField = ({ key, label = '', width = 10 }) => { + return { + key, + label, + thClass: [ + `gl-w-${width}p`, + 'gl-bg-transparent!', + 'gl-border-b-solid!', + 'gl-border-b-gray-100!', + 'gl-py-5!', + 'gl-px-0!', + 'gl-border-b-1!', + ], + tdClass: ['gl-py-5!', 'gl-px-1!'], + tdAttr: { + 'data-testid': `td-${key}`, + }, + }; +}; + +export default { + components: { + GlTable, + GlSkeletonLoader, + TimeAgo, + RunnerActionsCell, + RunnerNameCell, + RunnerTags, + RunnerTypeCell, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + runners: { + type: Array, + required: true, + }, + activeRunnersCount: { + type: Number, + required: true, + }, + }, + computed: { + activeRunnersMessage() { + return sprintf(__('Runners currently online: %{active_runners_count}'), { + active_runners_count: formatNumber(this.activeRunnersCount), + }); + }, + }, + methods: { + runnerTrAttr(runner) { + if (runner) { + return { + 'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`, + }; + } + return {}; + }, + }, + fields: [ + tableField({ key: 'type', label: __('Type/State') }), + tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }), + tableField({ key: 'version', label: __('Version') }), + tableField({ key: 'ipAddress', label: __('IP Address') }), + tableField({ key: 'projectCount', label: __('Projects'), width: 5 }), + tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }), + tableField({ key: 'tagList', label: __('Tags') }), + tableField({ key: 'contactedAt', label: __('Last contact') }), + tableField({ key: 'actions', label: '' }), + ], +}; +</script> +<template> + <div> + <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div> + <gl-table + :busy="loading" + :items="runners" + :fields="$options.fields" + :tbody-tr-attr="runnerTrAttr" + stacked="md" + fixed + > + <template v-if="!runners.length" #table-busy> + <gl-skeleton-loader v-for="i in 4" :key="i" /> + </template> + + <template #cell(type)="{ item }"> + <runner-type-cell :runner="item" /> + </template> + + <template #cell(name)="{ item }"> + <runner-name-cell :runner="item" /> + </template> + + <template #cell(version)="{ item: { version } }"> + {{ version }} + </template> + + <template #cell(ipAddress)="{ item: { ipAddress } }"> + {{ ipAddress }} + </template> + + <template #cell(projectCount)> + <!-- TODO add projects count --> + </template> + + <template #cell(jobCount)> + <!-- TODO add jobs count --> + </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> + </template> + + <template #cell(actions)="{ item }"> + <runner-actions-cell :runner="item" /> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue new file mode 100644 index 00000000000..4755977b051 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue @@ -0,0 +1,76 @@ +<script> +import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; + +export default { + components: { + GlLink, + GlSprintf, + ClipboardButton, + RunnerInstructions, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + runnerInstallHelpPage: { + default: null, + }, + }, + props: { + registrationToken: { + type: String, + required: true, + }, + typeName: { + type: String, + required: false, + default: __('shared'), + }, + }, + computed: { + rootUrl() { + return gon.gitlab_url || ''; + }, + }, +}; +</script> + +<template> + <div class="bs-callout"> + <h5 data-testid="runner-help-title"> + <gl-sprintf :message="__('Set up a %{type} runner manually')"> + <template #type> + {{ typeName }} + </template> + </gl-sprintf> + </h5> + + <ol> + <li> + <gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank"> + {{ __("Install GitLab Runner and ensure it's running.") }} + </gl-link> + </li> + <li> + {{ __('Register the runner with this URL:') }} + <br /> + + <code data-testid="coordinator-url">{{ rootUrl }}</code> + <clipboard-button :title="__('Copy URL')" :text="rootUrl" /> + </li> + <li> + {{ __('And this registration token:') }} + <br /> + + <code data-testid="registration-token">{{ registrationToken }}</code> + <clipboard-button :title="__('Copy token')" :text="registrationToken" /> + </li> + </ol> + + <!-- TODO Implement reset token functionality --> + <runner-instructions /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue new file mode 100644 index 00000000000..8645b90f5cd --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_pagination.vue @@ -0,0 +1,57 @@ +<script> +import { GlPagination } from '@gitlab/ui'; + +export default { + components: { + GlPagination, + }, + props: { + value: { + required: false, + type: Object, + default: () => ({ + page: 1, + }), + }, + pageInfo: { + required: false, + type: Object, + default: () => ({}), + }, + }, + computed: { + prevPage() { + return this.pageInfo?.hasPreviousPage ? this.value?.page - 1 : null; + }, + nextPage() { + return this.pageInfo?.hasNextPage ? this.value?.page + 1 : null; + }, + }, + methods: { + handlePageChange(page) { + if (page > this.value.page) { + this.$emit('input', { + page, + after: this.pageInfo.endCursor, + }); + } else { + this.$emit('input', { + page, + before: this.pageInfo.startCursor, + }); + } + }, + }, +}; +</script> + +<template> + <gl-pagination + :value="value.page" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="handlePageChange" + /> +</template> diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue new file mode 100644 index 00000000000..4ba07e00c96 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_tags.vue @@ -0,0 +1,33 @@ +<script> +import { GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + }, + props: { + tagList: { + type: Array, + required: false, + default: () => [], + }, + size: { + type: String, + required: false, + default: 'md', + }, + variant: { + type: String, + required: false, + default: 'info', + }, + }, +}; +</script> +<template> + <div> + <gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant"> + {{ tag }} + </gl-badge> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_type_alert.vue b/app/assets/javascripts/runner/components/runner_type_alert.vue new file mode 100644 index 00000000000..72ce582e02c --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_type_alert.vue @@ -0,0 +1,66 @@ +<script> +import { GlAlert, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; + +const ALERT_DATA = { + [INSTANCE_TYPE]: { + title: s__( + 'Runners|This runner is available to all groups and projects in your GitLab instance.', + ), + message: s__( + 'Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.', + ), + variant: 'success', + anchor: 'shared-runners', + }, + [GROUP_TYPE]: { + title: s__('Runners|This runner is available to all projects and subgroups in a group.'), + message: s__( + 'Runners|Use Group runners when you want all projects in a group to have access to a set of runners.', + ), + variant: 'success', + anchor: 'group-runners', + }, + [PROJECT_TYPE]: { + title: s__('Runners|This runner is associated with specific projects.'), + message: s__( + 'Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.', + ), + variant: 'info', + anchor: 'specific-runners', + }, +}; + +export default { + components: { + GlAlert, + GlLink, + }, + props: { + type: { + type: String, + required: false, + default: null, + validator(type) { + return Boolean(ALERT_DATA[type]); + }, + }, + }, + computed: { + alert() { + return ALERT_DATA[this.type]; + }, + helpHref() { + return helpPagePath('ci/runners/runners_scope', { anchor: this.alert.anchor }); + }, + }, +}; +</script> +<template> + <gl-alert v-if="alert" :variant="alert.variant" :title="alert.title" :dismissible="false"> + {{ alert.message }} + <gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue index dd4fff3a77a..c2f43daa899 100644 --- a/app/assets/javascripts/runner/components/runner_type_badge.vue +++ b/app/assets/javascripts/runner/components/runner_type_badge.vue @@ -3,7 +3,7 @@ import { GlBadge } from '@gitlab/ui'; import { s__ } from '~/locale'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; -const badge = { +const BADGE_DATA = { [INSTANCE_TYPE]: { variant: 'success', text: s__('Runners|shared'), @@ -25,21 +25,22 @@ export default { props: { type: { type: String, - required: true, + required: false, + default: null, + validator(type) { + return Boolean(BADGE_DATA[type]); + }, }, }, computed: { - variant() { - return badge[this.type]?.variant; - }, - text() { - return badge[this.type]?.text; + badge() { + return BADGE_DATA[this.type]; }, }, }; </script> <template> - <gl-badge v-if="text" :variant="variant" v-bind="$attrs"> - {{ text }} + <gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs"> + {{ badge.text }} </gl-badge> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue new file mode 100644 index 00000000000..927deb290a4 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_type_help.vue @@ -0,0 +1,60 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import RunnerTypeBadge from './runner_type_badge.vue'; + +export default { + components: { + GlBadge, + RunnerTypeBadge, + }, + runnerTypes: { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + }, +}; +</script> + +<template> + <div class="bs-callout"> + <p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p> + <p> + {{ + __( + 'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.', + ) + }} + </p> + + <div> + <span> {{ __('Runners can be:') }}</span> + <ul> + <li> + <runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" /> + - {{ __('Runs jobs from all unassigned projects.') }} + </li> + <li> + <runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" /> + - {{ __('Runs jobs from all unassigned projects in its group.') }} + </li> + <li> + <runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" /> + - {{ __('Runs jobs from assigned projects.') }} + </li> + <li> + <gl-badge variant="warning" size="sm"> + {{ __('locked') }} + </gl-badge> + - {{ __('Cannot be assigned to other projects.') }} + </li> + <li> + <gl-badge variant="danger" size="sm"> + {{ __('paused') }} + </gl-badge> + - {{ __('Not available to run jobs.') }} + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue new file mode 100644 index 00000000000..0c1b83b6830 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -0,0 +1,227 @@ +<script> +import { + GlButton, + GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInputGroup, + GlTooltipDirective, +} from '@gitlab/ui'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { __ } from '~/locale'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; +import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql'; + +const runnerToModel = (runner) => { + const { + id, + description, + maximumTimeout, + accessLevel, + active, + locked, + runUntagged, + tagList = [], + } = runner || {}; + + return { + id, + description, + maximumTimeout, + accessLevel, + active, + locked, + runUntagged, + tagList: tagList.join(', '), + }; +}; + +export default { + components: { + GlButton, + GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInputGroup, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { + saving: false, + model: runnerToModel(this.runner), + }; + }, + computed: { + canBeLockedToProject() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + readonlyIpAddress() { + return this.runner?.ipAddress; + }, + updateMutationInput() { + const { maximumTimeout, tagList } = this.model; + + return { + ...this.model, + maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null, + tagList: tagList + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => Boolean(tag)), + }; + }, + }, + watch: { + runner(newVal, oldVal) { + if (oldVal === null) { + this.model = runnerToModel(newVal); + } + }, + }, + methods: { + async onSubmit() { + this.saving = true; + + try { + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerUpdateMutation, + variables: { + input: this.updateMutationInput, + }, + }); + + if (errors?.length) { + this.onError(new Error(errors[0])); + return; + } + + this.onSuccess(); + } catch (e) { + this.onError(e); + } finally { + this.saving = false; + } + }, + onError(error) { + const { message } = error; + createFlash({ message }); + }, + onSuccess() { + createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS }); + this.model = runnerToModel(this.runner); + }, + }, + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> +<template> + <gl-form @submit.prevent="onSubmit"> + <gl-form-checkbox + v-model="model.active" + data-testid="runner-field-paused" + :value="false" + :unchecked-value="true" + > + {{ __('Paused') }} + <template #help> + {{ __("Paused runners don't accept new jobs") }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-model="model.accessLevel" + data-testid="runner-field-protected" + :value="$options.ACCESS_LEVEL_REF_PROTECTED" + :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" + > + {{ __('Protected') }} + <template #help> + {{ __('This runner will only run on pipelines triggered on protected branches') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> + {{ __('Run untagged jobs') }} + <template #help> + {{ __('Indicates whether this runner can pick jobs without tags') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-model="model.locked" + data-testid="runner-field-locked" + :disabled="!canBeLockedToProject" + > + {{ __('Lock to current projects') }} + <template #help> + {{ __('When a runner is locked, it cannot be assigned to other projects') }} + </template> + </gl-form-checkbox> + + <gl-form-group :label="__('IP Address')" data-testid="runner-field-ip-address"> + <gl-form-input-group :value="readonlyIpAddress" readonly select-on-click> + <template #append> + <gl-button + v-gl-tooltip.hover + :title="__('Copy IP Address')" + :aria-label="__('Copy IP Address')" + :data-clipboard-text="readonlyIpAddress" + icon="copy-to-clipboard" + class="d-inline-flex" + /> + </template> + </gl-form-input-group> + </gl-form-group> + + <gl-form-group :label="__('Description')" data-testid="runner-field-description"> + <gl-form-input-group v-model="model.description" /> + </gl-form-group> + + <gl-form-group + data-testid="runner-field-max-timeout" + :label="__('Maximum job timeout')" + :description=" + s__( + 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.', + ) + " + > + <gl-form-input-group v-model.number="model.maximumTimeout" type="number" /> + </gl-form-group> + + <gl-form-group + data-testid="runner-field-tags" + :label="__('Tags')" + :description=" + __('You can set up jobs to only use runners with specific tags. Separate tags with commas.') + " + > + <gl-form-input-group v-model="model.tagList" /> + </gl-form-group> + + <div class="form-actions"> + <gl-button + type="submit" + variant="confirm" + class="js-no-auto-disable" + :loading="saving || !runner" + > + {{ __('Save changes') }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index de3a3fda47e..a57d18ba745 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,11 +1,47 @@ import { s__ } from '~/locale'; +export const RUNNER_PAGE_SIZE = 20; + export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); export const RUNNER_ENTITY_TYPE = 'Ci::Runner'; +// Filtered search parameter names +// - Used for URL params names +// - GlFilteredSearch tokens type + +export const PARAM_KEY_SEARCH = 'search'; +export const PARAM_KEY_STATUS = 'status'; +export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; +export const PARAM_KEY_SORT = 'sort'; +export const PARAM_KEY_PAGE = 'page'; +export const PARAM_KEY_AFTER = 'after'; +export const PARAM_KEY_BEFORE = 'before'; + // CiRunnerType export const INSTANCE_TYPE = 'INSTANCE_TYPE'; export const GROUP_TYPE = 'GROUP_TYPE'; export const PROJECT_TYPE = 'PROJECT_TYPE'; + +// CiRunnerStatus + +export const STATUS_ACTIVE = 'ACTIVE'; +export const STATUS_PAUSED = 'PAUSED'; +export const STATUS_ONLINE = 'ONLINE'; +export const STATUS_OFFLINE = 'OFFLINE'; +export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; + +// CiRunnerAccessLevel + +export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; +export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED'; + +// CiRunnerSort + +export const CREATED_DESC = 'CREATED_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API +export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API +export const CONTACTED_ASC = 'CONTACTED_ASC'; + +export const DEFAULT_SORT = CREATED_DESC; diff --git a/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql b/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql new file mode 100644 index 00000000000..d580ea2785e --- /dev/null +++ b/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql @@ -0,0 +1,5 @@ +mutation runnerDelete($input: RunnerDeleteInput!) { + runnerDelete(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql index d209313d4df..84e0d6cc95c 100644 --- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql @@ -1,6 +1,7 @@ +#import "~/runner/graphql/runner_details.fragment.graphql" + query getRunner($id: CiRunnerID!) { runner(id: $id) { - id - runnerType + ...RunnerDetails } } diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql new file mode 100644 index 00000000000..45df9c625a6 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql @@ -0,0 +1,31 @@ +#import "~/runner/graphql/runner_node.fragment.graphql" +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getRunners( + $before: String + $after: String + $first: Int + $last: Int + $search: String + $status: CiRunnerStatus + $type: CiRunnerType + $sort: CiRunnerSort +) { + runners( + before: $before + after: $after + first: $first + last: $last + search: $search + status: $status + type: $type + sort: $sort + ) { + nodes { + ...RunnerNode + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql new file mode 100644 index 00000000000..6d7dc1e2798 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql @@ -0,0 +1,12 @@ +fragment RunnerDetails on CiRunner { + id + runnerType + active + accessLevel + runUntagged + locked + ipAddress + description + maximumTimeout + tagList +} diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql new file mode 100644 index 00000000000..0835e3c7c09 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -0,0 +1,13 @@ +fragment RunnerNode on CiRunner { + id + description + runnerType + shortSha + version + revision + ipAddress + active + locked + tagList + contactedAt +} diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql new file mode 100644 index 00000000000..d50c1880d77 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql @@ -0,0 +1,10 @@ +#import "~/runner/graphql/runner_details.fragment.graphql" + +mutation runnerUpdate($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + ...RunnerDetails + } + errors + } +} diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue index 4736e547cb9..5d5fa81b851 100644 --- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue +++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue @@ -1,12 +1,16 @@ <script> import { convertToGraphQLId } from '~/graphql_shared/utils'; +import RunnerTypeAlert from '../components/runner_type_alert.vue'; import RunnerTypeBadge from '../components/runner_type_badge.vue'; +import RunnerUpdateForm from '../components/runner_update_form.vue'; import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants'; import getRunnerQuery from '../graphql/get_runner.query.graphql'; export default { components: { + RunnerTypeAlert, RunnerTypeBadge, + RunnerUpdateForm, }, i18n: { I18N_DETAILS_TITLE, @@ -19,7 +23,7 @@ export default { }, data() { return { - runner: {}, + runner: null, }; }, apollo: { @@ -35,9 +39,15 @@ export default { }; </script> <template> - <h2 class="page-title"> - {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }} + <div> + <h2 class="page-title"> + {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }} - <runner-type-badge v-if="runner.runnerType" :type="runner.runnerType" /> - </h2> + <runner-type-badge v-if="runner" :type="runner.runnerType" /> + </h2> + + <runner-type-alert v-if="runner" :type="runner.runnerType" /> + + <runner-update-form :runner="runner" class="gl-my-5" /> + </div> </template> diff --git a/app/assets/javascripts/runner/runner_list/index.js b/app/assets/javascripts/runner/runner_list/index.js new file mode 100644 index 00000000000..5eba14a7948 --- /dev/null +++ b/app/assets/javascripts/runner/runner_list/index.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import RunnerDetailsApp from './runner_list_app.vue'; + +Vue.use(VueApollo); + +export const initRunnerList = (selector = '#js-runner-list') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + // TODO `activeRunnersCount` should be implemented using a GraphQL API. + const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + runnerInstallHelpPage, + }, + render(h) { + return h(RunnerDetailsApp, { + props: { + activeRunnersCount: parseInt(activeRunnersCount, 10), + registrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/runner_list/runner_list_app.vue new file mode 100644 index 00000000000..b4eacb911a2 --- /dev/null +++ b/app/assets/javascripts/runner/runner_list/runner_list_app.vue @@ -0,0 +1,127 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { fetchPolicies } from '~/lib/graphql'; +import { updateHistory } from '~/lib/utils/url_utility'; +import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; +import RunnerList from '../components/runner_list.vue'; +import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; +import RunnerPagination from '../components/runner_pagination.vue'; +import RunnerTypeHelp from '../components/runner_type_help.vue'; +import getRunnersQuery from '../graphql/get_runners.query.graphql'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, +} from './runner_search_utils'; + +export default { + components: { + RunnerFilteredSearchBar, + RunnerList, + RunnerManualSetupHelp, + RunnerTypeHelp, + RunnerPagination, + }, + props: { + activeRunnersCount: { + type: Number, + required: true, + }, + registrationToken: { + type: String, + required: true, + }, + }, + data() { + return { + search: fromUrlQueryToSearch(), + runners: { + items: [], + pageInfo: {}, + }, + }; + }, + apollo: { + runners: { + query: getRunnersQuery, + // Runners can be updated by users directly in this list. + // A "cache and network" policy prevents outdated filtered + // results. + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.variables; + }, + update(data) { + const { runners } = data; + return { + items: runners?.nodes || [], + pageInfo: runners?.pageInfo || {}, + }; + }, + error(err) { + this.captureException(err); + }, + }, + }, + computed: { + variables() { + return fromSearchToVariables(this.search); + }, + runnersLoading() { + return this.$apollo.queries.runners.loading; + }, + noRunnersFound() { + return !this.runnersLoading && !this.runners.items.length; + }, + }, + watch: { + search: { + deep: true, + handler() { + // TODO Implement back button reponse using onpopstate + updateHistory({ + url: fromSearchToUrl(this.search), + title: document.title, + }); + }, + }, + }, + errorCaptured(err) { + this.captureException(err); + }, + methods: { + captureException(err) { + Sentry.withScope((scope) => { + scope.setTag('component', 'runner_list_app'); + Sentry.captureException(err); + }); + }, + }, +}; +</script> +<template> + <div> + <div class="row"> + <div class="col-sm-6"> + <runner-type-help /> + </div> + <div class="col-sm-6"> + <runner-manual-setup-help :registration-token="registrationToken" /> + </div> + </div> + + <runner-filtered-search-bar v-model="search" namespace="admin_runners" /> + + <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> + {{ __('No runners found') }} + </div> + <template v-else> + <runner-list + :runners="runners.items" + :loading="runnersLoading" + :active-runners-count="activeRunnersCount" + /> + <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/runner/runner_list/runner_search_utils.js b/app/assets/javascripts/runner/runner_list/runner_search_utils.js new file mode 100644 index 00000000000..e45972b81db --- /dev/null +++ b/app/assets/javascripts/runner/runner_list/runner_search_utils.js @@ -0,0 +1,109 @@ +import { queryToObject, setUrlParams } from '~/lib/utils/url_utility'; +import { + filterToQueryObject, + processFilters, + urlQueryToFilter, + prepareTokens, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { + PARAM_KEY_SEARCH, + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_SORT, + PARAM_KEY_PAGE, + PARAM_KEY_AFTER, + PARAM_KEY_BEFORE, + DEFAULT_SORT, + RUNNER_PAGE_SIZE, +} from '../constants'; + +const getPaginationFromParams = (params) => { + const page = parseInt(params[PARAM_KEY_PAGE], 10); + const after = params[PARAM_KEY_AFTER]; + const before = params[PARAM_KEY_BEFORE]; + + if (page && (before || after)) { + return { + page, + before, + after, + }; + } + return { + page: 1, + }; +}; + +export const fromUrlQueryToSearch = (query = window.location.search) => { + const params = queryToObject(query, { gatherArrays: true }); + + return { + filters: prepareTokens( + urlQueryToFilter(query, { + filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE], + filteredSearchTermKey: PARAM_KEY_SEARCH, + legacySpacesDecode: false, + }), + ), + sort: params[PARAM_KEY_SORT] || DEFAULT_SORT, + pagination: getPaginationFromParams(params), + }; +}; + +export const fromSearchToUrl = ( + { filters = [], sort = null, pagination = {} }, + url = window.location.href, +) => { + const filterParams = { + // Defaults + [PARAM_KEY_SEARCH]: null, + [PARAM_KEY_STATUS]: [], + [PARAM_KEY_RUNNER_TYPE]: [], + // Current filters + ...filterToQueryObject(processFilters(filters), { + filteredSearchTermKey: PARAM_KEY_SEARCH, + }), + }; + + const isDefaultSort = sort !== DEFAULT_SORT; + const isFirstPage = pagination?.page === 1; + const otherParams = { + // Sorting & Pagination + [PARAM_KEY_SORT]: isDefaultSort ? sort : null, + [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page, + [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before, + [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after, + }; + + return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true); +}; + +export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => { + const variables = {}; + + const queryObj = filterToQueryObject(processFilters(filters), { + filteredSearchTermKey: PARAM_KEY_SEARCH, + }); + + variables.search = queryObj[PARAM_KEY_SEARCH]; + + // TODO Get more than one value when GraphQL API supports OR for "status" + [variables.status] = queryObj[PARAM_KEY_STATUS] || []; + + // TODO Get more than one value when GraphQL API supports OR for "runner type" + [variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || []; + + if (sort) { + variables.sort = sort; + } + + if (pagination.before) { + variables.before = pagination.before; + variables.last = RUNNER_PAGE_SIZE; + } else { + variables.after = pagination.after; + variables.first = RUNNER_PAGE_SIZE; + } + + return variables; +}; diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 10c41315972..d9d4056466a 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -8,10 +8,7 @@ import createStore from './store'; import { initTopbar } from './topbar'; export const initSearchApp = () => { - // Similar to url_utility.decodeUrlParameter - // Our query treats + as %20. This replaces the query + symbols with %20. - const sanitizedSearch = window.location.search.replace(/\+/g, '%20'); - const query = queryToObject(sanitizedSearch); + const query = queryToObject(window.location.search); const store = createStore({ query }); diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index 0af679644f3..0c3f273fec7 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -29,6 +29,7 @@ export const fetchProjects = ({ commit, state }, search) => { }; if (groupId) { + // TODO (https://gitlab.com/gitlab-org/gitlab/-/issues/323331): For errors `createFlash` is called twice; in `callback` and in `Api.groupProjects` Api.groupProjects(groupId, search, {}, callback); } else { // The .catch() is due to the API method not handling a rejection properly diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 2439ab55923..a490adbc11a 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -48,7 +48,7 @@ export default { <template> <gl-form class="search-page-form" @submit.prevent="applyQuery"> <section class="gl-lg-display-flex gl-align-items-flex-end"> - <div class="gl-flex-fill-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> + <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> <label>{{ __('What are you searching for?') }}</label> <gl-search-box-by-type id="dashboard_search" diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index 2acab4e805d..da9252eeacd 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -39,8 +39,8 @@ export default { <searchable-dropdown data-testid="group-filter" :header-text="$options.GROUP_DATA.headerText" - :selected-display-value="$options.GROUP_DATA.selectedDisplayValue" - :items-display-value="$options.GROUP_DATA.itemsDisplayValue" + :name="$options.GROUP_DATA.name" + :full-name="$options.GROUP_DATA.fullName" :loading="fetchingGroups" :selected-item="selectedGroup" :items="groups" diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index b2dd79fcfa3..dbe8ba54216 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -42,8 +42,8 @@ export default { <searchable-dropdown data-testid="project-filter" :header-text="$options.PROJECT_DATA.headerText" - :selected-display-value="$options.PROJECT_DATA.selectedDisplayValue" - :items-display-value="$options.PROJECT_DATA.itemsDisplayValue" + :name="$options.PROJECT_DATA.name" + :full-name="$options.PROJECT_DATA.fullName" :loading="fetchingProjects" :selected-item="selectedProject" :items="projects" diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue index d16850cd889..2e2aa052dd8 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -11,6 +11,7 @@ import { } from '@gitlab/ui'; import { __ } from '~/locale'; import { ANY_OPTION } from '../constants'; +import SearchableDropdownItem from './searchable_dropdown_item.vue'; export default { i18n: { @@ -25,6 +26,7 @@ export default { GlIcon, GlButton, GlSkeletonLoader, + SearchableDropdownItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,12 +37,12 @@ export default { required: false, default: "__('Filter')", }, - selectedDisplayValue: { + name: { type: String, required: false, default: 'name', }, - itemsDisplayValue: { + fullName: { type: String, required: false, default: 'name', @@ -75,6 +77,9 @@ export default { resetDropdown() { this.$emit('change', ANY_OPTION); }, + updateDropdown(item) { + this.$emit('change', item); + }, }, ANY_OPTION, }; @@ -83,15 +88,16 @@ export default { <template> <gl-dropdown class="gl-w-full" - menu-class="gl-w-full!" + menu-class="global-search-dropdown-menu" toggle-class="gl-text-truncate" :header-text="headerText" - @show="$emit('search', searchText)" + :right="true" + @show="openDropdown" @shown="$refs.searchBox.focusInput()" > <template #button-content> <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> - {{ selectedItem[selectedDisplayValue] }} + {{ selectedItem[name] }} </span> <gl-loading-icon v-if="loading" inline class="gl-mr-3" /> <gl-button @@ -115,27 +121,29 @@ export default { v-model="searchText" class="gl-m-3" :debounce="500" - @input="$emit('search', searchText)" + @input="openDropdown" /> <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-checked="isSelected($options.ANY_OPTION)" - @click="resetDropdown" + :is-check-centered="true" + @click="updateDropdown($options.ANY_OPTION)" > - {{ $options.ANY_OPTION.name }} + <span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span> </gl-dropdown-item> </div> <div v-if="!loading"> - <gl-dropdown-item + <searchable-dropdown-item v-for="item in items" :key="item.id" - :is-check-item="true" - :is-checked="isSelected(item)" - @click="$emit('change', item)" - > - {{ item[itemsDisplayValue] }} - </gl-dropdown-item> + :item="item" + :selected-item="selectedItem" + :search-text="searchText" + :name="name" + :full-name="fullName" + @change="updateDropdown" + /> </div> <div v-if="loading" class="gl-mx-4 gl-mt-3"> <gl-skeleton-loader :height="100"> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue new file mode 100644 index 00000000000..498d4af59b4 --- /dev/null +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -0,0 +1,73 @@ +<script> +import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; + +export default { + name: 'SearchableDropdownItem', + components: { + GlDropdownItem, + GlAvatar, + }, + props: { + item: { + type: Object, + required: true, + }, + selectedItem: { + type: Object, + required: true, + }, + searchText: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: true, + }, + fullName: { + type: String, + required: true, + }, + }, + computed: { + isSelected() { + return this.item.id === this.selectedItem.id; + }, + truncatedNamespace() { + return truncateNamespace(this.item[this.fullName]); + }, + highlightedItemName() { + return highlight(this.item[this.name], this.searchText); + }, + }, +}; +</script> + +<template> + <gl-dropdown-item + :is-check-item="true" + :is-checked="isSelected" + :is-check-centered="true" + @click="$emit('change', item)" + > + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + :src="item.avatar_url" + :entity-id="item.id" + :entity-name="item[name]" + shape="rect" + :size="32" + /> + <div class="gl-display-flex gl-flex-direction-column"> + <!-- eslint-disable-next-line vue/no-v-html --> + <span data-testid="item-title" v-html="highlightedItemName">{{ item[name] }}</span> + <span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace">{{ + truncatedNamespace + }}</span> + </div> + </div> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js index 3944b2c8374..dc040fdef34 100644 --- a/app/assets/javascripts/search/topbar/constants.js +++ b/app/assets/javascripts/search/topbar/constants.js @@ -9,13 +9,13 @@ export const ANY_OPTION = Object.freeze({ export const GROUP_DATA = { headerText: __('Filter results by group'), queryParam: 'group_id', - selectedDisplayValue: 'name', - itemsDisplayValue: 'full_name', + name: 'name', + fullName: 'full_name', }; export const PROJECT_DATA = { headerText: __('Filter results by project'), queryParam: 'project_id', - selectedDisplayValue: 'name_with_namespace', - itemsDisplayValue: 'name_with_namespace', + name: 'name', + fullName: 'name_with_namespace', }; diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 3cdcac4c0b4..142dade914b 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -18,18 +18,27 @@ import { * Translations & helpPagePaths for Static Security Configuration Page */ export const SAST_NAME = __('Static Application Security Testing (SAST)'); +export const SAST_SHORT_NAME = s__('ciReport|SAST'); export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); +export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', { + anchor: 'configuration', +}); export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); +export const DAST_SHORT_NAME = s__('ciReport|DAST'); export const DAST_DESCRIPTION = __('Analyze a review version of your web application.'); export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); +export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { + anchor: 'enable-dast', +}); export const DAST_PROFILES_NAME = __('DAST Scans'); export const DAST_PROFILES_DESCRIPTION = __( 'Saved scan settings and target site settings which are reusable.', ); export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index'); +export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage scans'); export const SECRET_DETECTION_NAME = __('Secret Detection'); export const SECRET_DETECTION_DESCRIPTION = __( @@ -38,6 +47,10 @@ export const SECRET_DETECTION_DESCRIPTION = __( export const SECRET_DETECTION_HELP_PATH = helpPagePath( 'user/application_security/secret_detection/index', ); +export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/secret_detection/index', + { anchor: 'configuration' }, +); export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); export const DEPENDENCY_SCANNING_DESCRIPTION = __( @@ -46,6 +59,10 @@ export const DEPENDENCY_SCANNING_DESCRIPTION = __( export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( 'user/application_security/dependency_scanning/index', ); +export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/dependency_scanning/index', + { anchor: 'configuration' }, +); export const CONTAINER_SCANNING_NAME = __('Container Scanning'); export const CONTAINER_SCANNING_DESCRIPTION = __( @@ -54,6 +71,10 @@ export const CONTAINER_SCANNING_DESCRIPTION = __( export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( 'user/application_security/container_scanning/index', ); +export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/container_scanning/index', + { anchor: 'configuration' }, +); export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); export const COVERAGE_FUZZING_DESCRIPTION = __( @@ -136,6 +157,83 @@ export const scanners = [ }, ]; +export const securityFeatures = [ + { + name: SAST_NAME, + shortName: SAST_SHORT_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + configurationHelpPath: SAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST, + // This field is currently hardcoded because SAST is always available. + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 + available: true, + + // This field is currently hardcoded because SAST can always be enabled via MR + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: true, + }, + { + name: DAST_NAME, + shortName: DAST_SHORT_NAME, + description: DAST_DESCRIPTION, + helpPath: DAST_HELP_PATH, + configurationHelpPath: DAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_DAST, + secondary: { + type: REPORT_TYPE_DAST_PROFILES, + name: DAST_PROFILES_NAME, + description: DAST_PROFILES_DESCRIPTION, + configurationText: DAST_PROFILES_CONFIG_TEXT, + }, + }, + { + name: DEPENDENCY_SCANNING_NAME, + description: DEPENDENCY_SCANNING_DESCRIPTION, + helpPath: DEPENDENCY_SCANNING_HELP_PATH, + configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_DEPENDENCY_SCANNING, + }, + { + name: CONTAINER_SCANNING_NAME, + description: CONTAINER_SCANNING_DESCRIPTION, + helpPath: CONTAINER_SCANNING_HELP_PATH, + configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_CONTAINER_SCANNING, + }, + { + name: SECRET_DETECTION_NAME, + description: SECRET_DETECTION_DESCRIPTION, + helpPath: SECRET_DETECTION_HELP_PATH, + configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, + type: REPORT_TYPE_SECRET_DETECTION, + available: true, + }, + { + name: API_FUZZING_NAME, + description: API_FUZZING_DESCRIPTION, + helpPath: API_FUZZING_HELP_PATH, + type: REPORT_TYPE_API_FUZZING, + }, + { + name: COVERAGE_FUZZING_NAME, + description: COVERAGE_FUZZING_DESCRIPTION, + helpPath: COVERAGE_FUZZING_HELP_PATH, + type: REPORT_TYPE_COVERAGE_FUZZING, + }, +]; + +export const complianceFeatures = [ + { + name: LICENSE_COMPLIANCE_NAME, + description: LICENSE_COMPLIANCE_DESCRIPTION, + helpPath: LICENSE_COMPLIANCE_HELP_PATH, + type: REPORT_TYPE_LICENSE_COMPLIANCE, + }, +]; + export const featureToMutationMap = { [REPORT_TYPE_SAST]: { mutationId: 'configureSast', diff --git a/app/assets/javascripts/security_configuration/components/redesigned_app.vue b/app/assets/javascripts/security_configuration/components/redesigned_app.vue new file mode 100644 index 00000000000..d8a12f4a792 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/redesigned_app.vue @@ -0,0 +1,147 @@ +<script> +import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import FeatureCard from './feature_card.vue'; +import SectionLayout from './section_layout.vue'; +import UpgradeBanner from './upgrade_banner.vue'; + +export const i18n = { + compliance: s__('SecurityConfiguration|Compliance'), + securityTesting: s__('SecurityConfiguration|Security testing'), + securityTestingDescription: s__( + `SecurityConfiguration|The status of the tools only applies to the + default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. + Once you've enabled a scan for the default branch, any subsequent feature + branch you create will include the scan.`, + ), + securityConfiguration: __('Security Configuration'), +}; + +export default { + i18n, + components: { + GlTab, + GlLink, + GlTabs, + GlSprintf, + FeatureCard, + SectionLayout, + UpgradeBanner, + UserCalloutDismisser, + }, + props: { + augmentedSecurityFeatures: { + type: Array, + required: true, + }, + augmentedComplianceFeatures: { + type: Array, + required: true, + }, + gitlabCiPresent: { + type: Boolean, + required: false, + default: false, + }, + gitlabCiHistoryPath: { + type: String, + required: false, + default: '', + }, + latestPipelinePath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + canUpgrade() { + return [...this.augmentedSecurityFeatures, ...this.augmentedComplianceFeatures].some( + ({ available }) => !available, + ); + }, + canViewCiHistory() { + return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath); + }, + }, +}; +</script> + +<template> + <article> + <header> + <h1 class="gl-font-size-h1">{{ $options.i18n.securityConfiguration }}</h1> + </header> + + <user-callout-dismisser v-if="canUpgrade" feature-name="security_configuration_upgrade_banner"> + <template #default="{ dismiss, shouldShowCallout }"> + <upgrade-banner v-if="shouldShowCallout" @close="dismiss" /> + </template> + </user-callout-dismisser> + + <gl-tabs content-class="gl-pt-6"> + <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting"> + <section-layout :heading="$options.i18n.securityTesting"> + <template #description> + <p + v-if="latestPipelinePath" + data-testid="latest-pipeline-info-security" + class="gl-line-height-20" + > + <gl-sprintf :message="$options.i18n.securityTestingDescription"> + <template #link="{ content }"> + <gl-link :href="latestPipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p v-if="canViewCiHistory"> + <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{ + $options.i18n.configurationHistory + }}</gl-link> + </p> + </template> + + <template #features> + <feature-card + v-for="feature in augmentedSecurityFeatures" + :key="feature.type" + :feature="feature" + class="gl-mb-6" + /> + </template> + </section-layout> + </gl-tab> + <gl-tab data-testid="compliance-testing-tab" :title="$options.i18n.compliance"> + <section-layout :heading="$options.i18n.compliance"> + <template #description> + <p + v-if="latestPipelinePath" + class="gl-line-height-20" + data-testid="latest-pipeline-info-compliance" + > + <gl-sprintf :message="$options.i18n.securityTestingDescription"> + <template #link="{ content }"> + <gl-link :href="latestPipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p v-if="canViewCiHistory"> + <gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{ + $options.i18n.configurationHistory + }}</gl-link> + </p> + </template> + <template #features> + <feature-card + v-for="feature in augmentedComplianceFeatures" + :key="feature.type" + :feature="feature" + class="gl-mb-6" + /> + </template> + </section-layout> + </gl-tab> + </gl-tabs> + </article> +</template> diff --git a/app/assets/javascripts/security_configuration/components/section_layout.vue b/app/assets/javascripts/security_configuration/components/section_layout.vue new file mode 100644 index 00000000000..1e1f83a6d99 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/section_layout.vue @@ -0,0 +1,23 @@ +<script> +export default { + name: 'SectionLayout', + props: { + heading: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-lg-5"> + <h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2> + <slot name="description"></slot> + </div> + <div class="col-lg-7"> + <slot name="features"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue new file mode 100644 index 00000000000..ca0f9e5c85a --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue @@ -0,0 +1,45 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlBanner, + }, + inject: ['upgradePath'], + i18n: { + title: s__('SecurityConfiguration|Secure your project with Ultimate'), + bodyStart: s__( + `SecurityConfiguration|GitLab Ultimate checks your application for security vulnerabilities + that may lead to unauthorized access, data leaks, and denial of service + attacks. Its features include:`, + ), + bodyListItems: [ + s__('SecurityConfiguration|Vulnerability details and statistics in the merge request.'), + s__('SecurityConfiguration|High-level vulnerability statistics across projects and groups.'), + s__('SecurityConfiguration|Runtime security metrics for application environments.'), + ], + bodyEnd: s__( + 'SecurityConfiguration|With the information provided, you can immediately begin risk analysis and remediation within GitLab.', + ), + buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'), + }, +}; +</script> + +<template> + <gl-banner + :title="$options.i18n.title" + :button-text="$options.i18n.buttonText" + :button-link="upgradePath" + v-on="$listeners" + > + <p>{{ $options.i18n.bodyStart }}</p> + <ul> + <li v-for="bodyListItem in $options.i18n.bodyListItems" :key="bodyListItem"> + {{ bodyListItem }} + </li> + </ul> + <p>{{ $options.i18n.bodyEnd }}</p> + </gl-banner> +</template> diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 1134a1ffb44..e1dc6f24737 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -1,7 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; +import { securityFeatures, complianceFeatures } from './components/constants'; +import RedesignedSecurityConfigurationApp from './components/redesigned_app.vue'; +import { augmentFeatures } from './utils'; export const initStaticSecurityConfiguration = (el) => { if (!el) { @@ -14,8 +18,41 @@ export const initStaticSecurityConfiguration = (el) => { defaultClient: createDefaultClient(), }); - const { projectPath, upgradePath } = el.dataset; + const { + projectPath, + upgradePath, + features, + latestPipelinePath, + gitlabCiHistoryPath, + } = el.dataset; + if (gon.features.securityConfigurationRedesign) { + const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures( + securityFeatures, + complianceFeatures, + features ? JSON.parse(features) : [], + ); + + return new Vue({ + el, + apolloProvider, + provide: { + projectPath, + upgradePath, + }, + render(createElement) { + return createElement(RedesignedSecurityConfigurationApp, { + props: { + augmentedComplianceFeatures, + augmentedSecurityFeatures, + latestPipelinePath, + gitlabCiHistoryPath, + ...parseBooleanDataAttributes(el, ['gitlabCiPresent']), + }, + }); + }, + }); + } return new Vue({ el, apolloProvider, diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js new file mode 100644 index 00000000000..071ebff4f21 --- /dev/null +++ b/app/assets/javascripts/security_configuration/utils.js @@ -0,0 +1,24 @@ +export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => { + const featuresByType = features.reduce((acc, feature) => { + acc[feature.type] = feature; + return acc; + }, {}); + + const augmentFeature = (feature) => { + const augmented = { + ...feature, + ...featuresByType[feature.type], + }; + + if (augmented.secondary) { + augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] }; + } + + return augmented; + }; + + return { + augmentedSecurityFeatures: securityFeatures.map((feature) => augmentFeature(feature)), + augmentedComplianceFeatures: complianceFeatures.map((feature) => augmentFeature(feature)), + }; +}; diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue index 49922ad8e6c..8a5ed9debb3 100644 --- a/app/assets/javascripts/serverless/components/empty_state.vue +++ b/app/assets/javascripts/serverless/components/empty_state.vue @@ -9,7 +9,7 @@ export default { GlSprintf, }, computed: { - ...mapState(['clustersPath', 'emptyImagePath', 'helpPath']), + ...mapState(['emptyImagePath', 'helpPath']), }, }; </script> @@ -18,8 +18,6 @@ export default { <gl-empty-state :svg-path="emptyImagePath" :title="s__('Serverless|Getting started with serverless')" - :primary-button-link="clustersPath" - :primary-button-text="s__('Serverless|Install Knative')" > <template #description> <gl-sprintf diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue index 0b83d4b36eb..0023c64e3e4 100644 --- a/app/assets/javascripts/serverless/components/missing_prometheus.vue +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -26,7 +26,7 @@ export default { return this.missingData ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`) : s__( - `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`, + `ServerlessDetails|Function invocation metrics require the Prometheus cluster integration.`, ); }, }, @@ -48,7 +48,7 @@ export default { <div v-if="!missingData" class="text-left"> <gl-button :href="clustersPath" variant="success" category="primary"> - {{ s__('ServerlessDetails|Install Prometheus') }} + {{ s__('ServerlessDetails|Configure cluster.') }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js index a6c0380a789..166cd796680 100644 --- a/app/assets/javascripts/serverless/store/actions.js +++ b/app/assets/javascripts/serverless/store/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; import statusCodes from '~/lib/utils/http_status'; @@ -59,7 +59,9 @@ export const fetchFunctions = ({ dispatch }, { functionsPath }) => { .then((data) => { if (data === TIMEOUT) { dispatch('receiveFunctionsTimeout'); - createFlash(__('Loading functions timed out. Please reload the page to try again.')); + createFlash({ + message: __('Loading functions timed out. Please reload the page to try again.'), + }); } else if (data.functions !== null && data.functions.length) { dispatch('receiveFunctionsSuccess', data); } else { @@ -68,7 +70,9 @@ export const fetchFunctions = ({ dispatch }, { functionsPath }) => { }) .catch((error) => { dispatch('receiveFunctionsError', error); - createFlash(error); + createFlash({ + message: error, + }); }); }; @@ -120,6 +124,8 @@ export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => { }) .catch((error) => { dispatch('receiveMetricsError', error); - createFlash(error); + createFlash({ + message: error, + }); }); }; 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 c754af5c7de..e522e3ff408 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 @@ -13,11 +13,12 @@ import $ from 'jquery'; import Vue from 'vue'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; import { updateUserStatus } from '~/rest_api'; import { timeRanges } from '~/vue_shared/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import EmojiMenuInModal from './emoji_menu_in_modal'; import { isUserBusy } from './utils'; @@ -44,10 +45,12 @@ export default { GlFormCheckbox, GlDropdown, GlDropdownItem, + EmojiPicker: () => import('~/emoji/components/picker.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { defaultEmoji: { type: String, @@ -102,7 +105,9 @@ export default { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, beforeDestroy() { - this.emojiMenu.destroy(); + if (this.emojiMenu) { + this.emojiMenu.destroy(); + } }, methods: { closeModal() { @@ -121,16 +126,23 @@ export default { this.noEmoji = this.emoji === ''; this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); - this.emojiMenu = new EmojiMenuInModal( - Emoji, - toggleEmojiMenuButtonSelector, - emojiMenuClass, - this.setEmoji, - this.$refs.userStatusForm, - ); + if (!this.glFeatures.improvedEmojiPicker) { + this.emojiMenu = new EmojiMenuInModal( + Emoji, + toggleEmojiMenuButtonSelector, + emojiMenuClass, + this.setEmoji, + this.$refs.userStatusForm, + ); + } + this.setDefaultEmoji(); }) - .catch(() => createFlash(__('Failed to load emoji list.'))); + .catch(() => + createFlash({ + message: __('Failed to load emoji list.'), + }), + ); }, showEmojiMenu(e) { e.stopPropagation(); @@ -164,7 +176,12 @@ export default { this.emoji = emoji; this.noEmoji = false; this.clearEmoji(); - this.emojiTag = emojiTag; + + if (this.glFeatures.improvedEmojiPicker) { + this.emojiTag = Emoji.glEmojiTag(this.emoji); + } else { + this.emojiTag = emojiTag; + } }, clearEmoji() { if (this.emojiTag) { @@ -204,9 +221,11 @@ export default { window.location.reload(); }, onUpdateFail() { - createFlash( - s__("SetStatusModal|Sorry, we weren't able to set your status. Please try again later."), - ); + createFlash({ + message: s__( + "SetStatusModal|Sorry, we weren't able to set your status. Please try again later.", + ), + }); this.closeModal(); }, @@ -241,7 +260,26 @@ export default { <div ref="userStatusForm" class="form-group position-relative m-0"> <div class="input-group gl-mb-5"> <span class="input-group-prepend"> + <emoji-picker + v-if="glFeatures.improvedEmojiPicker" + 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!" + @click="setEmoji" + > + <template #button-content> + <span v-html="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> <button + v-else ref="toggleEmojiMenuButton" v-gl-tooltip.bottom.hover :title="s__('SetStatusModal|Add status emoji')" diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 26e88523abb..adb573db652 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -45,7 +45,7 @@ export default { }; </script> <template> - <div class="title hide-collapsed"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ assigneeTitle }} <gl-loading-icon v-if="loading" inline class="align-bottom" /> <a diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index c3c009e680a..e41bb41dc05 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -48,17 +48,15 @@ export default { <collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" /> <div data-testid="expanded-assignee" class="value hide-collapsed"> - <template v-if="hasNoUsers"> - <span class="assign-yourself no-value"> - {{ __('None') }} - <template v-if="editable"> - - - <button type="button" class="btn-link" @click="assignSelf"> - {{ __('assign yourself') }} - </button> - </template> - </span> - </template> + <span v-if="hasNoUsers" class="no-value" data-testid="no-value"> + {{ __('None') }} + <template v-if="editable"> + - + <button type="button" class="btn-link" data-testid="assign-yourself" @click="assignSelf"> + {{ __('assign yourself') }} + </button> + </template> + </span> <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" /> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index ca95599742a..9840aa4ed66 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -149,7 +149,6 @@ export default { :users="exposeAvailabilityStatus(store.assignees)" :editable="store.editable" :issuable-type="issuableType" - class="value" @assign-self="assignSelf" /> </div> 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 932be7addc0..d9a974202a3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -61,7 +61,7 @@ export default { required: false, default: IssuableType.Issue, validator(value) { - return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); + return [IssuableType.Issue, IssuableType.MergeRequest, IssuableType.Alert].includes(value); }, }, issuableId: { @@ -229,7 +229,7 @@ export default { @expand-widget="expandWidget" /> </template> - <template #default> + <template #default="{ edit }"> <user-select ref="userSelect" v-model="selected" @@ -240,6 +240,7 @@ export default { :allow-multiple-assignees="allowMultipleAssignees" :current-user="currentUser" :issuable-type="issuableType" + :is-editing="edit" class="gl-w-full dropdown-menu-user" @toggle="collapseWidget" @error="showError" @@ -247,7 +248,7 @@ export default { > <template #footer> <gl-dropdown-item v-if="directlyInviteMembers"> - <sidebar-invite-members /> + <sidebar-invite-members :issuable-type="issuableType" /> </gl-dropdown-item> </template ></user-select> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index 5c32d03e0d4..8ef65ef7308 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -9,6 +9,17 @@ export default { components: { InviteMembersTrigger, }, + props: { + issuableType: { + type: String, + required: true, + }, + }, + computed: { + triggerSource() { + return `${this.issuableType}-assignee-dropdown`; + }, + }, }; </script> @@ -18,6 +29,7 @@ export default { :display-text="$options.displayText" :event="$options.dataTrackEvent" :label="$options.dataTrackLabel" + :trigger-source="triggerSource" classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" /> </template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 6a68e914b84..c3dfa5f8b14 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -112,6 +112,9 @@ export default { dateValue() { return this.issuable?.[this.dateType] || null; }, + firstDay() { + return gon.first_day_of_week; + }, isLoading() { return this.$apollo.queries.issuable.loading || this.loading; }, @@ -286,6 +289,7 @@ export default { ref="datePicker" class="gl-relative" :default-date="parsedDate" + :first-day="firstDay" show-clear-button autocomplete="off" @input="setDate" diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index c9b6616e067..b7832ca679c 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -10,6 +10,8 @@ import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_req import { toLabelGid } from '~/sidebar/utils'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const mutationMap = { [IssuableType.Issue]: { @@ -25,8 +27,10 @@ const mutationMap = { export default { components: { LabelsSelect, + LabelsSelectWidget, }, variant: DropdownVariant.Sidebar, + mixins: [glFeatureFlagMixin()], inject: [ 'allowLabelCreate', 'allowLabelEdit', @@ -135,7 +139,32 @@ export default { </script> <template> + <labels-select-widget + v-if="glFeatures.labelsWidget" + class="block labels js-labels-block" + :allow-label-remove="allowLabelEdit" + :allow-label-create="allowLabelCreate" + :allow-label-edit="allowLabelEdit" + :allow-multiselect="true" + :allow-scoped-labels="allowScopedLabels" + :footer-create-label-title="__('Create project label')" + :footer-manage-label-title="__('Manage project labels')" + :labels-create-title="__('Create project label')" + :labels-fetch-path="labelsFetchPath" + :labels-filter-base-path="projectIssuesPath" + :labels-manage-path="labelsManagePath" + :labels-select-in-progress="isLabelsSelectInProgress" + :selected-labels="selectedLabels" + :variant="$options.sidebar" + data-qa-selector="labels_block" + @onDropdownClose="handleDropdownClose" + @onLabelRemove="handleLabelRemove" + @updateSelectedLabels="handleUpdateSelectedLabels" + > + {{ __('None') }} + </labels-select-widget> <labels-select + v-else class="block labels js-labels-block" :allow-label-remove="allowLabelEdit" :allow-label-create="allowLabelCreate" 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 3468acb38e7..81ee0a73739 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -86,7 +86,7 @@ export default { <gl-icon :name="lockStatus.icon" class="sidebar-item-icon is-active" /> </div> - <div class="title hide-collapsed"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} <a v-if="isEditable" diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue deleted file mode 100644 index 4ac515e552a..00000000000 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue +++ /dev/null @@ -1,31 +0,0 @@ -<script> -import Store from '../../stores/sidebar_store'; -import participants from './participants.vue'; - -export default { - components: { - participants, - }, - props: { - mediator: { - type: Object, - required: true, - }, - }, - data() { - return { - store: new Store(), - }; - }, -}; -</script> - -<template> - <div class="block participants"> - <participants - :loading="store.isFetching.participants" - :participants="store.participants" - :number-of-less-participants="7" - /> - </div> -</template> 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 d3043e6f6aa..9927a0f9114 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -64,5 +64,6 @@ export default { :loading="isLoading" :participants="participants" :number-of-less-participants="7" + class="block participants" /> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index a461d992222..88c0b18ccc7 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -33,7 +33,7 @@ export default { }; </script> <template> - <div class="title hide-collapsed"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ reviewerTitle }} <gl-loading-icon v-if="loading" inline class="align-bottom" /> <a diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 2c52d7142f7..5729b958b5d 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -59,7 +59,7 @@ export default { <div class="value hide-collapsed"> <template v-if="hasNoUsers"> - <span class="assign-yourself no-value"> + <span class="no-value"> {{ __('None') }} </span> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index b5cf5df4957..c0bd54c60da 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -103,7 +103,6 @@ export default { :users="store.reviewers" :editable="store.editable" :issuable-type="issuableType" - class="value" @request-review="requestReview" /> </div> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 6a6300dcde0..592cfea5e32 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -148,7 +148,9 @@ export default { </div> <div class="hide-collapsed"> - <p class="title gl-display-flex gl-justify-content-space-between"> + <p + class="gl-line-height-20 gl-mb-0 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" + > {{ $options.i18n.SEVERITY }} <gl-link data-testid="editButton" diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue new file mode 100644 index 00000000000..c80ccc928b3 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -0,0 +1,360 @@ +<script> +import { + GlLink, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + GlLoadingIcon, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; +import { __, s__, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { + IssuableAttributeState, + IssuableAttributeType, + issuableAttributesQueries, + noAttributeId, +} from '../constants'; + +export default { + noAttributeId, + IssuableAttributeState, + issuableAttributesQueries, + i18n: { + [IssuableAttributeType.Milestone]: __('Milestone'), + none: __('None'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + SidebarEditableItem, + GlLink, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlDropdownDivider, + GlSearchBoxByType, + GlIcon, + GlLoadingIcon, + }, + inject: { + isClassicSidebar: { + default: false, + }, + }, + props: { + issuableAttribute: { + type: String, + required: true, + validator(value) { + return [IssuableAttributeType.Milestone].includes(value); + }, + }, + workspacePath: { + required: true, + type: String, + }, + iid: { + required: true, + type: String, + }, + attrWorkspacePath: { + required: true, + type: String, + }, + issuableType: { + type: String, + required: true, + validator(value) { + return value === IssuableType.Issue; + }, + }, + }, + apollo: { + currentAttribute: { + query() { + const { current } = this.issuableAttributeQuery; + const { query } = current[this.issuableType]; + + return query; + }, + variables() { + return { + fullPath: this.workspacePath, + iid: this.iid, + }; + }, + update(data) { + return data?.workspace?.issuable.attribute; + }, + error(error) { + createFlash({ + message: this.i18n.currentFetchError, + captureError: true, + error, + }); + }, + }, + attributesList: { + query() { + const { list } = this.issuableAttributeQuery; + const { query } = list[this.issuableType]; + + return query; + }, + skip() { + return !this.editing; + }, + debounce: 250, + variables() { + return { + fullPath: this.attrWorkspacePath, + title: this.searchTerm, + state: this.$options.IssuableAttributeState[this.issuableAttribute], + }; + }, + update(data) { + if (data?.workspace) { + return data?.workspace?.attributes.nodes; + } + return []; + }, + error(error) { + createFlash({ message: this.i18n.listFetchError, captureError: true, error }); + }, + }, + }, + data() { + return { + searchTerm: '', + editing: false, + updating: false, + selectedTitle: null, + currentAttribute: null, + attributesList: [], + tracking: { + label: 'right_sidebar', + event: 'click_edit_button', + property: this.issuableAttribute, + }, + }; + }, + computed: { + issuableAttributeQuery() { + return this.$options.issuableAttributesQueries[this.issuableAttribute]; + }, + attributeTitle() { + return this.currentAttribute?.title || this.i18n.noAttribute; + }, + attributeUrl() { + return this.currentAttribute?.webUrl; + }, + dropdownText() { + return this.currentAttribute + ? this.currentAttribute?.title + : this.$options.i18n[this.issuableAttribute]; + }, + loading() { + return this.$apollo.queries.currentAttribute.loading; + }, + emptyPropsList() { + return this.attributesList.length === 0; + }, + attributeTypeTitle() { + return this.$options.i18n[this.issuableAttribute]; + }, + i18n() { + return { + noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { + issuableAttribute: this.issuableAttribute, + }), + assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { + issuableAttribute: this.issuableAttribute, + }), + noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { + issuableAttribute: this.issuableAttribute, + }), + updateError: sprintf( + s__( + 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', + ), + { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, + ), + listFetchError: sprintf( + s__( + 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', + ), + { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, + ), + currentFetchError: sprintf( + s__( + 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', + ), + { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, + ), + }; + }, + }, + methods: { + updateAttribute(attributeId) { + if (this.currentAttribute === null && attributeId === null) return; + if (attributeId === this.currentAttribute?.id) return; + + this.updating = true; + + const selectedAttribute = + Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId); + this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none; + + const { current } = this.issuableAttributeQuery; + const { mutation } = current[this.issuableType]; + + this.$apollo + .mutate({ + mutation, + variables: { + fullPath: this.workspacePath, + attributeId: + this.issuableAttribute === IssuableAttributeType.Milestone + ? getIdFromGraphQLId(attributeId) + : attributeId, + iid: this.iid, + }, + }) + .then(({ data }) => { + if (data.issuableSetAttribute?.errors?.length) { + createFlash({ + message: data.issuableSetAttribute.errors[0], + captureError: true, + error: data.issuableSetAttribute.errors[0], + }); + } else { + this.$emit('attribute-updated', data); + } + }) + .catch((error) => { + createFlash({ message: this.i18n.updateError, captureError: true, error }); + }) + .finally(() => { + this.updating = false; + this.searchTerm = ''; + this.selectedTitle = null; + }); + }, + isAttributeChecked(attributeId = undefined) { + return ( + attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId) + ); + }, + showDropdown() { + this.$refs.newDropdown.show(); + }, + handleOpen() { + this.editing = true; + this.showDropdown(); + }, + handleClose() { + this.editing = false; + }, + setFocus() { + this.$refs.search.focusInput(); + }, + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="attributeTypeTitle" + :data-testid="`${issuableAttribute}-edit`" + :tracking="tracking" + :loading="updating || loading" + @open="handleOpen" + @close="handleClose" + > + <template #collapsed> + <div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon"> + <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" /> + <span class="collapse-truncated-title">{{ attributeTitle }}</span> + </div> + <div + :data-testid="`select-${issuableAttribute}`" + :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" + > + <span v-if="updating" class="gl-font-weight-bold">{{ selectedTitle }}</span> + <span v-else-if="!currentAttribute" class="gl-text-gray-500"> + {{ $options.i18n.none }} + </span> + <slot + v-else + name="value" + :attributeTitle="attributeTitle" + :attributeUrl="attributeUrl" + :currentAttribute="currentAttribute" + > + <gl-link class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl"> + {{ attributeTitle }} + </gl-link> + </slot> + </div> + </template> + <template #default> + <gl-dropdown + ref="newDropdown" + lazy + :header-text="i18n.assignAttribute" + :text="dropdownText" + :loading="loading" + class="gl-w-full" + @shown="setFocus" + > + <gl-search-box-by-type ref="search" v-model="searchTerm" /> + <gl-dropdown-item + :data-testid="`no-${issuableAttribute}-item`" + :is-check-item="true" + :is-checked="isAttributeChecked($options.noAttributeId)" + @click="updateAttribute($options.noAttributeId)" + > + {{ i18n.noAttribute }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-loading-icon + v-if="$apollo.queries.attributesList.loading" + class="gl-py-4" + data-testid="loading-icon-dropdown" + /> + <template v-else> + <gl-dropdown-text v-if="emptyPropsList"> + {{ i18n.noAttributesFound }} + </gl-dropdown-text> + <slot + v-else + name="list" + :attributesList="attributesList" + :isAttributeChecked="isAttributeChecked" + :updateAttribute="updateAttribute" + > + <gl-dropdown-item + v-for="attrItem in attributesList" + :key="attrItem.id" + :is-check-item="true" + :is-checked="isAttributeChecked(attrItem.id)" + :data-testid="`${issuableAttribute}-items`" + @click="updateAttribute(attrItem.id)" + > + {{ attrItem.title }} + </gl-dropdown-item> + </slot> + </template> + </gl-dropdown> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 0fb8d762c7c..825d7ff5841 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -109,8 +109,13 @@ export default { <template> <div> - <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> - <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span> + <div + class="gl-display-flex gl-align-items-center gl-line-height-20 gl-mb-2 gl-text-gray-900" + @click.self="collapse" + > + <span class="hide-collapsed" data-testid="title" @click="collapse"> + {{ title }} + </span> <slot name="title-extra"></slot> <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon @@ -135,7 +140,7 @@ export default { </gl-button> </div> <template v-if="!initialLoading"> - <div v-show="!edit" data-testid="collapsed-content"> + <div v-show="!edit" data-testid="collapsed-content" class="gl-line-height-14"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> 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 ee7502e3457..e97742a1339 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -24,7 +24,6 @@ export default { GlToggle, SidebarEditableItem, }, - inject: ['canUpdate'], props: { iid: { type: String, @@ -102,6 +101,12 @@ export default { parent: this.parentIsGroup ? 'group' : 'project', }); }, + isLoggedIn() { + return Boolean(gon.current_user_id); + }, + canSubscribe() { + return this.emailsDisabled || !this.isLoggedIn; + }, }, methods: { setSubscribed(subscribed) { @@ -174,7 +179,7 @@ export default { <gl-toggle :value="subscribed" :is-loading="isLoading" - :disabled="emailsDisabled || !canUpdate" + :disabled="canSubscribe" class="hide-collapsed gl-ml-auto" data-testid="subscription-toggle" :label="$options.i18n.notifications" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index 99302993b9a..3705d725a15 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -70,7 +70,7 @@ export default { </script> <template> - <div data-testid="timeTrackingComparisonPane"> + <div class="gl-mt-2" data-testid="timeTrackingComparisonPane"> <div v-gl-tooltip data-testid="compareMeter" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 67242b3b5b7..f91a78b7f1d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -13,13 +13,17 @@ export default { GlLoadingIcon, GlTable, }, - inject: ['issuableId', 'issuableType'], + inject: ['issuableType'], props: { limitToHours: { type: Boolean, default: false, required: false, }, + issuableId: { + type: String, + required: true, + }, }, data() { return { report: [], isLoading: true }; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index c70d99ac178..58167b3934a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -5,19 +5,27 @@ import { intersection } from 'lodash'; import '~/smart_interval'; import eventHub from '../../event_hub'; -import Mediator from '../../sidebar_mediator'; -import Store from '../../stores/sidebar_store'; import IssuableTimeTracker from './time_tracker.vue'; export default { components: { IssuableTimeTracker, }, - data() { - return { - mediator: new Mediator(), - store: new Store(), - }; + props: { + fullPath: { + type: String, + required: false, + default: '', + }, + issuableIid: { + type: String, + required: true, + }, + limitToHours: { + type: Boolean, + required: false, + default: false, + }, }, mounted() { this.listenForQuickActions(); @@ -41,7 +49,7 @@ export default { changedCommands = []; } if (changedCommands && intersection(subscribedCommands, changedCommands).length) { - this.mediator.fetch(); + eventHub.$emit('timeTracker:refresh'); } }, }, @@ -51,11 +59,9 @@ export default { <template> <div class="block"> <issuable-time-tracker - :time-estimate="store.timeEstimate" - :time-spent="store.totalTimeSpent" - :human-time-estimate="store.humanTimeEstimate" - :human-time-spent="store.humanTotalTimeSpent" - :limit-to-hours="store.timeTrackingLimitToHours" + :full-path="fullPath" + :issuable-iid="issuableIid" + :limit-to-hours="limitToHours" /> </div> </template> 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 64f2ddc1d16..3feff8639a1 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,6 +1,9 @@ <script> -import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issue_show/constants'; import { s__, __ } from '~/locale'; +import { timeTrackingQueries } from '~/sidebar/constants'; + import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; @@ -18,6 +21,7 @@ export default { GlIcon, GlLink, GlModal, + GlLoadingIcon, TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, TimeTrackingComparisonPane, @@ -27,29 +31,27 @@ export default { directives: { GlModal: GlModalDirective, }, + inject: ['issuableType'], props: { - timeEstimate: { - type: Number, - required: true, - }, - timeSpent: { - type: Number, - required: true, + limitToHours: { + type: Boolean, + default: false, + required: false, }, - humanTimeEstimate: { + fullPath: { type: String, required: false, default: '', }, - humanTimeSpent: { + issuableIid: { type: String, required: false, default: '', }, - limitToHours: { - type: Boolean, - default: false, + initialTimeTracking: { + type: Object, required: false, + default: null, }, /* In issue list, "time-tracking-collapsed-state" is always rendered even if the sidebar isn't collapsed. @@ -70,47 +72,103 @@ export default { data() { return { showHelp: false, + timeTracking: { + ...this.initialTimeTracking, + }, }; }, + apollo: { + issuableTimeTracking: { + query() { + return timeTrackingQueries[this.issuableType].query; + }, + skip() { + // We don't fetch info via GraphQL in following cases + // 1. Time tracking info was provided via prop + // 2. issuableIid and fullPath are not provided. + if (!this.initialTimeTracking) { + return false; + } else if (this.issuableIid && this.fullPath) { + return false; + } + return true; + }, + variables() { + return { + iid: this.issuableIid, + fullPath: this.fullPath, + }; + }, + update(data) { + this.timeTracking = { + ...data.workspace?.issuable, + }; + }, + }, + }, computed: { - hasTimeSpent() { - return Boolean(this.timeSpent); + isTimeTrackingInfoLoading() { + return this.$apollo?.queries.issuableTimeTracking.loading ?? false; + }, + timeEstimate() { + return this.timeTracking?.timeEstimate || 0; + }, + totalTimeSpent() { + return this.timeTracking?.totalTimeSpent || 0; + }, + humanTimeEstimate() { + return this.timeTracking?.humanTimeEstimate || ''; + }, + humanTotalTimeSpent() { + return this.timeTracking?.humanTotalTimeSpent || ''; + }, + hasTotalTimeSpent() { + return Boolean(this.totalTimeSpent); }, hasTimeEstimate() { return Boolean(this.timeEstimate); }, showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; + return this.hasTimeEstimate && this.hasTotalTimeSpent; }, showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; + return this.hasTimeEstimate && !this.hasTotalTimeSpent; }, showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; + return this.hasTotalTimeSpent && !this.hasTimeEstimate; }, showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; + return !this.hasTimeEstimate && !this.hasTotalTimeSpent; }, showHelpState() { return Boolean(this.showHelp); }, + isTimeReportSupported() { + return ( + [IssuableType.Issue, IssuableType.MergeRequest].includes(this.issuableType) && + this.issuableIid + ); + }, + }, + watch: { + /** + * When `initialTimeTracking` is provided via prop, + * we don't query the same via GraphQl and instead + * monitor it for any updates (eg; Epic Swimlanes) + */ + initialTimeTracking(timeTracking) { + this.timeTracking = timeTracking; + }, }, created() { - eventHub.$on('timeTracker:updateData', this.update); + eventHub.$on('timeTracker:refresh', this.refresh); }, methods: { toggleHelpState(show) { this.showHelp = show; }, - update(data) { - const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data; - - /* eslint-disable vue/no-mutating-props */ - this.timeEstimate = timeEstimate; - this.timeSpent = timeSpent; - this.humanTimeEstimate = humanTimeEstimate; - this.humanTimeSpent = humanTimeSpent; - /* eslint-enable vue/no-mutating-props */ + refresh() { + this.$apollo.queries.issuableTimeTracking.refetch(); }, }, }; @@ -125,11 +183,12 @@ export default { :show-help-state="showHelpState" :show-spent-only-state="showSpentOnlyState" :show-estimate-only-state="showEstimateOnlyState" - :time-spent-human-readable="humanTimeSpent" + :time-spent-human-readable="humanTotalTimeSpent" :time-estimate-human-readable="humanTimeEstimate" /> - <div class="title hide-collapsed gl-mb-3"> + <div class="hide-collapsed gl-line-height-20 gl-text-gray-900"> {{ __('Time tracking') }} + <gl-loading-icon v-if="isTimeTrackingInfoLoading" inline /> <div v-if="!showHelpState" data-testid="helpButton" @@ -147,14 +206,14 @@ export default { <gl-icon name="close" /> </div> </div> - <div class="time-tracking-content hide-collapsed"> + <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> <div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane"> <span class="gl-font-weight-bold">{{ $options.i18n.estimatedOnlyText }} </span >{{ humanTimeEstimate }} </div> <time-tracking-spent-only-pane v-if="showSpentOnlyState" - :time-spent-human-readable="humanTimeSpent" + :time-spent-human-readable="humanTotalTimeSpent" /> <div v-if="showNoTimeTrackingState" data-testid="noTrackingPane"> <span class="gl-text-gray-500">{{ $options.i18n.noTimeTrackingText }}</span> @@ -162,26 +221,28 @@ export default { <time-tracking-comparison-pane v-if="showComparisonState" :time-estimate="timeEstimate" - :time-spent="timeSpent" - :time-spent-human-readable="humanTimeSpent" + :time-spent="totalTimeSpent" + :time-spent-human-readable="humanTotalTimeSpent" :time-estimate-human-readable="humanTimeEstimate" :limit-to-hours="limitToHours" /> - <gl-link - v-if="hasTimeSpent" - v-gl-modal="'time-tracking-report'" - data-testid="reportLink" - href="#" - class="btn-link" - >{{ __('Time tracking report') }}</gl-link - > - <gl-modal - modal-id="time-tracking-report" - :title="__('Time tracking report')" - :hide-footer="true" - > - <time-tracking-report :limit-to-hours="limitToHours" /> - </gl-modal> + <template v-if="isTimeReportSupported"> + <gl-link + v-if="hasTotalTimeSpent" + v-gl-modal="'time-tracking-report'" + data-testid="reportLink" + href="#" + > + {{ __('Time tracking report') }} + </gl-link> + <gl-modal + modal-id="time-tracking-report" + :title="__('Time tracking report')" + :hide-footer="true" + > + <time-tracking-report :limit-to-hours="limitToHours" :issuable-iid="issuableIid" /> + </gl-modal> + </template> <transition name="help-state-toggle"> <time-tracking-help-state v-if="showHelpState" /> </transition> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index a4e6d8854d1..e8e69c19d9f 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -9,8 +9,10 @@ import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.g import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; +import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; +import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql'; import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql'; import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; @@ -19,6 +21,8 @@ import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_conf import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; +import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; @@ -27,6 +31,9 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; +import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; +import projectMilestonesQuery from './queries/project_milestones.query.graphql'; export const ASSIGNEES_DEBOUNCE_DELAY = 250; @@ -40,6 +47,10 @@ export const assigneesQueries = { query: getMergeRequestAssignees, mutation: updateMergeRequestAssigneesMutation, }, + [IssuableType.Alert]: { + query: getAlertAssignees, + mutation: updateAlertAssigneesMutation, + }, }; export const participantsQueries = { @@ -52,6 +63,10 @@ export const participantsQueries = { [IssuableType.Epic]: { query: epicParticipantsQuery, }, + [IssuableType.Alert]: { + query: '', + skipQuery: true, + }, }; export const confidentialityQueries = { @@ -107,6 +122,15 @@ export const subscribedQueries = { }, }; +export const timeTrackingQueries = { + [IssuableType.Issue]: { + query: issueTimeTrackingQuery, + }, + [IssuableType.MergeRequest]: { + query: mergeRequestTimeTrackingQuery, + }, +}; + export const dueDateQueries = { [IssuableType.Issue]: { query: issueDueDateQuery, @@ -133,3 +157,33 @@ export const timelogQueries = { query: getMrTimelogsQuery, }, }; + +export const noAttributeId = null; + +export const issuableMilestoneQueries = { + [IssuableType.Issue]: { + query: projectIssueMilestoneQuery, + mutation: projectIssueMilestoneMutation, + }, +}; + +export const milestonesQueries = { + [IssuableType.Issue]: { + query: projectMilestonesQuery, + }, +}; + +export const IssuableAttributeType = { + Milestone: 'milestone', +}; + +export const IssuableAttributeState = { + [IssuableAttributeType.Milestone]: 'active', +}; + +export const issuableAttributesQueries = { + [IssuableAttributeType.Milestone]: { + current: issuableMilestoneQueries, + list: milestonesQueries, + }, +}; diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index 8615b52f1b8..1a806a051b7 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,5 +1,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import produce from 'immer'; import VueApollo from 'vue-apollo'; +import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; @@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ introspectionQueryResultData, }); -export const defaultClient = createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, +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 }); }, - assumeImmutableResults: true, }, -); +}; + +export const defaultClient = createDefaultClient(resolvers, { + cacheConfig: { + fragmentMatcher, + }, + assumeImmutableResults: true, +}); 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 b11c8f76a6d..270b22fcdf9 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { IssuableType } from '~/issue_show/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; import timeTracker from './components/time_tracking/time_tracker.vue'; @@ -8,7 +9,14 @@ export default class SidebarMilestone { if (!el) return; - const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent, limitToHours } = el.dataset; + const { + timeEstimate, + timeSpent, + humanTimeEstimate, + humanTimeSpent, + limitToHours, + iid, + } = el.dataset; // eslint-disable-next-line no-new new Vue({ @@ -16,14 +24,20 @@ export default class SidebarMilestone { components: { timeTracker, }, + provide: { + issuableType: IssuableType.Milestone, + }, render: (createElement) => createElement('timeTracker', { props: { - timeEstimate: parseInt(timeEstimate, 10), - timeSpent: parseInt(timeSpent, 10), - humanTimeEstimate, - humanTimeSpent, limitToHours: parseBoolean(limitToHours), + issuableIid: iid.toString(), + initialTimeTracking: { + timeEstimate: parseInt(timeEstimate, 10), + totalTimeSpent: parseInt(timeSpent, 10), + humanTimeEstimate, + humanTotalTimeSpent: humanTimeSpent, + }, }, }), }); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 3f24fdc75dc..f53760eab93 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createFlash from '~/flash'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issue_show/constants'; import { isInIssuePage, @@ -14,14 +16,15 @@ import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assi import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; +import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; +import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; -import sidebarParticipants from './components/participants/sidebar_participants.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; @@ -123,6 +126,12 @@ function mountAssigneesComponent() { }, }), }); + + const assigneeDropdown = document.querySelector('.js-sidebar-assignee-dropdown'); + + if (assigneeDropdown) { + trackShowInviteMemberLink(assigneeDropdown); + } } function mountReviewersComponent(mediator) { @@ -149,6 +158,12 @@ function mountReviewersComponent(mediator) { }, }), }); + + const reviewerDropdown = document.querySelector('.js-sidebar-reviewer-dropdown'); + + if (reviewerDropdown) { + trackShowInviteMemberLink(reviewerDropdown); + } } export function mountSidebarLabels() { @@ -191,6 +206,7 @@ function mountConfidentialComponent() { }, provide: { canUpdate: initialData.is_editable, + isClassicSidebar: true, }, render: (createElement) => @@ -314,21 +330,29 @@ function mountLockComponent() { }); } -function mountParticipantsComponent(mediator) { +function mountParticipantsComponent() { const el = document.querySelector('.js-sidebar-participants-entry-point'); if (!el) return; + const { fullPath, iid } = getSidebarOptions(); + // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, components: { - sidebarParticipants, + SidebarParticipantsWidget, }, render: (createElement) => - createElement('sidebar-participants', { + createElement('sidebar-participants-widget', { props: { - mediator, + iid: String(iid), + fullPath, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, }, }), }); @@ -367,7 +391,7 @@ function mountSubscriptionsComponent() { function mountTimeTrackingComponent() { const el = document.getElementById('issuable-time-tracker'); - const { id, issuableType } = getSidebarOptions(); + const { iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions(); if (!el) return; @@ -375,8 +399,15 @@ function mountTimeTrackingComponent() { new Vue({ el, apolloProvider, - provide: { issuableId: id, issuableType }, - render: (createElement) => createElement(SidebarTimeTracking, {}), + provide: { issuableType }, + render: (createElement) => + createElement(SidebarTimeTracking, { + props: { + fullPath, + issuableIid: iid.toString(), + limitToHours: timeTrackingLimitToHours, + }, + }), }); } @@ -425,6 +456,9 @@ const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; export function mountSidebar(mediator) { + initInviteMembersModal(); + initInviteMembersTrigger(); + if (isAssigneesWidgetShown) { mountAssigneesComponent(); } else { @@ -435,7 +469,7 @@ export function mountSidebar(mediator) { mountDueDateComponent(mediator); mountReferenceComponent(mediator); mountLockComponent(); - mountParticipantsComponent(mediator); + mountParticipantsComponent(); mountSubscriptionsComponent(); mountCopyEmailComponent(); diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql new file mode 100644 index 00000000000..7ac989b5c63 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql @@ -0,0 +1,13 @@ +query issueTimeTracking($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + 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 new file mode 100644 index 00000000000..b1ab1bcbe87 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql @@ -0,0 +1,13 @@ +query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: mergeRequest(iid: $iid) { + __typename + id + humanTimeEstimate + humanTotalTimeSpent + timeEstimate + totalTimeSpent + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql new file mode 100644 index 00000000000..8db5359dac0 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql @@ -0,0 +1,5 @@ +fragment MilestoneFragment on Milestone { + id + title + webUrl: webPath +} diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql new file mode 100644 index 00000000000..d88ad8b1087 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql @@ -0,0 +1,17 @@ +mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attributeId: ID) { + issuableSetAttribute: updateIssue( + input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId } + ) { + __typename + errors + issuable: issue { + __typename + id + attribute: milestone { + title + id + state + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql new file mode 100644 index 00000000000..2bc42a0b011 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql @@ -0,0 +1,14 @@ +#import "./milestone.fragment.graphql" + +query projectIssueMilestone($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + attribute: milestone { + ...MilestoneFragment + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql new file mode 100644 index 00000000000..1237640c468 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql @@ -0,0 +1,13 @@ +#import "./milestone.fragment.graphql" + +query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { + workspace: project(fullPath: $fullPath) { + __typename + attributes: milestones(searchTitle: $title, state: $state) { + nodes { + ...MilestoneFragment + state + } + } + } +} diff --git a/app/assets/javascripts/sidebar/track_invite_members.js b/app/assets/javascripts/sidebar/track_invite_members.js new file mode 100644 index 00000000000..eab15578f0f --- /dev/null +++ b/app/assets/javascripts/sidebar/track_invite_members.js @@ -0,0 +1,12 @@ +import $ from 'jquery'; +import Tracking from '~/tracking'; + +export default function initTrackInviteMembers(userDropdown) { + const { trackEvent, trackLabel } = userDropdown.querySelector('.js-invite-members-track').dataset; + + $(userDropdown).on('shown.bs.dropdown', () => { + Tracking.event(undefined, trackEvent, { + label: trackLabel, + }); + }); +} diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 2c4928fc338..d2841156e55 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import { spriteIcon } from '~/lib/utils/common_utils'; import FilesCommentButton from './files_comment_button'; -import { deprecatedCreateFlash as createFlash } from './flash'; +import createFlash from './flash'; import initImageDiffHelper from './image_diff/helpers/init_image_diff'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; @@ -95,7 +95,9 @@ export default class SingleFileDiff { if (cb) cb(); }) .catch(() => { - createFlash(__('An error occurred while retrieving diff')); + createFlash({ + message: __('An error occurred while retrieving diff'), + }); }); } } diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 4fb27397039..612b4c7d2e3 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; @@ -63,7 +63,7 @@ export default { .catch((e) => this.flashAPIFailure(e)); }, flashAPIFailure(err) { - Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); + createFlash({ message: sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }) }); }, }, }; diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index a51a4f9f604..ea775eff358 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -1,7 +1,7 @@ <script> +import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants'; +import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; -import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import imageRepository from '../image_repository'; import formatter from '../services/formatter'; import renderImage from '../services/renderers/render_image'; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 49a2ca03ace..beec1b515ad 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -1,5 +1,5 @@ <script> -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import Tracking from '~/tracking'; import EditArea from '../components/edit_area.vue'; @@ -45,7 +45,9 @@ export default { return !this.appData.isSupportedContent; }, error() { - createFlash(LOAD_CONTENT_ERROR); + createFlash({ + message: LOAD_CONTENT_ERROR, + }); }, }, }, diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js index cbb30baa488..cbb30baa488 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue index 82060d2e4ad..82060d2e4ad 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue index 9baa7f286d7..9baa7f286d7 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue index 99bb2080610..99bb2080610 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue index 8988dab85d2..8988dab85d2 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js index 6ffd280e005..6ffd280e005 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js index 273e0a59963..273e0a59963 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js index 026a4069d9b..026a4069d9b 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js index 638e5fd6f60..638e5fd6f60 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js index bd419447a48..bd419447a48 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js index 0e122f598e5..0e122f598e5 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js index 572f6e3cf9d..572f6e3cf9d 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_heading.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js index 71026fd0d65..71026fd0d65 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_heading.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js index 710b807275b..710b807275b 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js index d7716543b53..d770dd18d7f 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -4,7 +4,7 @@ import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_ 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]` + - `[an issue labelled `~"main:broken"`][broken-main-issues]` - Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) - `[this link][]` - `[this link]` diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js index 4829f0f2243..4829f0f2243 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_list_item.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js index 71026fd0d65..71026fd0d65 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_list_item.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js index c004e839821..c004e839821 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js index eff5dbf59f2..eff5dbf59f2 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js index 486d88466b7..486d88466b7 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue index 85a67c087bb..85a67c087bb 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue +++ b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js new file mode 100644 index 00000000000..cd0af59e4fe --- /dev/null +++ b/app/assets/javascripts/tracking/constants.js @@ -0,0 +1 @@ +export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript'; diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js new file mode 100644 index 00000000000..c318029323d --- /dev/null +++ b/app/assets/javascripts/tracking/get_standard_context.js @@ -0,0 +1,14 @@ +import { SNOWPLOW_JS_SOURCE } from './constants'; + +export default function getStandardContext({ extra = {} } = {}) { + const { schema, data = {} } = { ...window.gl?.snowplowStandardContext }; + + return { + schema, + data: { + ...data, + source: SNOWPLOW_JS_SOURCE, + extra: extra || data.extra, + }, + }; +} diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking/index.js index d2e69bc06cf..e0ba7dba97f 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking/index.js @@ -1,16 +1,7 @@ import { omitBy, isUndefined } from 'lodash'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData } from '~/experimentation/utils'; - -const standardContext = { ...window.gl?.snowplowStandardContext }; - -export const STANDARD_CONTEXT = { - schema: standardContext.schema, - data: { - ...(standardContext.data || {}), - source: 'gitlab-javascript', - }, -}; +import getStandardContext from './get_standard_context'; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -25,6 +16,10 @@ const DEFAULT_SNOWPLOW_OPTIONS = { formTracking: false, linkClickTracking: false, pageUnloadTimer: 10, + formTrackingConfig: { + forms: { allow: [] }, + fields: { allow: [] }, + }, }; const addExperimentContext = (opts) => { @@ -40,19 +35,41 @@ const addExperimentContext = (opts) => { }; const createEventPayload = (el, { suffix = '' } = {}) => { - const action = (el.dataset.trackAction || el.dataset.trackEvent) + (suffix || ''); - let value = el.dataset.trackValue || el.value || undefined; - if (el.type === 'checkbox' && !el.checked) value = false; + const { + trackAction, + trackEvent, + trackValue, + trackExtra, + trackExperiment, + trackContext, + trackLabel, + trackProperty, + } = el?.dataset || {}; + + const action = (trackAction || trackEvent) + (suffix || ''); + let value = trackValue || el.value || undefined; + if (el.type === 'checkbox' && !el.checked) value = 0; + + let extra = trackExtra; + + if (extra !== undefined) { + try { + extra = JSON.parse(extra); + } catch (e) { + extra = undefined; + } + } const context = addExperimentContext({ - experiment: el.dataset.trackExperiment, - context: el.dataset.trackContext, + experiment: trackExperiment, + context: trackContext, }); const data = { - label: el.dataset.trackLabel, - property: el.dataset.trackProperty, + label: trackLabel, + property: trackProperty, value, + extra, ...context, }; @@ -84,8 +101,10 @@ const dispatchEvent = (category = document.body.dataset.page, action = 'generic' // eslint-disable-next-line @gitlab/require-i18n-strings if (!category) throw new Error('Tracking: no category provided for tracking.'); - const { label, property, value } = data; - const contexts = [STANDARD_CONTEXT]; + const { label, property, value, extra = {} } = data; + + const standardContext = getStandardContext({ extra }); + const contexts = [standardContext]; if (data.context) { contexts.push(data.context); @@ -156,13 +175,23 @@ export default class Tracking { static enableFormTracking(config, contexts = []) { if (!this.enabled()) return; - if (!config?.forms?.whitelist?.length && !config?.fields?.whitelist?.length) { + if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) { // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Unable to enable form event tracking without whitelist rules.'); + throw new Error('Unable to enable form event tracking without allow rules.'); } - contexts.unshift(STANDARD_CONTEXT); - const enabler = () => window.snowplow('enableFormTracking', config, contexts); + // Ignore default/standard schema + const standardContext = getStandardContext(); + const userProvidedContexts = contexts.filter( + (context) => context.schema !== standardContext.schema, + ); + + const mappedConfig = { + forms: { whitelist: config.forms?.allow || [] }, + fields: { whitelist: config.fields?.allow || [] }, + }; + + const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); if (document.readyState !== 'loading') enabler(); else document.addEventListener('DOMContentLoaded', enabler); @@ -207,11 +236,14 @@ export function initUserTracking() { export function initDefaultTrackers() { if (!Tracking.enabled()) return; + const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; + window.snowplow('enableActivityTracking', 30, 30); // must be after enableActivityTracking - window.snowplow('trackPageView', null, [STANDARD_CONTEXT]); + const standardContext = getStandardContext(); + window.snowplow('trackPageView', null, [standardContext]); - if (window.snowplowOptions.formTracking) window.snowplow('enableFormTracking'); + if (window.snowplowOptions.formTracking) Tracking.enableFormTracking(opts.formTrackingConfig); if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); Tracking.bindDocument(); diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue index a0364089d68..b53aaf46ace 100644 --- a/app/assets/javascripts/user_lists/components/user_list_form.vue +++ b/app/assets/javascripts/user_lists/components/user_list_form.vue @@ -75,7 +75,7 @@ export default { </template> </gl-sprintf> </div> - <div class="gl-flex-fill-1 gl-ml-7"> + <div class="gl-flex-grow-1 gl-ml-7"> <gl-form-group label-for="user-list-name" :label="$options.translations.nameLabel" diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue new file mode 100644 index 00000000000..80be894c689 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/user_lists.vue @@ -0,0 +1,120 @@ +<script> +import { GlBadge, GlButton } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { mapState, mapActions } from 'vuex'; +import EmptyState from '~/feature_flags/components/empty_state.vue'; +import { + buildUrlWithCurrentLocation, + getParameterByName, + historyPushState, +} from '~/lib/utils/common_utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import UserListsTable from './user_lists_table.vue'; + +export default { + components: { + EmptyState, + UserListsTable, + GlBadge, + GlButton, + TablePagination, + }, + inject: { + newUserListPath: { default: '' }, + }, + data() { + return { + page: getParameterByName('page') || '1', + }; + }, + computed: { + ...mapState(['userLists', 'alerts', 'count', 'pageInfo', 'isLoading', 'hasError', 'options']), + canUserRotateToken() { + return this.rotateInstanceIdPath !== ''; + }, + shouldRenderPagination() { + return ( + !this.isLoading && + !this.hasError && + this.userLists.length > 0 && + this.pageInfo.total > this.pageInfo.perPage + ); + }, + shouldShowEmptyState() { + return !this.isLoading && !this.hasError && this.userLists.length === 0; + }, + shouldRenderErrorState() { + return this.hasError && !this.isLoading; + }, + shouldRenderUserLists() { + return !this.isLoading && this.userLists.length > 0 && !this.hasError; + }, + hasNewPath() { + return !isEmpty(this.newUserListPath); + }, + }, + created() { + this.setUserListsOptions({ page: this.page }); + this.fetchUserLists(); + }, + methods: { + ...mapActions(['setUserListsOptions', 'fetchUserLists', 'clearAlert', 'deleteUserList']), + onChangePage(page) { + this.updateUserListsOptions({ + /* URLS parameters are strings, we need to parse to match types */ + page: Number(page).toString(), + }); + }, + updateUserListsOptions(parameters) { + const queryString = objectToQuery(parameters); + + historyPushState(buildUrlWithCurrentLocation(`?${queryString}`)); + this.setUserListsOptions(parameters); + this.fetchUserLists(); + }, + }, +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-flex-direction-column"> + <div class="gl-display-flex gl-flex-direction-column gl-md-display-none!"> + <gl-button v-if="hasNewPath" :href="newUserListPath" variant="confirm"> + {{ s__('UserLists|New user list') }} + </gl-button> + </div> + <div + class="gl-display-flex gl-align-items-baseline gl-flex-direction-column gl-md-flex-direction-row gl-justify-content-space-between gl-mt-6" + > + <div class="gl-display-flex gl-align-items-center"> + <h2 class="gl-font-size-h2 gl-my-0"> + {{ s__('UserLists|User Lists') }} + </h2> + <gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge> + </div> + <div class="gl-display-flex gl-align-items-center gl-justify-content-end"> + <gl-button v-if="hasNewPath" :href="newUserListPath" variant="confirm"> + {{ s__('UserLists|New user list') }} + </gl-button> + </div> + </div> + <empty-state + :alerts="alerts" + :is-loading="isLoading" + :loading-label="s__('UserLists|Loading user lists')" + :error-state="shouldRenderErrorState" + :error-title="s__('UserLists|There was an error fetching the user lists.')" + :empty-state="shouldShowEmptyState" + :empty-title="s__('UserLists|Get started with user lists')" + :empty-description=" + s__('UserLists|User lists allow you to define a set of users to use with Feature Flags.') + " + @dismissAlert="clearAlert" + > + <user-lists-table :user-lists="userLists" @delete="deleteUserList" /> + </empty-state> + </div> + <table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" /> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/user_lists/components/user_lists_table.vue index 765f59228a6..765f59228a6 100644 --- a/app/assets/javascripts/feature_flags/components/user_lists_table.vue +++ b/app/assets/javascripts/user_lists/components/user_lists_table.vue diff --git a/app/assets/javascripts/user_lists/store/index/actions.js b/app/assets/javascripts/user_lists/store/index/actions.js new file mode 100644 index 00000000000..432c576694a --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/actions.js @@ -0,0 +1,38 @@ +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setUserListsOptions = ({ commit }, options) => + commit(types.SET_USER_LISTS_OPTIONS, options); + +export const fetchUserLists = ({ state, dispatch }) => { + dispatch('requestUserLists'); + + return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page) + .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers })) + .catch(() => dispatch('receiveUserListsError')); +}; + +export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS); +export const receiveUserListsSuccess = ({ commit }, response) => + commit(types.RECEIVE_USER_LISTS_SUCCESS, response); +export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR); + +export const deleteUserList = ({ state, dispatch }, list) => { + dispatch('requestDeleteUserList', list); + + return Api.deleteFeatureFlagUserList(state.projectId, list.iid) + .then(() => dispatch('fetchUserLists')) + .catch((error) => + dispatch('receiveDeleteUserListError', { + list, + error: error?.response?.data ?? error, + }), + ); +}; + +export const requestDeleteUserList = ({ commit }, list) => + commit(types.REQUEST_DELETE_USER_LIST, list); + +export const receiveDeleteUserListError = ({ commit }, { error, list }) => + commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list }); +export const clearAlert = ({ commit }, index) => commit(types.RECEIVE_CLEAR_ALERT, index); diff --git a/app/assets/javascripts/user_lists/store/index/index.js b/app/assets/javascripts/user_lists/store/index/index.js new file mode 100644 index 00000000000..9b9df59ed32 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/index.js @@ -0,0 +1,11 @@ +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import createState from './state'; + +export default (initialState) => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/user_lists/store/index/mutation_types.js b/app/assets/javascripts/user_lists/store/index/mutation_types.js new file mode 100644 index 00000000000..5637ed60b7b --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/mutation_types.js @@ -0,0 +1,10 @@ +export const SET_USER_LISTS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS'; + +export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS'; +export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS'; +export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR'; + +export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST'; +export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR'; + +export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT'; diff --git a/app/assets/javascripts/user_lists/store/index/mutations.js b/app/assets/javascripts/user_lists/store/index/mutations.js new file mode 100644 index 00000000000..8e2865dc165 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/mutations.js @@ -0,0 +1,37 @@ +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +export default { + [types.SET_USER_LISTS_OPTIONS](state, options = {}) { + state.options = options; + }, + [types.REQUEST_USER_LISTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_USER_LISTS_SUCCESS](state, { data, headers }) { + state.isLoading = false; + state.hasError = false; + state.userLists = data || []; + + const normalizedHeaders = normalizeHeaders(headers); + const paginationInfo = parseIntPagination(normalizedHeaders); + state.count = paginationInfo?.total ?? state.userLists.length; + state.pageInfo = paginationInfo; + }, + [types.RECEIVE_USER_LISTS_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, + [types.REQUEST_DELETE_USER_LIST](state, list) { + state.userLists = state.userLists.filter((l) => l !== list); + }, + [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) { + state.isLoading = false; + state.hasError = false; + state.alerts = [].concat(error.message); + state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid); + }, + [types.RECEIVE_CLEAR_ALERT](state, index) { + state.alerts.splice(index, 1); + }, +}; diff --git a/app/assets/javascripts/user_lists/store/index/state.js b/app/assets/javascripts/user_lists/store/index/state.js new file mode 100644 index 00000000000..0658d23cffc --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/state.js @@ -0,0 +1,10 @@ +export default ({ projectId }) => ({ + userLists: [], + alerts: [], + count: 0, + pageInfo: {}, + isLoading: true, + hasError: false, + options: {}, + projectId, +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index a5b83167283..386ba2e2d77 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import eventHub from '../../event_hub'; @@ -120,7 +120,11 @@ export default { .then(() => { this.fetchingApprovals = false; }) - .catch(() => createFlash(FETCH_ERROR)); + .catch(() => + createFlash({ + message: FETCH_ERROR, + }), + ); }, methods: { approve() { @@ -131,7 +135,10 @@ export default { this.updateApproval( () => this.service.approveMergeRequest(), - () => createFlash(APPROVE_ERROR), + () => + createFlash({ + message: APPROVE_ERROR, + }), ); }, approveWithAuth(data) { @@ -142,14 +149,19 @@ export default { this.hasApprovalAuthError = true; return; } - createFlash(APPROVE_ERROR); + createFlash({ + message: APPROVE_ERROR, + }); }, ); }, unapprove() { this.updateApproval( () => this.service.unapproveMergeRequest(), - () => createFlash(UNAPPROVE_ERROR), + () => + createFlash({ + message: UNAPPROVE_ERROR, + }), ); }, updateApproval(serviceFn, errFn) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue index 55fa24fb51a..07821b01dd5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -32,9 +32,9 @@ export default { :href="helpPath" :title="__('About this feature')" target="_blank" - class="d-flex-center pl-1" + class="d-flex-center" > - <gl-icon name="question" /> + <gl-icon name="question-o" class="gl-ml-3" /> </gl-link> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 513d88ecab6..671f9cb8e74 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -1,5 +1,5 @@ <script> -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -128,7 +128,9 @@ export default { } }) .catch(() => { - createFlash(errorMessage); + createFlash({ + message: errorMessage, + }); }) .finally(() => { this.actionInProgress = null; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue index 560a68031ef..4dc8bb0562b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue @@ -1,45 +1,46 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; -import { WARNING, DANGER, WARNING_MESSAGE_CLASS, DANGER_MESSAGE_CLASS } from '../constants'; +import { GlLink, GlAlert } from '@gitlab/ui'; export default { - name: 'MrWidgetAlertMessage', + name: 'MRWidgetAlertMessage', components: { + GlAlert, GlLink, - GlIcon, }, props: { type: { type: String, - required: false, - default: DANGER, - validator: (value) => [WARNING, DANGER].includes(value), + required: true, }, helpPath: { type: String, required: false, default: undefined, }, + dismissible: { + type: Boolean, + required: false, + default: false, + }, }, - computed: { - messageClass() { - if (this.type === WARNING) { - return WARNING_MESSAGE_CLASS; - } else if (this.type === DANGER) { - return DANGER_MESSAGE_CLASS; - } - - return ''; + data() { + return { + isDismissed: false, + }; + }, + methods: { + onDismiss() { + this.isDismissed = true; }, }, }; </script> <template> - <div class="gl-m-3 gl-ml-7" :class="messageClass"> + <gl-alert v-if="!isDismissed" :variant="type" :dismissible="dismissible" @dismiss="onDismiss"> <slot></slot> - <gl-link v-if="helpPath" :href="helpPath" target="_blank"> - <gl-icon :size="16" name="question-o" class="align-middle" /> + <gl-link v-if="helpPath" :href="helpPath" target="_blank" class="gl-label-link"> + <slot name="link-content"></slot> </gl-link> - </div> + </gl-alert> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue index c368399ed6f..f8c4ad69e39 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue @@ -51,7 +51,7 @@ export default { <gl-icon :name="iconName" :size="24" /> </span> - <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column gl-md-flex-direction-row"> + <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-md-flex-direction-row"> <slot name="header"></slot> <div> 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 fa46b4b1364..6c162a06161 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 @@ -160,7 +160,7 @@ export default { <div class="ci-widget media"> <template v-if="hasCIError"> <gl-icon name="status_failed" class="gl-text-red-500" :size="24" /> - <p class="gl-flex-fill-1 gl-ml-5 gl-mb-0" data-testid="ci-error-message"> + <p class="gl-flex-grow-1 gl-ml-5 gl-mb-0" data-testid="ci-error-message"> <gl-sprintf :message="$options.errorText"> <template #link="{ content }"> <gl-link :href="mrTroubleshootingDocsPath">{{ content }}</gl-link> @@ -171,7 +171,7 @@ export default { <template v-else-if="!hasPipeline"> <gl-loading-icon size="md" /> <p - class="gl-flex-fill-1 gl-display-flex gl-ml-5 gl-mb-0" + class="gl-flex-grow-1 gl-display-flex gl-ml-5 gl-mb-0" data-testid="monitoring-pipeline-message" > {{ $options.monitoringPipelineText }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index d50d97e3570..9268e426954 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -1,15 +1,14 @@ <script> -import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '../../locale'; export default { i18n: { - removesBranchText: __('%{strongStart}Deletes%{strongEnd} source branch'), + removesBranchText: __('The source branch will be deleted'), tooltipTitle: __('A user with write access to the source branch selected this option'), }, components: { GlIcon, - GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -20,11 +19,7 @@ export default { <template> <p v-once class="mr-info-list gl-ml-7 gl-pb-5 gl-mb-0"> <span class="status-text"> - <gl-sprintf :message="$options.i18n.removesBranchText"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> + {{ $options.i18n.removesBranchText }} </span> <gl-icon v-gl-tooltip.hover 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 6331a7d8388..68ffca9cd68 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 @@ -14,8 +14,9 @@ export default { <div class="media-body space-children"> <span class="bold"> {{ - s__(`mrWidget|Pipeline blocked. -The pipeline for this merge request requires a manual action to proceed`) + s__( + `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, + ) }} </span> </div> 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 751f8082e1a..07de525b1fa 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 @@ -22,7 +22,13 @@ import { __ } from '~/locale'; import SmartInterval from '~/smart_interval'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MergeRequest from '../../../merge_request'; -import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants'; +import { + AUTO_MERGE_STRATEGIES, + DANGER, + CONFIRM, + WARNING, + MT_MERGE_STRATEGY, +} from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import MergeRequestStore from '../../stores/mr_widget_store'; @@ -191,7 +197,7 @@ export default { }, squashIsSelected() { if (this.glFeatures.mergeRequestWidgetGraphql) { - return this.squashReadOnly ? this.state.squashOnMerge : this.state.squash; + return this.isSquashReadOnly ? this.state.squashOnMerge : this.state.squash; } return this.mr.squashIsSelected; @@ -223,15 +229,11 @@ export default { return PIPELINE_SUCCESS_STATE; }, mergeButtonVariant() { - if (this.status === PIPELINE_FAILED_STATE) { + if (this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) { return DANGER; } - if (this.status === PIPELINE_PENDING_STATE) { - return INFO; - } - - return PIPELINE_SUCCESS_STATE; + return CONFIRM; }, iconClass() { if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) { @@ -290,6 +292,9 @@ export default { shaMismatchLink() { return this.mr.mergeRequestDiffsPath; }, + showDangerMessageForMergeTrain() { + return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed; + }, }, mounted() { if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -503,7 +508,7 @@ export default { v-if="shouldShowMergeImmediatelyDropdown" v-gl-tooltip.hover.focus="__('Select merge moment')" :disabled="isMergeButtonDisabled" - variant="info" + :variant="mergeButtonVariant" data-qa-selector="merge_moment_dropdown" toggle-class="btn-icon js-merge-moment" > @@ -583,6 +588,14 @@ export default { </gl-sprintf> </span> </div> + + <div + v-if="showDangerMessageForMergeTrain" + class="gl-mt-5 gl-text-gray-500" + data-testid="failed-pipeline-merge-train-text" + > + {{ __('The latest pipeline for this merge request did not complete successfully.') }} + </div> </div> </div> <merge-train-helper-text 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 f0c624c5d8d..a1eb77479bd 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 @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; import $ from 'jquery'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import MergeRequest from '~/merge_request'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -76,7 +76,9 @@ export default { }, ) { if (errors?.length) { - createFlash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); return; } @@ -121,11 +123,18 @@ export default { }, }, }) => { - createFlash(__('The merge request can now be merged.'), 'notice'); + createFlash({ + message: __('The merge request can now be merged.'), + type: 'notice', + }); $('.merge-request .detail-page-description .title').text(title); }, ) - .catch(() => createFlash(__('Something went wrong. Please try again.'))) + .catch(() => + createFlash({ + message: __('Something went wrong. Please try again.'), + }), + ) .finally(() => { this.isMakingRequest = false; }); @@ -144,7 +153,9 @@ export default { }) .catch(() => { this.isMakingRequest = false; - createFlash(__('Something went wrong. Please try again.')); + createFlash({ + message: __('Something went wrong. Please try again.'), + }); }); } }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue index 25f339b362f..2ba945a3ecf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue @@ -107,7 +107,7 @@ export default { <template #header> <div data-testid="terraform-header-text" - class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column" + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column" > <p v-if="validPlanCountText" class="gl-m-0"> <gl-sprintf :message="validPlanCountText"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue index 25e3dae92e8..427ab0842ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -71,8 +71,8 @@ export default { <gl-icon :name="iconType" :size="16" data-testid="change-type-icon" /> </span> - <div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column flex-md-row gl-pl-3"> - <div class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column gl-pr-3"> + <div class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column flex-md-row gl-pl-3"> + <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-pr-3"> <p class="gl-mb-3 gl-line-height-normal"> <gl-sprintf :message="reportHeaderText"> <template #name> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 822fb58db60..d067e531fad 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -4,9 +4,7 @@ export const SUCCESS = 'success'; export const WARNING = 'warning'; export const DANGER = 'danger'; export const INFO = 'info'; - -export const WARNING_MESSAGE_CLASS = 'warning_message'; -export const DANGER_MESSAGE_CLASS = 'danger_message'; +export const CONFIRM = 'confirm'; export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds'; export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds'; 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 0cfb059b0ce..e9dcf494099 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 @@ -10,7 +10,7 @@ import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; -import { deprecatedCreateFlash as createFlash } from '../flash'; +import createFlash from '../flash'; import { setFaviconOverlay } from '../lib/utils/favicon'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue'; @@ -198,6 +198,9 @@ export default { formattedHumanAccess() { return (this.mr.humanAccess || '').toLowerCase(); }, + hasAlerts() { + return this.mr.mergeError || this.showMergePipelineForkWarning; + }, }, watch: { state(newVal, oldVal) { @@ -214,7 +217,9 @@ export default { this.initWidget(data); }) .catch(() => - createFlash(__('Unable to load the merge request widget. Try reloading the page.')), + createFlash({ + message: __('Unable to load the merge request widget. Try reloading the page.'), + }), ); }, beforeDestroy() { @@ -295,7 +300,11 @@ export default { cb.call(null, data); } }) - .catch(() => createFlash(__('Something went wrong. Please try again.'))); + .catch(() => + createFlash({ + message: __('Something went wrong. Please try again.'), + }), + ); }, setFaviconHelper() { if (this.mr.ciStatusFaviconPath) { @@ -349,11 +358,11 @@ export default { .catch(() => this.throwDeploymentsError()); }, throwDeploymentsError() { - createFlash( - __( + createFlash({ + message: __( 'Something went wrong while fetching the environments for this merge request. Please try again.', ), - ); + }); }, fetchActionsContent() { this.service @@ -367,7 +376,11 @@ export default { Project.initRefSwitcher(); } }) - .catch(() => createFlash(__('Something went wrong. Please try again.'))); + .catch(() => + createFlash({ + message: __('Something went wrong. Please try again.'), + }), + ); }, handleNotification(data) { if (data.ci_status === this.mr.ciStatus) return; @@ -432,7 +445,12 @@ export default { </script> <template> <div v-if="isLoaded" class="mr-state-widget gl-mt-3"> - <mr-widget-header :mr="mr" /> + <header class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100"> + <mr-widget-alert-message v-if="shouldRenderCollaborationStatus" type="info"> + {{ s__('mrWidget|Members who can merge are allowed to add commits.') }} + </mr-widget-alert-message> + <mr-widget-header :mr="mr" /> + </header> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" data-testid="mr-suggest-pipeline" @@ -456,10 +474,32 @@ export default { :service="service" /> <div class="mr-section-container mr-widget-workflow"> + <div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container"> + <mr-widget-alert-message v-if="mr.mergeError" type="danger" dismissible> + <span v-safe-html="mergeError"></span> + </mr-widget-alert-message> + <mr-widget-alert-message + v-if="showMergePipelineForkWarning" + type="warning" + :help-path="mr.mergeRequestPipelinesHelpPath" + > + {{ + s__( + 'mrWidget|If the last pipeline ran in the fork project, it may be inaccurate. Before merge, we advise running a pipeline in this project.', + ) + }} + <template #link-content> + {{ __('Learn more') }} + </template> + </mr-widget-alert-message> + </div> <!-- <extensions-container :mr="mr" /> --> <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" :base-path="mr.codeclimate.base_path" + :head-path="mr.codeclimate.head_path" + :head-blob-path="mr.headBlobPath" + :base-blob-path="mr.baseBlobPath" :codequality-reports-path="mr.codequalityReportsPath" :codequality-help-path="mr.codequalityHelpPath" /> @@ -492,34 +532,12 @@ export default { <component :is="componentName" :mr="mr" :service="service" /> <div class="mr-widget-info"> - <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> - <p> - {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} - </p> - </section> - <mr-widget-related-links v-if="shouldRenderRelatedLinks" :state="mr.state" :related-links="mr.relatedLinks" /> - <mr-widget-alert-message - v-if="showMergePipelineForkWarning" - type="warning" - :help-path="mr.mergeRequestPipelinesHelpPath" - > - {{ - s__( - 'mrWidget|If the last pipeline ran in the fork project, it may be inaccurate. Before merge, we advise running a pipeline in this project.', - ) - }} - </mr-widget-alert-message> - - <mr-widget-alert-message v-if="mr.mergeError" type="danger"> - <span v-safe-html="mergeError"></span> - </mr-widget-alert-message> - <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" /> </div> </div> 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 9f85140bab8..4cc2f423d73 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 @@ -44,7 +44,6 @@ export default class MergeRequestStore { this.sourceBranch = data.source_branch; this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; - this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path; this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merged_commit_sha; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 3905ce2596c..d595c49f9aa 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -24,7 +24,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants'; import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql'; import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql'; -import alertQuery from '../graphql/queries/alert_details.query.graphql'; +import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql'; import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql'; import AlertMetrics from './alert_metrics.vue'; import AlertSidebar from './alert_sidebar.vue'; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index ef31106b709..b7544a4a5d0 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -167,10 +167,10 @@ export default { variables: { iid: this.alert.iid, assigneeUsernames: [this.isActive(assignees) ? '' : assignees], - projectPath: this.projectPath, + fullPath: this.projectPath, }, }) - .then(({ data: { alertSetAssignees: { errors } = [] } = {} } = {}) => { + .then(({ data: { issuableSetAssignees: { errors } = [] } = {} } = {}) => { this.hideDropdown(); if (errors[0]) { diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index 8715eb99518..ce90a759cee 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -1,11 +1,12 @@ <script> -import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; import { PAGE_CONFIG } from '../../constants'; import AlertStatus from '../alert_status.vue'; export default { components: { GlIcon, + GlButton, GlLoadingIcon, GlTooltip, GlSprintf, @@ -96,16 +97,15 @@ export default { class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" > {{ s__('AlertManagement|Status') }} - <a + <gl-button v-if="isEditable" - ref="editButton" - class="btn-link" - href="#" + class="gl-text-black-normal!" + variant="link" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ s__('AlertManagement|Edit') }} - </a> + </gl-button> </p> <alert-status diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue index a2a4046ab81..322ea64eb7e 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -4,7 +4,7 @@ import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.muta import { s__ } from '~/locale'; import Todo from '~/sidebar/components/todo_toggle/todo.vue'; import createAlertTodoMutation from '../../graphql/mutations/alert_todo_create.mutation.graphql'; -import alertQuery from '../../graphql/queries/alert_details.query.graphql'; +import alertQuery from '../../graphql/queries/alert_sidebar_details.query.graphql'; export default { i18n: { diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql index 63d952a4857..33091f1ba5e 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql @@ -1,18 +1,18 @@ #import "~/graphql_shared/fragments/alert_note.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" -mutation alertSetAssignees($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { - alertSetAssignees( - input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } +mutation alertSetAssignees($fullPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) { + issuableSetAssignees: alertSetAssignees( + input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } ) { errors - alert { + issuable: alert { iid assignees { nodes { - username - name - avatarUrl - webUrl + ...User + ...UserAvailability } } notes { diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql index dc961b5eb90..c860bf0915c 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql @@ -1,10 +1,16 @@ -#import "../fragments/alert_detail_item.fragment.graphql" +#import "~/graphql_shared/fragments/alert_detail_item.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" mutation alertTodoCreate($projectPath: ID!, $iid: String!) { alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) { errors alert { ...AlertDetailItem + assignees { + nodes { + ...User + } + } } } } diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql new file mode 100644 index 00000000000..da5f1a00e11 --- /dev/null +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/alert_detail_item.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" + +query alertDetails($fullPath: ID!, $alertId: String) { + project(fullPath: $fullPath) { + alertManagementAlerts(iid: $alertId) { + nodes { + ...AlertDetailItem + assignees { + nodes { + ...User + } + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue index a74e9d97143..ba4279fe3e3 100644 --- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue +++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue @@ -1,11 +1,12 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { reduce } from 'lodash'; import { capitalizeFirstCharacter, convertToSentenceCase, splitCamelCase, } from '~/lib/utils/text_utility'; +import { isSafeURL } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; @@ -30,6 +31,7 @@ const allowedFields = [ export default { components: { + GlLink, GlLoadingIcon, GlTable, }, @@ -94,6 +96,9 @@ export default { isAllowed(fieldName) { return allowedFields.includes(fieldName); }, + isValidLink(value) { + return typeof value === 'string' && isSafeURL(value); + }, }, }; </script> @@ -109,5 +114,11 @@ export default { <template #table-busy> <gl-loading-icon size="lg" color="dark" class="gl-mt-5" /> </template> + <template #cell(value)="{ item: { value } }"> + <span v-if="!isValidLink(value)">{{ value }}</span> + <gl-link v-else :href="value" target="_blank"> + {{ value }} + </gl-link> + </template> </gl-table> </template> diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue index 1f293b2150f..16ca2df02c0 100644 --- a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue +++ b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue @@ -22,7 +22,12 @@ export default { </script> <template> - <gl-alert v-if="hasManagedPrometheus" variant="warning" class="my-2"> + <gl-alert + v-if="hasManagedPrometheus" + variant="warning" + class="my-2" + data-testid="alerts-deprecation-warning" + > <gl-sprintf :message="$options.i18n.alertsDeprecationText"> <template #link="{ content }"> <gl-link diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 08d3e163257..e6d9a38d1fb 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -173,7 +173,7 @@ export default { v-for="awardList in groupedAwards" :key="awardList.name" v-gl-tooltip.viewport - class="gl-mr-3" + class="gl-mr-3 gl-my-2" :class="awardList.classes" :title="awardList.title" data-testid="award-button" @@ -184,10 +184,10 @@ export default { </template> <span class="js-counter">{{ awardList.list.length }}</span> </gl-button> - <div v-if="canAwardEmoji" class="award-menu-holder"> + <div v-if="canAwardEmoji" class="award-menu-holder gl-my-2"> <emoji-picker v-if="glFeatures.improvedEmojiPicker" - :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]" + :toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]" @click="handleAward" @shown="setIsMenuOpen(true)" @hidden="setIsMenuOpen(false)" 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 2cb1b6a195f..9775a9119c6 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 @@ -21,7 +21,7 @@ export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ { value: FILTER_CURRENT, text: __(FILTER_CURRENT) }, ]); -export const DEFAULT_LABELS = [{ value: 'No label', text: __('No label') }]; // eslint-disable-line @gitlab/require-i18n-strings +export const DEFAULT_LABELS = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings 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 3e7feb91b27..5ab287150f2 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 @@ -12,7 +12,7 @@ import { import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { SortDirection } from './constants'; @@ -211,7 +211,9 @@ export default { .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; - createFlash(__('An error occurred while parsing recent searches')); + createFlash({ + message: __('An error occurred while parsing recent searches'), + }); // Gracefully fail to empty array return []; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index e5c8d29e09b..37436de907f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -2,7 +2,7 @@ import { isEmpty, uniqWith, isEqual } from 'lodash'; import AccessorUtilities from '~/lib/utils/accessor'; import { queryToObject } from '~/lib/utils/url_utility'; -import { MAX_RECENT_TOKENS_SIZE } from './constants'; +import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants'; /** * Strips enclosing quotations from a string if it has one. @@ -23,7 +23,7 @@ export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); export const uniqueTokens = (tokens) => { const knownTokens = []; return tokens.reduce((uniques, token) => { - if (typeof token === 'object' && token.type !== 'filtered-search-term') { + if (typeof token === 'object' && token.type !== FILTERED_SEARCH_TERM) { const tokenString = `${token.type}${token.value.operator}${token.value.data}`; if (!knownTokens.includes(tokenString)) { uniques.push(token); @@ -86,21 +86,37 @@ export function processFilters(filters) { }, {}); } +function filteredSearchQueryParam(filter) { + return filter + .map(({ value }) => value) + .join(' ') + .trim(); +} + /** * This function takes a filter object and maps it into a query object. Example filter: - * { myFilterName: { value: 'foo', operator: '=' } } + * { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] } * gets translated into: - * { myFilterName: 'foo', 'not[myFilterName]': null } + * { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' } * @param {Object} filters - * @param {Object.myFilterName} a single filter value or an array of filters + * @param {Object} filters.myFilterName a single filter value or an array of filters + * @param {Object} options + * @param {Object} [options.filteredSearchTermKey] if set, 'filtered-search-term' filters are assigned to this key, 'search' is suggested * @return {Object} query object with both filter name and not-name with values */ -export function filterToQueryObject(filters = {}) { +export function filterToQueryObject(filters = {}, options = {}) { + const { filteredSearchTermKey } = options; + return Object.keys(filters).reduce((memo, key) => { const filter = filters[key]; + if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) { + return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) }; + } + let selected; let unselected; + if (Array.isArray(filter)) { selected = filter.filter((item) => item.operator === '=').map((item) => item.value); unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value); @@ -125,7 +141,7 @@ export function filterToQueryObject(filters = {}) { * and returns the operator with it depending on the filter name * @param {String} filterName from url * @return {Object} - * @return {Object.filterName} extracted filtern ame + * @return {Object.filterName} extracted filter name * @return {Object.operator} `=` or `!=` */ function extractNameAndOperator(filterName) { @@ -138,21 +154,52 @@ function extractNameAndOperator(filterName) { } /** + * Gathers search term as values + * @param {String|Array} value + * @returns {Array} List of search terms split by word + */ +function filteredSearchTermValue(value) { + const values = Array.isArray(value) ? value : [value]; + return values + .filter((term) => term) + .join(' ') + .split(' ') + .map((term) => ({ value: term })); +} + +/** * This function takes a URL query string and maps it into a filter object. Example query string: * '?myFilterName=foo' * gets translated into: * { myFilterName: { value: 'foo', operator: '=' } } - * @param {String} query URL quert string, e.g. from `window.location.search` + * @param {String} query URL query string, e.g. from `window.location.search` + * @param {Object} options + * @param {Object} options + * @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested + * @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped + * @param {Boolean} [options.legacySpacesDecode] if set, plus symbols (+) are not encoded as spaces. `false` is suggested * @return {Object} filter object with filter names and their values */ -export function urlQueryToFilter(query = '') { - const filters = queryToObject(query, { gatherArrays: true }); +export function urlQueryToFilter(query = '', options = {}) { + const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options; + + const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode }); return Object.keys(filters).reduce((memo, key) => { const value = filters[key]; if (!value) { return memo; } + if (key === filteredSearchTermKey) { + return { + ...memo, + [FILTERED_SEARCH_TERM]: filteredSearchTermValue(value), + }; + } + const { filterName, operator } = extractNameAndOperator(key); + if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) { + return memo; + } let previousValues = []; if (Array.isArray(memo[filterName])) { previousValues = memo[filterName]; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js index 4dfc61f1fff..f4317ba90a2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -24,7 +24,9 @@ export function fetchBranches({ commit, state }, search = '') { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_BRANCHES_ERROR, status); - createFlash(__('Failed to load branches. Please try again.')); + createFlash({ + message: __('Failed to load branches. Please try again.'), + }); }); } @@ -41,7 +43,9 @@ export const fetchMilestones = ({ commit, state }, search_title = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_MILESTONES_ERROR, status); - createFlash(__('Failed to load milestones. Please try again.')); + createFlash({ + message: __('Failed to load milestones. Please try again.'), + }); }); }; @@ -57,7 +61,9 @@ export const fetchLabels = ({ commit, state }, search = '') => { .catch(({ response }) => { const { status } = response; commit(types.RECEIVE_LABELS_ERROR, status); - createFlash(__('Failed to load labels. Please try again.')); + createFlash({ + message: __('Failed to load labels. Please try again.'), + }); }); }; @@ -80,7 +86,9 @@ function fetchUser(options = {}) { .catch(({ response }) => { const { status } = response; commit(`RECEIVE_${action}_ERROR`, status); - createFlash(errorMessage); + createFlash({ + message: errorMessage, + }); }); } diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index aeb698a3adb..2e7b3e149b2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -1,25 +1,18 @@ <script> -import { - GlFilteredSearchToken, - GlAvatar, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_LABEL_ANY } from '../constants'; + +import BaseToken from './base_token.vue'; export default { components: { - GlFilteredSearchToken, + BaseToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { config: { @@ -30,37 +23,28 @@ export default { type: Object, required: true, }, + active: { + type: Boolean, + required: true, + }, }, data() { return { authors: this.config.initialAuthors || [], defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], - loading: true, + preloadedAuthors: this.config.preloadedAuthors || [], + loading: false, }; }, - computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeAuthor() { - return this.authors.find((author) => author.username.toLowerCase() === this.currentValue); - }, - activeAuthorAvatar() { - return this.avatarUrl(this.activeAuthor); + methods: { + getActiveAuthor(authors, currentValue) { + return authors.find((author) => author.username.toLowerCase() === currentValue); }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.authors.length) { - this.fetchAuthorBySearchTerm(this.value.data); - } - }, + getAvatarUrl(author) { + return author.avatarUrl || author.avatar_url; }, - }, - methods: { fetchAuthorBySearchTerm(searchTerm) { + this.loading = true; const fetchPromise = this.config.fetchPath ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) : this.config.fetchAuthors(searchTerm); @@ -72,63 +56,56 @@ export default { // return response differently. this.authors = Array.isArray(res) ? res : res.data; }) - .catch(() => createFlash(__('There was a problem fetching users.'))) + .catch(() => + createFlash({ + message: __('There was a problem fetching users.'), + }), + ) .finally(() => { this.loading = false; }); }, - avatarUrl(author) { - return author.avatarUrl || author.avatar_url; - }, - searchAuthors: debounce(function debouncedSearch({ data }) { - this.fetchAuthorBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token - :config="config" - v-bind="{ ...$props, ...$attrs }" - v-on="$listeners" - @input="searchAuthors" + <base-token + :token-config="config" + :token-value="value" + :token-active="active" + :tokens-list-loading="loading" + :token-values="authors" + :fn-active-token-value="getActiveAuthor" + :default-token-values="defaultAuthors" + :preloaded-token-values="preloadedAuthors" + :recent-token-values-storage-key="config.recentTokenValuesStorageKey" + @fetch-token-values="fetchAuthorBySearchTerm" > - <template #view="{ inputValue }"> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> <gl-avatar - v-if="activeAuthor" + v-if="activeTokenValue" :size="16" - :src="activeAuthorAvatar" + :src="getAvatarUrl(activeTokenValue)" shape="circle" class="gl-mr-2" /> - <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span> + <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span> </template> - <template #suggestions> + <template #token-values-list="{ tokenValues }"> <gl-filtered-search-suggestion - v-for="author in defaultAuthors" - :key="author.value" - :value="author.value" + v-for="author in tokenValues" + :key="author.username" + :value="author.username" > - {{ author.text }} - </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultAuthors.length" /> - <gl-loading-icon v-if="loading" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="author in authors" - :key="author.username" - :value="author.username" - > - <div class="d-flex"> - <gl-avatar :size="32" :src="avatarUrl(author)" /> - <div> - <div>{{ author.name }}</div> - <div>@{{ author.username }}</div> - </div> + <div class="gl-display-flex"> + <gl-avatar :size="32" :src="getAvatarUrl(author)" /> + <div> + <div>{{ author.name }}</div> + <div>@{{ author.username }}</div> </div> - </gl-filtered-search-suggestion> - </template> + </div> + </gl-filtered-search-suggestion> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 6ebc5431012..fb6b9e4bc0d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -48,6 +48,11 @@ export default { required: false, default: () => [], }, + preloadedTokenValues: { + type: Array, + required: false, + default: () => [], + }, recentTokenValuesStorageKey: { type: String, required: false, @@ -78,7 +83,10 @@ export default { return Boolean(this.recentTokenValuesStorageKey); }, recentTokenIds() { - return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name); + return this.recentTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]); + }, + preloadedTokenIds() { + return this.preloadedTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]); }, currentTokenValue() { if (this.fnCurrentTokenValue) { @@ -98,7 +106,9 @@ export default { return this.searchKey ? this.tokenValues : this.tokenValues.filter( - (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]), + (tokenValue) => + !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) && + !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]), ); }, }, @@ -120,7 +130,15 @@ export default { }, DEBOUNCE_DELAY); }, handleTokenValueSelected(activeTokenValue) { - if (this.isRecentTokenValuesEnabled) { + // Make sure that; + // 1. Recently used values feature is enabled + // 2. User has actually selected a value + // 3. Selected value is not part of preloaded list. + if ( + this.isRecentTokenValuesEnabled && + activeTokenValue && + !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier]) + ) { setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue); } }, @@ -158,6 +176,11 @@ export default { <slot name="token-values-list" :token-values="recentTokenValues"></slot> <gl-dropdown-divider /> </template> + <slot + v-if="preloadedTokenValues.length" + name="token-values-list" + :token-values="preloadedTokenValues" + ></slot> <gl-loading-icon v-if="tokensListLoading" /> <template v-else> <slot name="token-values-list" :token-values="availableTokenValues"></slot> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index f2f4787d80b..9ba7f3d1a1d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -7,7 +7,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; @@ -65,7 +65,11 @@ export default { .then((res) => { this.emojis = Array.isArray(res) ? res : res.data; }) - .catch(() => createFlash(__('There was a problem fetching emojis.'))) + .catch(() => + createFlash({ + message: __('There was a problem fetching emojis.'), + }), + ) .finally(() => { this.loading = false; }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index 1450807b11d..d21fa9a344a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -11,6 +11,7 @@ import { __ } from '~/locale'; import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; export default { + separator: '::&', components: { GlDropdownDivider, GlFilteredSearchToken, @@ -34,17 +35,35 @@ export default { }; }, computed: { + idProperty() { + return this.config.idProperty || 'iid'; + }, currentValue() { - return Number(this.value.data); + const epicIid = Number(this.value.data); + if (epicIid) { + return epicIid; + } + return this.value.data; }, defaultEpics() { return this.config.defaultEpics || DEFAULT_NONE_ANY; }, - idProperty() { - return this.config.idProperty || 'id'; - }, activeEpic() { - return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); + if (this.currentValue && this.epics.length) { + // Check if current value is an epic ID. + if (typeof this.currentValue === 'number') { + return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); + } + + // Current value is a string. + const [groupPath, idProperty] = this.currentValue?.split('::&'); + return this.epics.find( + (epic) => + epic.group_full_path === groupPath && + epic[this.idProperty] === parseInt(idProperty, 10), + ); + } + return null; }, }, watch: { @@ -58,10 +77,10 @@ export default { }, }, methods: { - fetchEpicsBySearchTerm(searchTerm = '') { + fetchEpicsBySearchTerm({ epicPath = '', search = '' }) { this.loading = true; this.config - .fetchEpics(searchTerm) + .fetchEpics({ epicPath, search }) .then((response) => { this.epics = Array.isArray(response) ? response : response.data; }) @@ -71,11 +90,21 @@ export default { }); }, searchEpics: debounce(function debouncedSearch({ data }) { - this.fetchEpicsBySearchTerm(data); + let epicPath = this.activeEpic?.web_url; + + // When user visits the page with token value already included in filters + // We don't have any information about selected token except for its + // group path and iid joined by separator, so we need to manually + // compose epic path from it. + if (data.includes(this.$options.separator)) { + const [groupPath, epicIid] = data.split(this.$options.separator); + epicPath = `/groups/${groupPath}/-/epics/${epicIid}`; + } + this.fetchEpicsBySearchTerm({ epicPath, search: data }); }, DEBOUNCE_DELAY), getEpicDisplayText(epic) { - return `${epic.title}::&${epic[this.idProperty]}`; + return `${epic.title}${this.$options.separator}${epic.iid}`; }, }, }; @@ -104,8 +133,8 @@ export default { <template v-else> <gl-filtered-search-suggestion v-for="epic in epics" - :key="epic[idProperty]" - :value="String(epic[idProperty])" + :key="epic.id" + :value="`${epic.group_full_path}::&${epic[idProperty]}`" > {{ epic.title }} </gl-filtered-search-suggestion> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 76b005772ec..20b8cbfe933 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -1,27 +1,20 @@ <script> -import { - GlToken, - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { DEFAULT_LABELS, DEBOUNCE_DELAY } from '../constants'; +import { DEFAULT_LABELS } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; +import BaseToken from './base_token.vue'; + export default { components: { + BaseToken, GlToken, - GlFilteredSearchToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { config: { @@ -32,43 +25,24 @@ export default { type: Object, required: true, }, + active: { + type: Boolean, + required: true, + }, }, data() { return { labels: this.config.initialLabels || [], defaultLabels: this.config.defaultLabels || DEFAULT_LABELS, - loading: true, + loading: false, }; }, - computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeLabel() { - return this.labels.find( - (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue), + methods: { + getActiveLabel(labels, currentValue) { + return labels.find( + (label) => this.getLabelName(label).toLowerCase() === stripQuotes(currentValue), ); }, - containerStyle() { - if (this.activeLabel) { - const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel); - - return { backgroundColor: color, color: textColor }; - } - return {}; - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.labels.length) { - this.fetchLabelBySearchTerm(this.value.data); - } - }, - }, - }, - methods: { /** * There's an inconsistency between private and public API * for labels where label name is included in a different @@ -84,6 +58,16 @@ export default { getLabelName(label) { return label.name || label.title; }, + getContainerStyle(activeLabel) { + if (activeLabel) { + const { color: backgroundColor, textColor: color } = convertObjectPropsToCamelCase( + activeLabel, + ); + + return { backgroundColor, color }; + } + return {}; + }, fetchLabelBySearchTerm(searchTerm) { this.loading = true; this.config @@ -94,55 +78,56 @@ export default { // return response differently. this.labels = Array.isArray(res) ? res : res.data; }) - .catch(() => createFlash(__('There was a problem fetching labels.'))) + .catch(() => + createFlash({ + message: __('There was a problem fetching labels.'), + }), + ) .finally(() => { this.loading = false; }); }, - searchLabels: debounce(function debouncedSearch({ data }) { - if (!this.loading) this.fetchLabelBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token - :config="config" - v-bind="{ ...$props, ...$attrs }" - v-on="$listeners" - @input="searchLabels" + <base-token + :token-config="config" + :token-value="value" + :token-active="active" + :tokens-list-loading="loading" + :token-values="labels" + :fn-active-token-value="getActiveLabel" + :default-token-values="defaultLabels" + :recent-token-values-storage-key="config.recentTokenValuesStorageKey" + @fetch-token-values="fetchLabelBySearchTerm" > - <template #view-token="{ inputValue, cssClasses, listeners }"> - <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners" - >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token + <template + #view-token="{ viewTokenProps: { inputValue, cssClasses, listeners, activeTokenValue } }" + > + <gl-token + variant="search-value" + :class="cssClasses" + :style="getContainerStyle(activeTokenValue)" + v-on="listeners" + >~{{ activeTokenValue ? getLabelName(activeTokenValue) : inputValue }}</gl-token > </template> - <template #suggestions> + <template #token-values-list="{ tokenValues }"> <gl-filtered-search-suggestion - v-for="label in defaultLabels" - :key="label.value" - :value="label.value" + v-for="label in tokenValues" + :key="label.id" + :value="getLabelName(label)" > - {{ label.text }} + <div class="gl-display-flex gl-align-items-center"> + <span + :style="{ backgroundColor: label.color }" + class="gl-display-inline-block mr-2 p-2" + ></span> + <div>{{ getLabelName(label) }}</div> + </div> </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultLabels.length" /> - <gl-loading-icon v-if="loading" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="label in labels" - :key="label.id" - :value="getLabelName(label)" - > - <div class="gl-display-flex gl-align-items-center"> - <span - :style="{ backgroundColor: label.color }" - class="gl-display-inline-block mr-2 p-2" - ></span> - <div>{{ getLabelName(label) }}</div> - </div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/incidents/utils.js b/app/assets/javascripts/vue_shared/components/incidents/utils.js new file mode 100644 index 00000000000..bcb578a6ba6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incidents/utils.js @@ -0,0 +1,3 @@ +import { noop } from 'lodash'; + +export const isValidSlaDueAt = noop; diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 3006ba83f98..b2f077f5329 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -60,7 +60,7 @@ export default { }, methods: { avatarUrlTitle(assignee) { - return sprintf(__('Avatar for %{assigneeName}'), { + return sprintf(__('Assigned to %{assigneeName}'), { assigneeName: assignee.name, }); }, diff --git a/app/assets/javascripts/vue_shared/components/registry/details_row.vue b/app/assets/javascripts/vue_shared/components/registry/details_row.vue index 2e245fadead..72e06b45561 100644 --- a/app/assets/javascripts/vue_shared/components/registry/details_row.vue +++ b/app/assets/javascripts/vue_shared/components/registry/details_row.vue @@ -8,7 +8,8 @@ export default { props: { icon: { type: String, - required: true, + required: false, + default: null, }, padding: { type: String, @@ -34,7 +35,7 @@ export default { class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all" :class="[padding, borderClass]" > - <gl-icon :name="icon" class="gl-mr-4" /> + <gl-icon v-if="icon" :name="icon" class="gl-mr-4" /> <span> <slot></slot> </span> diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index b9e916bc199..933a215112b 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -62,7 +62,7 @@ export default { <slot name="left-action"></slot> </div> <div - class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-fill-1" + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" > <div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1"> <div @@ -81,7 +81,7 @@ export default { </div> <div v-if="$slots['left-secondary']" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-fill-1" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" > <slot name="left-secondary"></slot> </div> @@ -114,7 +114,7 @@ export default { <div class="gl-w-7"></div> <div v-if="isDetailsShown" - class="gl-display-flex gl-flex-direction-column gl-flex-fill-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" + class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" > <div v-for="(row, detailIndex) in detailsSlots" diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 0825c3a76ea..767a108dde5 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -109,7 +109,7 @@ export default { <div class="gl-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100"> <gl-filtered-search v-model="internalFilter" - class="gl-mr-4 gl-flex-fill-1" + class="gl-mr-4 gl-flex-grow-1" :placeholder="__('Filter results')" :available-tokens="tokens" @submit="submitSearch" diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js new file mode 100644 index 00000000000..46361c6eb32 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js @@ -0,0 +1,49 @@ +import { s__, sprintf } from '~/locale'; + +export const EXPERIMENT_NAME = 'ci_runner_templates'; + +export const README_URL = + 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; + +export const CF_BASE_URL = + 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?'; + +export const TEMPLATES_BASE_URL = 'https://gl-public-templates.s3.amazonaws.com/cfn/experimental/'; + +export const EASY_BUTTONS = [ + { + stackName: 'linux-docker-nonspot', + templateName: + 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml', + description: s__( + 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.', + ), + }, + { + stackName: 'linux-docker-spotonly', + templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml', + description: sprintf( + s__( + 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot.', + ), + { percentage: '100%' }, + ), + }, + { + stackName: 'win2019-shell-non-spot', + templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml', + description: s__( + 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.', + ), + }, + { + stackName: 'win2019-shell-spot', + templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml', + description: sprintf( + s__( + 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. %{percentage} spot.', + ), + { percentage: '100%' }, + ), + }, +]; diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue new file mode 100644 index 00000000000..e3e3b9abc3c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue @@ -0,0 +1,43 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import RunnerAwsDeploymentsModal from './runner_aws_deployments_modal.vue'; + +export default { + components: { + GlButton, + RunnerAwsDeploymentsModal, + }, + directives: { + GlModalDirective, + }, + modalId: 'runner-aws-deployments-modal', + i18n: { + buttonText: s__('Runners|Deploy GitLab Runner in AWS'), + }, + data() { + return { + opened: false, + }; + }, + methods: { + onClick() { + this.opened = true; + }, + }, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal-directive="$options.modalId" + class="gl-mt-4" + data-testid="show-modal-button" + variant="confirm" + @click="onClick" + > + {{ $options.i18n.buttonText }} + </gl-button> + <runner-aws-deployments-modal v-if="opened" :modal-id="$options.modalId" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue new file mode 100644 index 00000000000..f21dea468cb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue @@ -0,0 +1,97 @@ +<script> +import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import { + EXPERIMENT_NAME, + README_URL, + CF_BASE_URL, + TEMPLATES_BASE_URL, + EASY_BUTTONS, +} from './constants'; + +export default { + components: { + GlModal, + GlSprintf, + GlLink, + }, + props: { + modalId: { + type: String, + required: true, + }, + }, + methods: { + easyButtonUrl(easyButton) { + const params = { + templateURL: TEMPLATES_BASE_URL + easyButton.templateName, + stackName: easyButton.stackName, + param_3GITLABRunnerInstanceURL: getBaseURL(), + }; + return CF_BASE_URL + objectToQuery(params); + }, + trackCiRunnerTemplatesClick(stackName) { + const tracking = new ExperimentTracking(EXPERIMENT_NAME); + tracking.event(`template_clicked_${stackName}`); + }, + }, + i18n: { + title: s__('Runners|Deploy GitLab Runner in AWS'), + instructions: s__( + 'Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.', + ), + dont_see_what_you_are_looking_for: s__( + "Rnners|Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}.", + ), + note: s__( + 'Runners|If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.', + ), + }, + closeButton: { + text: __('Cancel'), + attributes: [{ variant: 'default' }], + }, + readmeUrl: README_URL, + easyButtons: EASY_BUTTONS, +}; +</script> +<template> + <gl-modal + :modal-id="modalId" + :title="$options.i18n.title" + :action-secondary="$options.closeButton" + size="sm" + > + <p>{{ $options.i18n.instructions }}</p> + <ul class="gl-list-style-none gl-p-0 gl-mb-0"> + <li v-for="easyButton in $options.easyButtons" :key="easyButton.templateName"> + <gl-link + :href="easyButtonUrl(easyButton)" + target="_blank" + class="gl-display-flex gl-font-weight-bold" + @click="trackCiRunnerTemplatesClick(easyButton.stackName)" + > + <img + :title="easyButton.stackName" + :alt="easyButton.stackName" + src="/assets/aws-cloud-formation.png" + width="46" + height="46" + class="gl-mt-2 gl-mr-5 gl-mb-6" + /> + {{ easyButton.description }} + </gl-link> + </li> + </ul> + <p> + <gl-sprintf :message="$options.i18n.dont_see_what_you_are_looking_for"> + <template #link="{ content }"> + <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p class="gl-font-sm gl-mb-0">{{ $options.i18n.note }}</p> + </gl-modal> +</template> 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 1f70644eb2c..580e1668f41 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 @@ -225,7 +225,7 @@ export default { <template v-if="!instructionsEmpty"> <div class="gl-display-flex"> <pre - class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" + class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" data-testid="binary-instructions" >{{ instructions.installInstructions }}</pre > @@ -241,7 +241,7 @@ export default { <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5> <div class="gl-display-flex"> <pre - class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" + class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" data-testid="register-command" >{{ instructions.registerInstructions }}</pre > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue deleted file mode 100644 index 88c4d132d61..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ /dev/null @@ -1,191 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import $ from 'jquery'; -import LabelsSelect from '~/labels_select'; -import { __ } from '~/locale'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; - -import { DropdownVariant } from '../labels_select_vue/constants'; -import DropdownButton from './dropdown_button.vue'; -import DropdownCreateLabel from './dropdown_create_label.vue'; -import DropdownFooter from './dropdown_footer.vue'; -import DropdownHeader from './dropdown_header.vue'; -import DropdownSearchInput from './dropdown_search_input.vue'; -import DropdownTitle from './dropdown_title.vue'; -import DropdownValue from './dropdown_value.vue'; -import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; - -export default { - DropdownVariant, - components: { - DropdownTitle, - DropdownValue, - DropdownValueCollapsed, - DropdownButton, - DropdownHiddenInput, - DropdownHeader, - DropdownSearchInput, - DropdownFooter, - DropdownCreateLabel, - GlLoadingIcon, - }, - props: { - showCreate: { - type: Boolean, - required: false, - default: false, - }, - isProject: { - type: Boolean, - required: false, - default: false, - }, - abilityName: { - type: String, - required: true, - }, - context: { - type: Object, - required: true, - }, - namespace: { - type: String, - required: false, - default: '', - }, - updatePath: { - type: String, - required: false, - default: '', - }, - labelsPath: { - type: String, - required: true, - }, - labelsWebUrl: { - type: String, - required: false, - default: '', - }, - labelFilterBasePath: { - type: String, - required: false, - default: '', - }, - canEdit: { - type: Boolean, - required: false, - default: false, - }, - enableScopedLabels: { - type: Boolean, - required: false, - default: false, - }, - variant: { - type: String, - required: false, - default: DropdownVariant.Sidebar, - }, - }, - computed: { - hiddenInputName() { - return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]'; - }, - createLabelTitle() { - if (this.isProject) { - return __('Create project label'); - } - - return __('Create group label'); - }, - manageLabelsTitle() { - if (this.isProject) { - return __('Manage project labels'); - } - - return __('Manage group labels'); - }, - }, - mounted() { - this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, { - handleClick: this.handleClick, - }); - $(this.$refs.dropdown).on('hidden.gl.dropdown', this.handleDropdownHidden); - }, - methods: { - handleClick(label) { - this.$emit('onLabelClick', label); - }, - handleCollapsedValueClick() { - this.$emit('toggleCollapse'); - }, - handleDropdownHidden() { - this.$emit('onDropdownClose'); - }, - }, -}; -</script> - -<template> - <div class="block labels js-labels-block"> - <dropdown-value-collapsed - v-if="showCreate && variant === $options.DropdownVariant.Sidebar" - :labels="context.labels" - @onValueClick="handleCollapsedValueClick" - /> - <dropdown-title :can-edit="canEdit" /> - <dropdown-value - :labels="context.labels" - :label-filter-base-path="labelFilterBasePath" - :enable-scoped-labels="enableScopedLabels" - > - <slot></slot> - </dropdown-value> - <div v-if="canEdit" class="selectbox js-selectbox" style="display: none"> - <dropdown-hidden-input - v-for="label in context.labels" - :key="label.id" - :name="hiddenInputName" - :value="label.id" - /> - <div ref="dropdown" class="dropdown"> - <dropdown-button - :ability-name="abilityName" - :field-name="hiddenInputName" - :update-path="updatePath" - :labels-path="labelsPath" - :namespace="namespace" - :labels="context.labels" - :show-extra-options="!showCreate || variant !== $options.DropdownVariant.Sidebar" - :enable-scoped-labels="enableScopedLabels" - /> - <div - class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable" - > - <div class="dropdown-page-one"> - <dropdown-header v-if="showCreate && variant === $options.DropdownVariant.Sidebar" /> - <dropdown-search-input /> - <div class="dropdown-content" data-qa-selector="labels_dropdown_content"></div> - <div class="dropdown-loading"> - <gl-loading-icon - class="gl-display-flex gl-justify-content-center gl-align-items-center gl-h-full" - /> - </div> - <dropdown-footer - v-if="showCreate" - :labels-web-url="labelsWebUrl" - :create-label-title="createLabelTitle" - :manage-labels-title="manageLabelsTitle" - /> - </div> - <dropdown-create-label - v-if="showCreate" - :is-project="isProject" - :header-title="createLabelTitle" - /> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue deleted file mode 100644 index 94cf1f84ec3..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ /dev/null @@ -1,86 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { __, s__, sprintf } from '~/locale'; - -export default { - components: { - GlIcon, - }, - props: { - abilityName: { - type: String, - required: true, - }, - fieldName: { - type: String, - required: true, - }, - updatePath: { - type: String, - required: true, - }, - labelsPath: { - type: String, - required: true, - }, - namespace: { - type: String, - required: true, - }, - labels: { - type: Array, - required: true, - }, - showExtraOptions: { - type: Boolean, - required: true, - }, - enableScopedLabels: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - dropdownToggleText() { - if (this.labels.length === 0) { - return __('Label'); - } - - if (this.labels.length > 1) { - return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { - firstLabelName: this.labels[0].title, - remainingLabelCount: this.labels.length - 1, - }); - } - - return this.labels[0].title; - }, - }, -}; -</script> - -<template> - <!-- eslint-disable @gitlab/vue-no-data-toggle --> - <button - ref="dropdownButton" - :class="{ 'js-extra-options': showExtraOptions }" - :data-ability-name="abilityName" - :data-field-name="fieldName" - :data-issue-update="updatePath" - :data-labels="labelsPath" - :data-namespace-path="namespace" - :data-show-any="showExtraOptions" - :data-scoped-labels="enableScopedLabels" - type="button" - class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" - data-toggle="dropdown" - > - <span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span> - <gl-icon - name="chevron-down" - class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" - :size="16" - /> - </button> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue deleted file mode 100644 index 795f16f4efc..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue +++ /dev/null @@ -1,92 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - headerTitle: { - type: String, - required: false, - default: () => __('Create new label'), - }, - }, - created() { - const rawLabelsColors = gon.suggested_label_colors; - this.suggestedColors = Object.keys(rawLabelsColors).map((colorCode) => ({ - colorCode, - title: rawLabelsColors[colorCode], - })); - }, -}; -</script> - -<template> - <div class="dropdown-page-two dropdown-new-label"> - <div - class="dropdown-title gl-display-flex gl-justify-content-space-between gl-align-items-center" - > - <gl-button - :aria-label="__('Go back')" - category="tertiary" - class="dropdown-menu-back" - icon="arrow-left" - size="small" - /> - {{ headerTitle }} - <gl-button - :aria-label="__('Close')" - category="tertiary" - class="dropdown-menu-close" - icon="close" - size="small" - /> - </div> - <div class="dropdown-content"> - <div class="dropdown-labels-error js-label-error"></div> - <input - id="new_label_name" - :placeholder="__('Name new label')" - type="text" - class="default-dropdown-input" - /> - <div class="suggest-colors suggest-colors-dropdown"> - <a - v-for="(color, index) in suggestedColors" - :key="index" - v-gl-tooltip - :data-color="color.colorCode" - :style="{ - backgroundColor: color.colorCode, - }" - :title="color.title" - href="#" - > - - </a> - </div> - <div class="dropdown-label-color-input"> - <div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div> - <input - id="new_label_color" - :placeholder="__('Assign custom color like #FF0000')" - type="text" - class="default-dropdown-input" - /> - </div> - <div class="clearfix"> - <gl-button category="secondary" class="float-left js-new-label-btn disabled"> - {{ __('Create') }} - </gl-button> - <gl-button category="secondary" class="float-right js-cancel-label-btn"> - {{ __('Cancel') }} - </gl-button> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue deleted file mode 100644 index ebbd8d119b5..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue +++ /dev/null @@ -1,37 +0,0 @@ -<script> -import { __ } from '~/locale'; - -export default { - props: { - labelsWebUrl: { - type: String, - required: true, - }, - createLabelTitle: { - type: String, - required: false, - default: () => __('Create new label'), - }, - manageLabelsTitle: { - type: String, - required: false, - default: () => __('Manage labels'), - }, - }, -}; -</script> - -<template> - <div class="dropdown-footer"> - <ul class="dropdown-footer-list"> - <li> - <a href="#" class="dropdown-toggle-page"> {{ createLabelTitle }} </a> - </li> - <li> - <a :href="labelsWebUrl" data-is-link="true" class="dropdown-external-link"> - {{ manageLabelsTitle }} - </a> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue deleted file mode 100644 index 4f505b9e678..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue +++ /dev/null @@ -1,22 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlIcon, - }, -}; -</script> - -<template> - <div class="dropdown-title gl-display-flex gl-justify-content-center"> - <span class="gl-ml-auto">{{ __('Assign labels') }}</span> - <button - :aria-label="__('Close')" - type="button" - class="dropdown-title-button dropdown-menu-close gl-ml-auto" - > - <gl-icon name="close" class="dropdown-menu-close-icon" /> - </button> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue deleted file mode 100644 index 6222dfc5853..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; - -export default { - components: { - GlIcon, - }, -}; -</script> - -<template> - <div class="dropdown-input"> - <input - :placeholder="__('Search')" - autocomplete="off" - class="dropdown-input-field" - type="search" - /> - <gl-icon - name="search" - class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-300 gl-pointer-events-none" - /> - <gl-icon - name="close" - class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-500" - /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue deleted file mode 100644 index 91cf5d6bef5..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue +++ /dev/null @@ -1,31 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; - -export default { - components: { - GlLoadingIcon, - }, - props: { - canEdit: { - type: Boolean, - required: true, - }, - }, -}; -</script> - -<template> - <div class="title hide-collapsed gl-mb-3"> - {{ __('Labels') }} - <template v-if="canEdit"> - <gl-loading-icon inline class="align-text-top block-loading" /> - <button - type="button" - class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle" - data-qa-selector="labels_edit_button" - > - {{ __('Edit') }} - </button> - </template> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue deleted file mode 100644 index 71d7069dd57..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { GlLabel } from '@gitlab/ui'; -import { isScopedLabel } from '~/lib/utils/common_utils'; - -export default { - components: { - GlLabel, - }, - props: { - labels: { - type: Array, - required: true, - }, - labelFilterBasePath: { - type: String, - required: true, - }, - enableScopedLabels: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - isEmpty() { - return this.labels.length === 0; - }, - }, - methods: { - labelFilterUrl(label) { - return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`; - }, - scopedLabelsDescription({ description = '' }) { - return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`; - }, - showScopedLabels(label) { - return this.enableScopedLabels && isScopedLabel(label); - }, - }, -}; -</script> - -<template> - <div - :class="{ - 'has-labels': !isEmpty, - }" - class="hide-collapsed value issuable-show-labels js-value" - > - <span v-if="isEmpty" class="text-secondary"> - <slot>{{ __('None') }}</slot> - </span> - - <template v-for="label in labels" v-else> - <gl-label - :key="label.id" - :target="labelFilterUrl(label)" - :background-color="label.color" - :title="label.title" - :description="label.description" - :scoped="showScopedLabels(label)" - /> - </template> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue index 5d1663bc1fd..813de528c0b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue @@ -23,17 +23,18 @@ export default { </script> <template> - <div class="title hide-collapsed gl-mb-3"> + <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900"> {{ __('Labels') }} <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" inline /> <gl-button variant="link" - class="float-right js-sidebar-dropdown-toggle" + class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle" data-qa-selector="labels_edit_button" @click="toggleDropdownContents" - >{{ __('Edit') }}</gl-button > + {{ __('Edit') }} + </gl-button> </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue index 122250d1ce7..122250d1ce7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue 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 a4462895f6a..87af3ffc52c 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 @@ -5,13 +5,12 @@ import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; - import { DropdownVariant } from './constants'; import DropdownButton from './dropdown_button.vue'; import DropdownContents from './dropdown_contents.vue'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; +import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import labelsSelectModule from './store'; Vue.use(Vuex); @@ -61,6 +60,11 @@ export default { required: false, default: () => [], }, + hideCollapsedView: { + type: Boolean, + required: false, + default: false, + }, labelsSelectInProgress: { type: Boolean, required: false, @@ -294,6 +298,7 @@ export default { > <template v-if="isDropdownVariantSidebar"> <dropdown-value-collapsed + v-if="!hideCollapsedView" ref="dropdownButtonCollapsed" :labels="selectedLabels" @onValueClick="handleCollapsedValueClick" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js new file mode 100644 index 00000000000..00c54313292 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js @@ -0,0 +1,5 @@ +export const DropdownVariant = { + Sidebar: 'sidebar', + Standalone: 'standalone', + Embedded: 'embedded', +}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue new file mode 100644 index 00000000000..60111210f5d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue @@ -0,0 +1,42 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + components: { + GlButton, + GlIcon, + }, + computed: { + ...mapGetters([ + 'dropdownButtonText', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), + }, + methods: { + ...mapActions(['toggleDropdownContents']), + handleButtonClick(e) { + if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { + this.toggleDropdownContents(); + } + + if (this.isDropdownVariantStandalone) { + e.stopPropagation(); + } + }, + }, +}; +</script> + +<template> + <gl-button + class="labels-select-dropdown-button js-dropdown-button w-100 text-left" + @click="handleButtonClick" + > + <span class="dropdown-toggle-text gl-pointer-events-none flex-fill"> + {{ dropdownButtonText }} + </span> + <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" /> + </gl-button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue new file mode 100644 index 00000000000..d80b66fd9be --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -0,0 +1,44 @@ +<script> +import { mapGetters, mapState } from 'vuex'; + +import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; +import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; + +export default { + components: { + DropdownContentsLabelsView, + DropdownContentsCreateView, + }, + props: { + renderOnTop: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['showDropdownContentsCreateView']), + ...mapGetters(['isDropdownVariantSidebar']), + dropdownContentsView() { + if (this.showDropdownContentsCreateView) { + return 'dropdown-contents-create-view'; + } + return 'dropdown-contents-labels-view'; + }, + directionStyle() { + const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem'; + return this.renderOnTop ? { bottom } : {}; + }, + }, +}; +</script> + +<template> + <div + class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute" + data-qa-selector="labels_dropdown_content" + :style="directionStyle" + > + <component :is="dropdownContentsView" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue new file mode 100644 index 00000000000..f8cc981ba3d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -0,0 +1,119 @@ +<script> +import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; + +export default { + components: { + GlButton, + GlFormInput, + GlLink, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + data() { + return { + labelTitle: '', + selectedColor: '', + }; + }, + computed: { + ...mapState(['labelsCreateTitle', 'labelCreateInProgress']), + disableCreate() { + return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress; + }, + suggestedColors() { + const colorsMap = gon.suggested_label_colors; + return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); + }, + }, + methods: { + ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']), + getColorCode(color) { + return Object.keys(color).pop(); + }, + getColorName(color) { + return Object.values(color).pop(); + }, + handleColorClick(color) { + this.selectedColor = this.getColorCode(color); + }, + handleCreateClick() { + this.createLabel({ + title: this.labelTitle, + color: this.selectedColor, + }); + }, + }, +}; +</script> + +<template> + <div class="labels-select-contents-create js-labels-create"> + <div class="dropdown-title d-flex align-items-center pt-0 pb-2"> + <gl-button + :aria-label="__('Go back')" + variant="link" + size="small" + class="js-btn-back dropdown-header-button p-0" + icon="arrow-left" + @click="toggleDropdownContentsCreateView" + /> + <span class="flex-grow-1">{{ labelsCreateTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button p-0" + icon="close" + @click="toggleDropdownContents" + /> + </div> + <div class="dropdown-input"> + <gl-form-input + v-model.trim="labelTitle" + :placeholder="__('Name new label')" + :autofocus="true" + /> + </div> + <div class="dropdown-content px-2"> + <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2"> + <gl-link + v-for="(color, index) in suggestedColors" + :key="index" + v-gl-tooltip:tooltipcontainer + :style="{ backgroundColor: getColorCode(color) }" + :title="getColorName(color)" + @click.prevent="handleColorClick(color)" + /> + </div> + <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" + 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"> + <gl-button + :disabled="disableCreate" + category="primary" + variant="success" + class="float-left d-flex align-items-center" + @click="handleCreateClick" + > + <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" /> + {{ __('Create') }} + </gl-button> + <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView"> + {{ __('Cancel') }} + </gl-button> + </div> + </div> +</template> 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 new file mode 100644 index 00000000000..86788a84260 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -0,0 +1,221 @@ +<script> +import { + GlIntersectionObserver, + GlLoadingIcon, + GlButton, + GlSearchBoxByType, + GlLink, +} from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { mapState, mapGetters, mapActions } from 'vuex'; + +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +import LabelItem from './label_item.vue'; + +export default { + components: { + GlIntersectionObserver, + GlLoadingIcon, + GlButton, + GlSearchBoxByType, + GlLink, + LabelItem, + }, + data() { + return { + searchKey: '', + currentHighlightItem: -1, + }; + }, + computed: { + ...mapState([ + 'allowLabelCreate', + 'allowMultiselect', + 'labelsManagePath', + 'labels', + 'labelsFetchInProgress', + 'labelsListTitle', + 'footerCreateLabelTitle', + 'footerManageLabelTitle', + ]), + ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), + visibleLabels() { + if (this.searchKey) { + return fuzzaldrinPlus.filter(this.labels, this.searchKey, { + key: ['title'], + }); + } + return this.labels; + }, + showNoMatchingResultsMessage() { + return Boolean(this.searchKey) && this.visibleLabels.length === 0; + }, + }, + watch: { + searchKey(value) { + // When there is search string present + // and there are matching results, + // highlight first item by default. + if (value && this.visibleLabels.length) { + this.currentHighlightItem = 0; + } + }, + }, + methods: { + ...mapActions([ + 'toggleDropdownContents', + 'toggleDropdownContentsCreateView', + 'fetchLabels', + 'receiveLabelsSuccess', + 'updateSelectedLabels', + 'toggleDropdownContents', + ]), + isLabelSelected(label) { + return this.selectedLabelsList.includes(label.id); + }, + /** + * This method scrolls item from dropdown into + * the view if it is off the viewable area of the + * container. + */ + scrollIntoViewIfNeeded() { + const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused'); + + if (highlightedLabel) { + const container = this.$refs.labelsListContainer.getBoundingClientRect(); + const label = highlightedLabel.getBoundingClientRect(); + + if (label.bottom > container.bottom) { + this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom; + } else if (label.top < container.top) { + this.$refs.labelsListContainer.scrollTop -= container.top - label.top; + } + } + }, + handleComponentAppear() { + // We can avoid putting `catch` block here + // as failure is handled within actions.js already. + return this.fetchLabels().then(() => { + this.$refs.searchInput.focusInput(); + }); + }, + /** + * We want to remove loaded labels to ensure component + * fetches fresh set of labels every time when shown. + */ + handleComponentDisappear() { + this.receiveLabelsSuccess([]); + }, + handleCreateLabelClick() { + this.receiveLabelsSuccess([]); + this.toggleDropdownContentsCreateView(); + }, + /** + * This method enables keyboard navigation support for + * the dropdown. + */ + handleKeyDown(e) { + if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) { + this.currentHighlightItem -= 1; + } else if ( + e.keyCode === DOWN_KEY_CODE && + this.currentHighlightItem < this.visibleLabels.length - 1 + ) { + this.currentHighlightItem += 1; + } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { + this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); + this.searchKey = ''; + } else if (e.keyCode === ESC_KEY_CODE) { + this.toggleDropdownContents(); + } + + if (e.keyCode !== ESC_KEY_CODE) { + // Scroll the list only after highlighting + // styles are rendered completely. + this.$nextTick(() => { + this.scrollIntoViewIfNeeded(); + }); + } + }, + handleLabelClick(label) { + this.updateSelectedLabels([label]); + if (!this.allowMultiselect) this.toggleDropdownContents(); + }, + }, +}; +</script> + +<template> + <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear"> + <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-title" + > + <span class="flex-grow-1">{{ labelsListTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button gl-p-0!" + icon="close" + @click="toggleDropdownContents" + /> + </div> + <div class="dropdown-input" @click.stop="() => {}"> + <gl-search-box-by-type + ref="searchInput" + v-model="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + /> + </div> + <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center w-100 h-100" + size="md" + /> + <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word"> + <label-item + v-for="(label, index) in visibleLabels" + :key="label.id" + :label="label" + :is-label-set="label.set" + :highlight="index === currentHighlightItem" + @clickLabel="handleLabelClick(label)" + /> + <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> + {{ __('No matching results') }} + </li> + </ul> + </div> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-footer" + data-testid="dropdown-footer" + > + <ul class="list-unstyled"> + <li v-if="allowLabelCreate"> + <gl-link + class="gl-display-flex w-100 flex-row text-break-word label-item" + @click="handleCreateLabelClick" + > + {{ footerCreateLabelTitle }} + </gl-link> + </li> + <li> + <gl-link + :href="labelsManagePath" + class="gl-display-flex flex-row text-break-word label-item" + > + {{ footerManageLabelTitle }} + </gl-link> + </li> + </ul> + </div> + </div> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue new file mode 100644 index 00000000000..5d1663bc1fd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue @@ -0,0 +1,39 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; + +export default { + components: { + GlButton, + GlLoadingIcon, + }, + props: { + labelsSelectInProgress: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState(['allowLabelEdit', 'labelsFetchInProgress']), + }, + methods: { + ...mapActions(['toggleDropdownContents']), + }, +}; +</script> + +<template> + <div class="title hide-collapsed gl-mb-3"> + {{ __('Labels') }} + <template v-if="allowLabelEdit"> + <gl-loading-icon v-show="labelsSelectInProgress" inline /> + <gl-button + variant="link" + class="float-right js-sidebar-dropdown-toggle" + data-qa-selector="labels_edit_button" + @click="toggleDropdownContents" + >{{ __('Edit') }}</gl-button + > + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue new file mode 100644 index 00000000000..46ccb9470e5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -0,0 +1,67 @@ +<script> +import { GlLabel } from '@gitlab/ui'; +import { mapState } from 'vuex'; + +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlLabel, + }, + props: { + disableLabels: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState([ + 'selectedLabels', + 'allowLabelRemove', + 'allowScopedLabels', + 'labelsFilterBasePath', + 'labelsFilterParam', + ]), + }, + methods: { + labelFilterUrl(label) { + return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent( + label.title, + )}`; + }, + scopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <div + :class="{ + 'has-labels': selectedLabels.length, + }" + class="hide-collapsed value issuable-show-labels js-value" + > + <span v-if="!selectedLabels.length" class="text-secondary"> + <slot></slot> + </span> + <template v-for="label in selectedLabels" v-else> + <gl-label + :key="label.id" + data-qa-selector="selected_label_content" + :data-qa-label-name="label.title" + :title="label.title" + :description="label.description" + :background-color="label.color" + :target="labelFilterUrl(label)" + :scoped="scopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disableLabels" + tooltip-placement="top" + @close="$emit('onLabelRemove', label.id)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue new file mode 100644 index 00000000000..e8fdf4bb0c2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue @@ -0,0 +1,82 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; + +export default { + functional: true, + props: { + label: { + type: Object, + required: true, + }, + isLabelSet: { + type: Boolean, + required: true, + }, + highlight: { + type: Boolean, + required: false, + default: false, + }, + }, + render(h, { props, listeners }) { + const { label, highlight, isLabelSet } = props; + + const labelColorBox = h('span', { + class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3', + style: { + backgroundColor: label.color, + }, + attrs: { + 'data-testid': 'label-color-box', + }, + }); + + const checkedIcon = h(GlIcon, { + class: { + 'gl-mr-3 gl-flex-shrink-0': true, + hidden: !isLabelSet, + }, + props: { + name: 'mobile-issue-close', + }, + }); + + const noIcon = h('span', { + class: { + 'gl-mr-5 gl-pr-3': true, + hidden: isLabelSet, + }, + attrs: { + 'data-testid': 'no-icon', + }, + }); + + const labelTitle = h('span', label.title); + + const labelLink = h( + GlLink, + { + class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal', + on: { + click: () => { + listeners.clickLabel(label); + }, + }, + }, + [noIcon, checkedIcon, labelColorBox, labelTitle], + ); + + return h( + 'li', + { + class: { + 'gl-display-block': true, + 'gl-text-left': true, + 'is-focused': highlight, + }, + }, + [labelLink], + ); + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue new file mode 100644 index 00000000000..bf30e3cfac5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -0,0 +1,327 @@ +<script> +import $ from 'jquery'; +import Vue from 'vue'; +import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; +import { isInViewport } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; + +import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; + +import { DropdownVariant } from './constants'; +import DropdownButton from './dropdown_button.vue'; +import DropdownContents from './dropdown_contents.vue'; +import DropdownTitle from './dropdown_title.vue'; +import DropdownValue from './dropdown_value.vue'; +import labelsSelectModule from './store'; + +Vue.use(Vuex); + +export default { + store: new Vuex.Store(labelsSelectModule()), + components: { + DropdownTitle, + DropdownValue, + DropdownButton, + DropdownContents, + DropdownValueCollapsed, + }, + props: { + allowLabelRemove: { + type: Boolean, + required: false, + default: false, + }, + allowLabelEdit: { + type: Boolean, + required: false, + default: false, + }, + allowLabelCreate: { + type: Boolean, + required: false, + default: false, + }, + allowMultiselect: { + type: Boolean, + required: false, + default: false, + }, + allowScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + variant: { + type: String, + required: false, + default: DropdownVariant.Sidebar, + }, + selectedLabels: { + type: Array, + required: false, + default: () => [], + }, + labelsSelectInProgress: { + type: Boolean, + required: false, + default: false, + }, + labelsFetchPath: { + type: String, + required: false, + default: '', + }, + labelsManagePath: { + type: String, + required: false, + default: '', + }, + labelsFilterBasePath: { + type: String, + required: false, + default: '', + }, + labelsFilterParam: { + type: String, + required: false, + default: 'label_name', + }, + dropdownButtonText: { + type: String, + required: false, + default: __('Label'), + }, + labelsListTitle: { + type: String, + required: false, + default: __('Assign labels'), + }, + labelsCreateTitle: { + type: String, + required: false, + default: __('Create group label'), + }, + footerCreateLabelTitle: { + type: String, + required: false, + default: __('Create group label'), + }, + footerManageLabelTitle: { + type: String, + required: false, + default: __('Manage group labels'), + }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + contentIsOnViewport: true, + }; + }, + computed: { + ...mapState(['showDropdownButton', 'showDropdownContents']), + ...mapGetters([ + 'isDropdownVariantSidebar', + 'isDropdownVariantStandalone', + 'isDropdownVariantEmbedded', + ]), + dropdownButtonVisible() { + return this.isDropdownVariantSidebar ? this.showDropdownButton : true; + }, + }, + watch: { + selectedLabels(selectedLabels) { + this.setInitialState({ + selectedLabels, + }); + }, + showDropdownContents(showDropdownContents) { + this.setContentIsOnViewport(showDropdownContents); + }, + isEditing(newVal) { + if (newVal) { + this.toggleDropdownContents(); + } + }, + }, + mounted() { + this.setInitialState({ + variant: this.variant, + allowLabelRemove: this.allowLabelRemove, + allowLabelEdit: this.allowLabelEdit, + allowLabelCreate: this.allowLabelCreate, + allowMultiselect: this.allowMultiselect, + allowScopedLabels: this.allowScopedLabels, + dropdownButtonText: this.dropdownButtonText, + selectedLabels: this.selectedLabels, + labelsFetchPath: this.labelsFetchPath, + labelsManagePath: this.labelsManagePath, + labelsFilterBasePath: this.labelsFilterBasePath, + labelsFilterParam: this.labelsFilterParam, + labelsListTitle: this.labelsListTitle, + labelsCreateTitle: this.labelsCreateTitle, + footerCreateLabelTitle: this.footerCreateLabelTitle, + footerManageLabelTitle: this.footerManageLabelTitle, + }); + + this.$store.subscribeAction({ + after: this.handleVuexActionDispatch, + }); + + document.addEventListener('mousedown', this.handleDocumentMousedown); + document.addEventListener('click', this.handleDocumentClick); + }, + beforeDestroy() { + document.removeEventListener('mousedown', this.handleDocumentMousedown); + document.removeEventListener('click', this.handleDocumentClick); + }, + methods: { + ...mapActions(['setInitialState', 'toggleDropdownContents']), + /** + * This method differentiates between + * dispatched actions and calls necessary method. + */ + handleVuexActionDispatch(action, state) { + if ( + action.type === 'toggleDropdownContents' && + !state.showDropdownButton && + !state.showDropdownContents + ) { + let filterFn = (label) => label.touched; + if (this.isDropdownVariantEmbedded) { + filterFn = (label) => label.set; + } + this.handleDropdownClose(state.labels.filter(filterFn)); + } + }, + /** + * This method stores a mousedown event's target. + * Required by the click listener because the click + * event itself has no reference to this element. + */ + handleDocumentMousedown({ target }) { + this.mousedownTarget = target; + }, + /** + * This method listens for document-wide click event + * and toggle dropdown if user clicks anywhere outside + * the dropdown while dropdown is visible. + */ + handleDocumentClick({ target }) { + // We also perform the toggle exception check for the + // last mousedown event's target to avoid hiding the + // box when the mousedown happened inside the box and + // only the mouseup did not. + if ( + this.showDropdownContents && + !this.preventDropdownToggleOnClick(target) && + !this.preventDropdownToggleOnClick(this.mousedownTarget) + ) { + this.toggleDropdownContents(); + } + }, + /** + * This method checks whether a given click target + * should prevent the dropdown from being toggled. + */ + preventDropdownToggleOnClick(target) { + // This approach of element detection is needed + // as the dropdown wrapper is not using `GlDropdown` as + // it will also require us to use `BDropdownForm` + // which is yet to be implemented in GitLab UI. + const hasExceptionClass = [ + 'js-dropdown-button', + 'js-btn-cancel-create', + 'js-sidebar-dropdown-toggle', + ].some( + (className) => + target?.classList.contains(className) || + target?.parentElement?.classList.contains(className), + ); + + const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some( + (className) => $(target).parents(className).length, + ); + + const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target); + + const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target); + + return ( + hasExceptionClass || + hasExceptionParent || + isInDropdownButtonCollapsed || + isInDropdownContents + ); + }, + handleDropdownClose(labels) { + // Only emit label updates if there are any labels to update + // on UI. + if (labels.length) this.$emit('updateSelectedLabels', labels); + this.$emit('onDropdownClose'); + }, + handleCollapsedValueClick() { + this.$emit('toggleCollapse'); + }, + setContentIsOnViewport(showDropdownContents) { + if (!showDropdownContents) { + this.contentIsOnViewport = true; + + return; + } + + this.$nextTick(() => { + if (this.$refs.dropdownContents) { + this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el); + } + }); + }, + }, +}; +</script> + +<template> + <div + class="labels-select-wrapper position-relative" + :class="{ + 'is-standalone': isDropdownVariantStandalone, + 'is-embedded': isDropdownVariantEmbedded, + }" + > + <template v-if="isDropdownVariantSidebar"> + <dropdown-value-collapsed + ref="dropdownButtonCollapsed" + :labels="selectedLabels" + @onValueClick="handleCollapsedValueClick" + /> + <dropdown-title + :allow-label-edit="allowLabelEdit" + :labels-select-in-progress="labelsSelectInProgress" + /> + <dropdown-value + :disable-labels="labelsSelectInProgress" + @onLabelRemove="$emit('onLabelRemove', $event)" + > + <slot></slot> + </dropdown-value> + <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> + <dropdown-contents + v-show="dropdownButtonVisible && showDropdownContents" + ref="dropdownContents" + :render-on-top="!contentIsOnViewport" + /> + </template> + <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> + <dropdown-button v-show="dropdownButtonVisible" /> + <dropdown-contents + v-if="dropdownButtonVisible && showDropdownContents" + ref="dropdownContents" + :render-on-top="!contentIsOnViewport" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js new file mode 100644 index 00000000000..89f96ab916b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js @@ -0,0 +1,58 @@ +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import * as types from './mutation_types'; + +export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props); + +export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON); +export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS); + +export const toggleDropdownContentsCreateView = ({ commit }) => + commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW); + +export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS); +export const receiveLabelsSuccess = ({ commit }, labels) => + commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); +export const receiveLabelsFailure = ({ commit }) => { + commit(types.RECEIVE_SET_LABELS_FAILURE); + flash(__('Error fetching labels.')); +}; +export const fetchLabels = ({ state, dispatch }) => { + dispatch('requestLabels'); + return axios + .get(state.labelsFetchPath) + .then(({ data }) => { + dispatch('receiveLabelsSuccess', data); + }) + .catch(() => dispatch('receiveLabelsFailure')); +}; + +export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL); +export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS); +export const receiveCreateLabelFailure = ({ commit }) => { + commit(types.RECEIVE_CREATE_LABEL_FAILURE); + flash(__('Error creating label.')); +}; +export const createLabel = ({ state, dispatch }, label) => { + dispatch('requestCreateLabel'); + axios + .post(state.labelsManagePath, { + label, + }) + .then(({ data }) => { + if (data.id) { + dispatch('receiveCreateLabelSuccess'); + dispatch('toggleDropdownContentsCreateView'); + } else { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Error Creating Label'); + } + }) + .catch(() => { + dispatch('receiveCreateLabelFailure'); + }); +}; + +export const updateSelectedLabels = ({ commit }, labels) => + commit(types.UPDATE_SELECTED_LABELS, { labels }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js new file mode 100644 index 00000000000..d14f96720b7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js @@ -0,0 +1,52 @@ +import { __, s__, sprintf } from '~/locale'; +import { DropdownVariant } from '../constants'; + +/** + * Returns string representing current labels + * selection on dropdown button. + * + * @param {object} state + */ +export const dropdownButtonText = (state, getters) => { + const selectedLabels = getters.isDropdownVariantSidebar + ? state.labels.filter((label) => label.set) + : state.selectedLabels; + + if (!selectedLabels.length) { + return state.dropdownButtonText || __('Label'); + } else if (selectedLabels.length > 1) { + return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { + firstLabelName: selectedLabels[0].title, + remainingLabelCount: selectedLabels.length - 1, + }); + } + return selectedLabels[0].title; +}; + +/** + * Returns array containing only label IDs from + * selectedLabels array. + * @param {object} state + */ +export const selectedLabelsList = (state) => state.selectedLabels.map((label) => label.id); + +/** + * Returns boolean representing whether dropdown variant + * is `sidebar` + * @param {object} state + */ +export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar; + +/** + * Returns boolean representing whether dropdown variant + * is `standalone` + * @param {object} state + */ +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; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js new file mode 100644 index 00000000000..5f61cb732c8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js @@ -0,0 +1,12 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +export default () => ({ + namespaced: true, + state: state(), + actions, + getters, + mutations, +}); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js new file mode 100644 index 00000000000..2e044dc3b3c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js @@ -0,0 +1,20 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +export const REQUEST_LABELS = 'REQUEST_LABELS'; +export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; +export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; + +export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS'; +export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS'; +export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE'; + +export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL'; +export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS'; +export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE'; + +export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; +export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; + +export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; + +export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js new file mode 100644 index 00000000000..55716e1105e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js @@ -0,0 +1,70 @@ +import { DropdownVariant } from '../constants'; +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, props) { + Object.assign(state, { ...props }); + }, + + [types.TOGGLE_DROPDOWN_BUTTON](state) { + state.showDropdownButton = !state.showDropdownButton; + }, + + [types.TOGGLE_DROPDOWN_CONTENTS](state) { + if (state.variant === DropdownVariant.Sidebar) { + state.showDropdownButton = !state.showDropdownButton; + } + state.showDropdownContents = !state.showDropdownContents; + // Ensure that Create View is hidden by default + // when dropdown contents are revealed. + if (state.showDropdownContents) { + state.showDropdownContentsCreateView = false; + } + }, + + [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) { + state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView; + }, + + [types.REQUEST_LABELS](state) { + state.labelsFetchInProgress = true; + }, + [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) { + // Iterate over every label and add a `set` prop + // to determine whether it is already a part of + // selectedLabels array. + const selectedLabelIds = state.selectedLabels.map((label) => label.id); + state.labelsFetchInProgress = false; + state.labels = labels.reduce((allLabels, label) => { + allLabels.push({ + ...label, + set: selectedLabelIds.includes(label.id), + }); + return allLabels; + }, []); + }, + [types.RECEIVE_SET_LABELS_FAILURE](state) { + state.labelsFetchInProgress = false; + }, + + [types.REQUEST_CREATE_LABEL](state) { + state.labelCreateInProgress = true; + }, + [types.RECEIVE_CREATE_LABEL_SUCCESS](state) { + state.labelCreateInProgress = false; + }, + [types.RECEIVE_CREATE_LABEL_FAILURE](state) { + state.labelCreateInProgress = false; + }, + + [types.UPDATE_SELECTED_LABELS](state, { labels }) { + // Find the label to update from all the labels + // and change `set` prop value to represent their current state. + const labelId = labels.pop()?.id; + const candidateLabel = state.labels.find((label) => labelId === label.id); + if (candidateLabel) { + candidateLabel.touched = true; + candidateLabel.set = !candidateLabel.set; + } + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js new file mode 100644 index 00000000000..d66cfed4163 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js @@ -0,0 +1,29 @@ +export default () => ({ + // Initial Data + labels: [], + selectedLabels: [], + labelsListTitle: '', + labelsCreateTitle: '', + footerCreateLabelTitle: '', + footerManageLabelTitle: '', + dropdownButtonText: '', + + // Paths + namespace: '', + labelsFetchPath: '', + labelsFilterBasePath: '', + + // UI Flags + variant: '', + allowLabelRemove: false, + allowLabelCreate: false, + allowLabelEdit: false, + allowScopedLabels: false, + allowMultiselect: false, + showDropdownButton: false, + showDropdownContents: false, + showDropdownContentsCreateView: false, + labelsFetchInProgress: false, + labelCreateInProgress: false, + selectedLabelsUpdated: false, +}); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql new file mode 100644 index 00000000000..d99fc125012 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql @@ -0,0 +1,20 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query alertAssignees( + $domain: AlertManagementDomainFilter = threat_monitoring + $fullPath: ID! + $iid: String! +) { + workspace: project(fullPath: $fullPath) { + issuable: alertManagementAlert(domain: $domain, iid: $iid) { + iid + assignees { + nodes { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue new file mode 100644 index 00000000000..121c3bd94ef --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue @@ -0,0 +1,175 @@ +<script> +import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; +import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql'; + +/** + * A renderless component for querying/dismissing UserCallouts via GraphQL. + * + * Simplest example usage: + * + * <user-callout-dismisser feature-name="my_user_callout"> + * <template #default="{ dismiss, shouldShowCallout }"> + * <my-callout-component + * v-if="shouldShowCallout" + * @close="dismiss" + * /> + * </template> + * </user-callout-dismisser> + * + * If you don't want the asynchronous query to run when the component is + * created, and know by some other means whether the user callout has already + * been dismissed, you can use the `skipQuery` prop, and a regular `v-if` + * directive: + * + * <user-callout-dismisser + * v-if="userCalloutIsNotDismissed" + * feature-name="my_user_callout" + * skip-query + * > + * <template #default="{ dismiss, shouldShowCallout }"> + * <my-callout-component + * v-if="shouldShowCallout" + * @close="dismiss" + * /> + * </template> + * </user-callout-dismisser> + * + * The component exposes various scoped slot props on the default slot, + * allowing for granular rendering behaviors based on the state of the initial + * query and user-initiated mutation: + * + * - dismiss: Function + * - Triggers mutation to dismiss the user callout. + * - isAnonUser: boolean + * - Whether the current user is anonymous or not (i.e., whether or not + * they're logged in). + * - isDismissed: boolean + * - Whether the given user callout has been dismissed or not. + * - isLoadingMutation: boolean + * - Whether the mutation is loading. + * - isLoadingQuery: boolean + * - Whether the initial query is loading. + * - mutationError: string[] | null + * - The mutation's errors, if any; otherwise `null`. + * - queryError: Error | null + * - The query's error, if any; otherwise `null`. + * - shouldShowCallout: boolean + * - A combination of the above which should cover 95% of use cases: `true` + * if the query has loaded without error, and the user is logged in, and + * the callout has not been dismissed yet; `false` otherwise. + */ +export default { + name: 'UserCalloutDismisser', + props: { + featureName: { + type: String, + required: true, + }, + skipQuery: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + currentUser: null, + isDismissedLocal: false, + isLoadingMutation: false, + mutationError: null, + queryError: null, + }; + }, + apollo: { + currentUser: { + query: getUserCalloutsQuery, + update(data) { + return data?.currentUser; + }, + error(err) { + this.queryError = err; + }, + skip() { + return this.skipQuery; + }, + }, + }, + computed: { + featureNameEnumValue() { + return this.featureName.toUpperCase(); + }, + isLoadingQuery() { + return this.$apollo.queries.currentUser.loading; + }, + isAnonUser() { + return !(this.skipQuery || this.queryError || this.isLoadingQuery || this.currentUser); + }, + isDismissedRemote() { + const callouts = this.currentUser?.callouts?.nodes ?? []; + + return callouts.some(({ featureName }) => featureName === this.featureNameEnumValue); + }, + isDismissed() { + return this.isDismissedLocal || this.isDismissedRemote; + }, + slotProps() { + const { + dismiss, + isAnonUser, + isDismissed, + isLoadingMutation, + isLoadingQuery, + mutationError, + queryError, + shouldShowCallout, + } = this; + + return { + dismiss, + isAnonUser, + isDismissed, + isLoadingMutation, + isLoadingQuery, + mutationError, + queryError, + shouldShowCallout, + }; + }, + shouldShowCallout() { + return !(this.isLoadingQuery || this.isDismissed || this.queryError || this.isAnonUser); + }, + }, + methods: { + async dismiss() { + this.isLoadingMutation = true; + this.isDismissedLocal = true; + + try { + const { data } = await this.$apollo.mutate({ + mutation: dismissUserCalloutMutation, + variables: { + input: { + featureName: this.featureName, + }, + }, + }); + + const errors = data?.userCalloutCreate?.errors ?? []; + if (errors.length > 0) { + this.onDismissalError(errors); + } + } catch (err) { + this.onDismissalError([err.message]); + } finally { + this.isLoadingMutation = false; + } + }, + onDismissalError(errors) { + this.mutationError = errors; + }, + }, + render() { + return this.$scopedSlots.default(this.slotProps); + }, +}; +</script> 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 3116d2fbf32..04e44aa2ed1 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 @@ -60,6 +60,11 @@ export default { required: false, default: 'issue', }, + isEditing: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -74,6 +79,9 @@ export default { query() { return participantsQueries[this.issuableType].query; }, + skip() { + return Boolean(participantsQueries[this.issuableType].skipQuery) || !this.isEditing; + }, variables() { return { iid: this.iid, @@ -99,10 +107,13 @@ export default { first: 20, }; }, + skip() { + return !this.isEditing; + }, update(data) { // TODO Remove null filter (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 - return data.workspace?.users?.nodes.filter((x) => x).map(({ user }) => user) || []; + return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; }, debounce: ASSIGNEES_DEBOUNCE_DELAY, error({ graphQLErrors }) { diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 54313297b14..a2b432d11f4 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -129,7 +129,7 @@ export default { <gl-icon name="chevron-right" :size="8" /> </template> </gl-breadcrumb> - <legacy-container :key="activePanel.name" class="gl-mt-3" :selector="activePanel.selector" /> + <legacy-container :key="activePanel.name" :selector="activePanel.selector" /> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue new file mode 100644 index 00000000000..8fdc5ca78db --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue @@ -0,0 +1,82 @@ +<script> +import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; +import { extractSecurityReportArtifactsFromMergeRequest } from '~/vue_shared/security_reports/utils'; + +export default { + components: { + SecurityReportDownloadDropdown, + }, + props: { + reportTypes: { + type: Array, + required: true, + validator: (reportType) => { + return reportType.every((report) => reportTypeToSecurityReportTypeEnum[report]); + }, + }, + targetProjectFullPath: { + type: String, + required: true, + }, + mrIid: { + type: Number, + required: true, + }, + }, + data() { + return { + reportArtifacts: [], + }; + }, + apollo: { + reportArtifacts: { + query: securityReportMergeRequestDownloadPathsQuery, + variables() { + return { + projectPath: this.targetProjectFullPath, + iid: String(this.mrIid), + reportTypes: this.reportTypes.map( + (reportType) => reportTypeToSecurityReportTypeEnum[reportType], + ), + }; + }, + update(data) { + return extractSecurityReportArtifactsFromMergeRequest(this.reportTypes, data); + }, + error(error) { + this.showError(error); + }, + }, + }, + computed: { + isLoadingReportArtifacts() { + return this.$apollo.queries.reportArtifacts.loading; + }, + }, + methods: { + showError(error) { + createFlash({ + message: this.$options.i18n.apiError, + captureError: true, + error, + }); + }, + }, + i18n: { + apiError: s__( + 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', + ), + }, +}; +</script> + +<template> + <security-report-download-dropdown + :artifacts="reportArtifacts" + :loading="isLoadingReportArtifacts" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue index 26bc9b5d60e..eed1c86c318 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue @@ -53,6 +53,6 @@ export default { </span> <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp"> - <gl-icon name="question" /> + <gl-icon name="question-o" /> </gl-link> </template> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue index d7c1e27ff3e..9e941087da2 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { @@ -8,6 +8,9 @@ export default { GlDropdown, GlDropdownItem, }, + directives: { + GlTooltip, + }, props: { artifacts: { type: Array, @@ -31,9 +34,11 @@ export default { <template> <gl-dropdown - :text="s__('SecurityReports|Download results')" + v-gl-tooltip + :title="s__('SecurityReports|Download results')" :loading="loading" icon="download" + size="small" right > <gl-dropdown-item diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index b7f283b8fd9..d7a3d4e611e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -191,6 +191,7 @@ export default { <security-summary :message="groupedSummaryText" /> <help-icon + class="gl-ml-3" :help-path="securityReportsDocsPath" :discover-project-security-path="discoverProjectSecurityPath" /> @@ -219,6 +220,7 @@ export default { {{ $options.i18n.scansHaveRun }} <help-icon + class="gl-ml-3" :help-path="securityReportsDocsPath" :discover-project-security-path="discoverProjectSecurityPath" /> diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index 5444e77a4d2..11096b08032 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -30,6 +30,7 @@ export default { return dateInWords(date); }, }, + safeHtmlConfig: { ADD_ATTR: ['target'] }, }; </script> @@ -71,7 +72,10 @@ export default { <gl-icon name="license" />{{ packageName }} </gl-badge> </div> - <div v-safe-html="feature.body" class="gl-pt-3 gl-line-height-20"></div> + <div + v-safe-html:[$options.safeHtmlConfig]="feature.body" + class="gl-pt-3 gl-line-height-20" + ></div> <gl-button :href="feature.url" target="_blank" diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss index d97a9bc227d..59bd69955d3 100644 --- a/app/assets/stylesheets/components/rich_content_editor.scss +++ b/app/assets/stylesheets/components/rich_content_editor.scss @@ -47,7 +47,7 @@ /** * Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com -* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/source/stylesheets/_base.scss#L977 +* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/main/source/stylesheets/_base.scss#L977 */ .video_container { padding-bottom: 56.25%; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index cde5ad24fa5..2fbdaaaf467 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -38,7 +38,8 @@ @import 'framework/secondary_navigation_elements'; @import 'framework/selects'; @import 'framework/sidebar'; -@import 'framework/contextual_sidebar'; +@import 'framework/contextual_sidebar_header'; +@import 'framework/contextual_sidebar_refactoring/contextual_sidebar'; @import 'framework/tables'; @import 'framework/notes'; @import 'framework/tabs'; @@ -46,6 +47,7 @@ @import 'framework/toggle'; @import 'framework/typography'; @import 'framework/zen'; +@import 'framework/blank'; @import 'framework/wells'; @import 'framework/page_header'; @import 'framework/page_title'; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 412a1e8d6c9..2c72c4b0f65 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -255,27 +255,9 @@ // This forces the height and width of the inner content to match // other gl-buttons despite all child elements being set to // `position:absolute` - &::after { - content: '\a0'; - display: block !important; - width: 1em; - color: transparent; - } - - .reaction-control-icon { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - - // center the icon vertically and horizontally within the button - display: flex; - align-items: center; - justify-content: center; - @include transition(opacity, transform); + .reaction-control-icon { .gl-icon { height: $default-icon-size; width: $default-icon-size; @@ -283,32 +265,26 @@ } .reaction-control-icon-neutral { - opacity: 1; + display: flex; } .reaction-control-icon-positive, .reaction-control-icon-super-positive { - opacity: 0; + display: none; } &:hover, &.active, &:active, &.is-active { - // extra specificty added to override another selector - .reaction-control-icon .gl-icon { - color: $blue-500; - transform: scale(1.15); - } - .reaction-control-icon-neutral { - opacity: 0; + display: none; } } &:hover { .reaction-control-icon-positive { - opacity: 1; + display: flex; } } @@ -316,11 +292,11 @@ &:active, &.is-active { .reaction-control-icon-positive { - opacity: 0; + display: none; } .reaction-control-icon-super-positive { - opacity: 1; + display: flex; } } @@ -336,17 +312,13 @@ } .reaction-control-icon-neutral { - opacity: 1; + display: flex; } .reaction-control-icon-positive, .reaction-control-icon-super-positive { - opacity: 0; + display: none; } } } } - -.awards .is-active { - box-shadow: inset 0 0 0 1px $blue-200; -} diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss new file mode 100644 index 00000000000..7dd7ab339dd --- /dev/null +++ b/app/assets/stylesheets/framework/blank.scss @@ -0,0 +1,118 @@ +.blank-state-parent-container { + .section-container { + padding: 10px; + } + + .section-body { + width: 100%; + height: 100%; + padding-bottom: 25px; + border-radius: $border-radius-default; + } +} + +.blank-state-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.blank-state-welcome { + text-align: center; + padding: $gl-padding 0 ($gl-padding * 2); + + .blank-state-welcome-title { + font-size: 24px; + } + + .blank-state-text { + margin-bottom: 0; + } +} + +.blank-state-link { + color: $gl-text-color; + margin-bottom: 15px; + + &:hover { + background-color: $gray-light; + text-decoration: none; + color: $gl-text-color; + } +} + +.blank-state-center { + padding-top: 20px; + padding-bottom: 20px; + text-align: center; +} + +.blank-state { + display: flex; + align-items: center; + padding: 20px 50px; + border: 1px solid $border-color; + border-radius: $border-radius-default; + min-height: 240px; + margin-bottom: $gl-padding; + width: calc(50% - #{$gl-padding-8}); + + @include media-breakpoint-down(sm) { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 50px 20px; + + .column-small & { + width: 100%; + } + + } +} + +.blank-state, +.blank-state-center { + .blank-state-icon { + svg { + display: block; + margin: auto; + } + } + + .blank-state-title { + margin-top: 0; + font-size: 18px; + } + + .blank-state-body { + @include media-breakpoint-down(sm) { + text-align: center; + margin-top: 20px; + } + + @include media-breakpoint-up(sm) { + padding-left: 20px; + } + } +} + +@include media-breakpoint-up(lg) { + .column-large { + flex: 2; + } + + .column-small { + flex: 1; + margin-bottom: 15px; + + .blank-state { + max-width: 400px; + flex-wrap: wrap; + margin-left: 15px; + } + + .blank-state-icon { + margin-bottom: 30px; + } + } +} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index bfa4a640fe2..10481294df5 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -72,7 +72,7 @@ } &.content-component-block { - padding: 11px 0; + padding: 8px 0; background-color: $body-bg; } @@ -253,7 +253,7 @@ } .content-block-small { - padding: 10px 0; + padding: 4px 0; } .landing { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 603d28a8395..ceccec8c5cb 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -200,10 +200,6 @@ @include btn-red; } - &.btn-cancel { - float: right; - } - &.btn-grouped { @include btn-with-margin; } diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 5b7f1a3f38b..1fa03d66f32 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -30,6 +30,16 @@ cursor: pointer; stroke: $black; } + + // `app/assets/javascripts/pages/users/activity_calendar.js` sets this attribute + @for $i from 1 through length($calendar-activity-colors) { + $color: nth($calendar-activity-colors, $i); + $level: $i - 1; + + &[data-level='#{$level}'] { + fill: $color; + } + } } .user-contrib-text { diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss deleted file mode 100644 index 14d1a0663d0..00000000000 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ /dev/null @@ -1,453 +0,0 @@ -.page-with-contextual-sidebar { - transition: padding-left $sidebar-transition-duration; - - @include media-breakpoint-up(md) { - padding-left: $contextual-sidebar-collapsed-width; - } - - @include media-breakpoint-up(xl) { - padding-left: $contextual-sidebar-width; - } - - .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { - padding: 10px 0 15px; - } -} - -.page-with-icon-sidebar { - @include media-breakpoint-up(md) { - padding-left: $contextual-sidebar-collapsed-width; - } -} - -.context-header { - position: relative; - margin-right: 2px; - width: $contextual-sidebar-width; - - > a, - > button { - transition: padding $sidebar-transition-duration; - font-weight: $gl-font-weight-bold; - display: flex; - width: 100%; - align-items: center; - padding: 10px 16px 10px 10px; - color: $gl-text-color; - background-color: transparent; - border: 0; - text-align: left; - - &:hover, - &:focus { - background-color: $link-hover-background; - color: $gl-text-color; - outline: 0; - } - } - - .avatar-container { - flex: 0 0 40px; - background-color: $white; - } - - .sidebar-context-title { - overflow: hidden; - text-overflow: ellipsis; - - &.text-secondary { - font-weight: normal; - font-size: 0.8em; - } - } -} - -.settings-avatar { - background-color: $white; - - svg { - fill: $gl-text-color-secondary; - margin: auto; - } -} - -@mixin collapse-contextual-sidebar-content { - .context-header { - height: 60px; - width: $contextual-sidebar-collapsed-width; - - a { - padding: 10px 4px; - } - } - - .sidebar-top-level-items > li { - .sidebar-sub-level-items { - &:not(.flyout-list) { - display: none; - } - } - } - - .nav-icon-container { - margin-right: 0; - } - - .toggle-sidebar-button { - padding: 16px; - width: $contextual-sidebar-collapsed-width - 1px; - - .collapse-text, - .icon-chevron-double-lg-left { - display: none; - } - - .icon-chevron-double-lg-right { - display: block; - margin: 0; - } - } -} - -.nav-sidebar { - transition: width $sidebar-transition-duration, left $sidebar-transition-duration; - position: fixed; - z-index: 600; - width: $contextual-sidebar-width; - top: $header-height; - bottom: 0; - left: 0; - background-color: $gray-light; - box-shadow: inset -1px 0 0 $border-color; - transform: translate3d(0, 0, 0); - - &:not(.sidebar-collapsed-desktop) { - @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) { - box-shadow: inset -1px 0 0 $border-color, 2px 1px 3px $dropdown-shadow-color; - } - } - - @mixin collapse-contextual-sidebar { - width: $contextual-sidebar-collapsed-width; - - .nav-sidebar-inner-scroll { - overflow-x: hidden; - } - - .badge.badge-pill:not(.fly-out-badge), - .sidebar-context-title, - .nav-item-name { - @include gl-sr-only; - } - - .sidebar-top-level-items > li > a { - min-height: 45px; - } - - .fly-out-top-item { - display: block; - } - - .avatar-container { - margin: 0 auto; - } - } - - &.sidebar-collapsed-desktop { - @include collapse-contextual-sidebar; - } - - &.sidebar-expanded-mobile { - left: 0; - } - - a { - text-decoration: none; - } - - ul { - padding-left: 0; - list-style: none; - } - - li { - white-space: nowrap; - - a { - transition: padding $sidebar-transition-duration; - display: flex; - align-items: center; - padding: 12px $gl-padding; - color: $gl-text-color-secondary; - } - - .nav-item-name { - flex: 1; - } - - &.active { - > a { - font-weight: $gl-font-weight-bold; - } - } - } - - @include media-breakpoint-down(sm) { - left: (-$contextual-sidebar-width); - } - - .nav-icon-container { - display: flex; - margin-right: 8px; - } - - .fly-out-top-item { - display: none; - } - - svg { - height: 16px; - width: 16px; - } - - @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) { - &:not(.sidebar-expanded-mobile) { - @include collapse-contextual-sidebar; - @include collapse-contextual-sidebar-content; - } - } -} - -.nav-sidebar-inner-scroll { - height: 100%; - width: 100%; - overflow: auto; -} - -.with-performance-bar .nav-sidebar { - top: $header-height + $performance-bar-height; -} - -.sidebar-sub-level-items { - display: none; - padding-bottom: 8px; - - > li { - a { - padding: 8px 16px 8px 40px; - - &:hover, - &:focus { - background: $link-active-background; - color: $gl-text-color; - } - } - - &.active { - a { - &, - &:hover, - &:focus { - background: $link-active-background; - } - } - } - } -} - -.sidebar-top-level-items { - margin-bottom: 60px; - - > li { - > a { - @include media-breakpoint-up(sm) { - margin-right: 1px; - } - - &:hover { - color: $gl-text-color; - } - } - - &.is-showing-fly-out { - > a { - margin-right: 1px; - } - - .sidebar-sub-level-items { - @include media-breakpoint-up(sm) { - position: fixed; - top: 0; - left: 0; - min-width: 150px; - margin-top: -1px; - padding: 4px 1px; - background-color: $white; - box-shadow: 2px 1px 3px $dropdown-shadow-color; - border: 1px solid $gray-darker; - border-left: 0; - border-radius: 0 3px 3px 0; - - &::before { - content: ''; - position: absolute; - top: -30px; - bottom: -30px; - left: -10px; - right: -30px; - z-index: -1; - } - - &.is-above { - margin-top: 1px; - } - - .divider { - height: 1px; - margin: 4px -1px; - padding: 0; - background-color: $dropdown-divider-bg; - } - - > .active { - box-shadow: none; - - > a { - background-color: transparent; - } - } - - a { - padding: 8px 16px; - color: $gl-text-color; - - &:hover, - &:focus { - background-color: $gray-darker; - } - } - } - } - } - - .badge.badge-pill { - background-color: $inactive-badge-background; - color: $gl-text-color-secondary; - } - - &.active { - background: $link-active-background; - - > a { - margin-left: 4px; - // Subtract width of left border on active element - padding-left: $gl-padding-12; - } - - .badge.badge-pill { - font-weight: $gl-font-weight-bold; - } - - .sidebar-sub-level-items:not(.is-fly-out-only) { - display: block; - } - } - - &.active > a:hover, - &.is-over > a { - background-color: $link-hover-background; - } - } -} - -// Collapsed nav - -.toggle-sidebar-button, -.close-nav-button, -.toggle-right-sidebar-button { - transition: width $sidebar-transition-duration; - height: $toggle-sidebar-height; - padding: 0 $gl-padding; - background-color: $gray-light; - border: 0; - color: $gl-text-color-secondary; - display: flex; - align-items: center; - - &:hover { - background-color: $border-color; - color: $gl-text-color; - } -} - -.toggle-sidebar-button, -.close-nav-button { - position: fixed; - bottom: 0; - width: $contextual-sidebar-width - 1px; - border-top: 1px solid $border-color; - - svg { - margin-right: 8px; - } - - .icon-chevron-double-lg-right { - display: none; - } -} - -.toggle-right-sidebar-button { - border-bottom: 1px solid $border-color; -} - -.collapse-text { - white-space: nowrap; - overflow: hidden; -} - -.sidebar-collapsed-desktop { - @include collapse-contextual-sidebar-content; -} - -.fly-out-top-item { - > a { - display: flex; - } - - .fly-out-badge { - margin-left: 8px; - } -} - -.fly-out-top-item-name { - flex: 1; -} - -// Mobile nav - -.close-nav-button { - display: none; -} - -@include media-breakpoint-down(sm) { - .close-nav-button { - display: flex; - } - - .toggle-sidebar-button { - display: none; - } - - .mobile-overlay { - display: none; - - &.mobile-nav-open { - display: block; - position: fixed; - background-color: $black-transparent; - height: 100%; - width: 100%; - z-index: $zindex-dropdown-menu; - } - } -} diff --git a/app/assets/stylesheets/framework/contextual_sidebar_header.scss b/app/assets/stylesheets/framework/contextual_sidebar_header.scss new file mode 100644 index 00000000000..fdd03f4cdc8 --- /dev/null +++ b/app/assets/stylesheets/framework/contextual_sidebar_header.scss @@ -0,0 +1,57 @@ +.context-header { + position: relative; + margin-right: 2px; + width: $contextual-sidebar-width; + + > a, + > button { + transition: padding $sidebar-transition-duration; + font-weight: $gl-font-weight-bold; + display: flex; + width: 100%; + align-items: center; + padding: 10px 16px 10px 10px; + color: $gl-text-color; + background-color: transparent; + border: 0; + text-align: left; + + &:hover, + &:focus { + background-color: $link-hover-background; + color: $gl-text-color; + outline: 0; + } + } + + .avatar-container { + flex: 0 0 40px; + background-color: $white; + } + + .sidebar-context-title { + overflow: hidden; + text-overflow: ellipsis; + + &.text-secondary { + font-weight: normal; + font-size: 0.8em; + } + } +} + +@mixin context-header-collapsed { + .context-header { + height: 60px; + width: $contextual-sidebar-collapsed-width; + + a { + padding: 10px 4px; + } + } + + .sidebar-context-title { + @include gl-sr-only; + } +} + diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss new file mode 100644 index 00000000000..905ac260203 --- /dev/null +++ b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss @@ -0,0 +1,7 @@ +body:not(.sidebar-refactoring) { + @import 'contextual_sidebar_base'; +} + +body.sidebar-refactoring { + @import 'contextual_sidebar_variant'; +} diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss new file mode 100644 index 00000000000..306a9b74ebd --- /dev/null +++ b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss @@ -0,0 +1,386 @@ +@mixin collapse-contextual-sidebar-content { + + @include context-header-collapsed; + + .sidebar-top-level-items > li { + .sidebar-sub-level-items { + &:not(.flyout-list) { + display: none; + } + } + } + + .nav-icon-container { + margin-right: 0; + } + + .toggle-sidebar-button { + padding: 16px; + width: $contextual-sidebar-collapsed-width - 1px; + + .collapse-text, + .icon-chevron-double-lg-left { + display: none; + } + + .icon-chevron-double-lg-right { + display: block; + margin: 0; + } + } +} + +@mixin collapse-contextual-sidebar { + width: $contextual-sidebar-collapsed-width; + + .nav-sidebar-inner-scroll { + overflow-x: hidden; + } + + .badge.badge-pill:not(.fly-out-badge), + .nav-item-name { + @include gl-sr-only; + } + + .sidebar-top-level-items > li > a { + min-height: 45px; + } + + .fly-out-top-item { + display: block; + } + + .avatar-container { + margin: 0 auto; + } +} + +@at-root { + .page-with-contextual-sidebar { + transition: padding-left $sidebar-transition-duration; + + @include media-breakpoint-up(md) { + padding-left: $contextual-sidebar-collapsed-width; + } + + @include media-breakpoint-up(xl) { + padding-left: $contextual-sidebar-width; + } + + .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { + padding: 10px 0 15px; + } + } + + .page-with-icon-sidebar { + @include media-breakpoint-up(md) { + padding-left: $contextual-sidebar-collapsed-width; + } + } + + .settings-avatar { + background-color: $white; + + svg { + fill: $gl-text-color-secondary; + margin: auto; + } + } + + .nav-sidebar { + transition: width $sidebar-transition-duration, left $sidebar-transition-duration; + position: fixed; + z-index: 600; + width: $contextual-sidebar-width; + top: $header-height; + bottom: 0; + left: 0; + background-color: $gray-light; + box-shadow: inset -1px 0 0 $border-color; + transform: translate3d(0, 0, 0); + + &:not(.sidebar-collapsed-desktop) { + @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) { + box-shadow: inset -1px 0 0 $border-color, 2px 1px 3px $dropdown-shadow-color; + } + } + + &.sidebar-collapsed-desktop { + @include collapse-contextual-sidebar; + } + + &.sidebar-expanded-mobile { + left: 0; + } + + a { + text-decoration: none; + } + + ul { + padding-left: 0; + list-style: none; + } + + li { + white-space: nowrap; + + a { + transition: padding $sidebar-transition-duration; + display: flex; + align-items: center; + padding: 12px $gl-padding; + color: $gl-text-color-secondary; + } + + .nav-item-name { + flex: 1; + } + + &.active { + > a { + font-weight: $gl-font-weight-bold; + } + } + } + + @include media-breakpoint-down(sm) { + left: (-$contextual-sidebar-width); + } + + .nav-icon-container { + display: flex; + margin-right: 8px; + } + + .fly-out-top-item { + display: none; + } + + svg { + height: 16px; + width: 16px; + } + + @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) { + &:not(.sidebar-expanded-mobile) { + @include collapse-contextual-sidebar; + @include collapse-contextual-sidebar-content; + } + } + } + + .nav-sidebar-inner-scroll { + height: 100%; + width: 100%; + overflow: auto; + } + + .sidebar-sub-level-items { + display: none; + padding-bottom: 8px; + + > li { + a { + padding: 8px 16px 8px 40px; + + &:hover, + &:focus { + background: $link-active-background; + color: $gl-text-color; + } + } + + &.active { + a { + &, + &:hover, + &:focus { + background: $link-active-background; + } + } + } + } + } + + .sidebar-top-level-items { + margin-bottom: 60px; + + > li { + > a { + @include media-breakpoint-up(sm) { + margin-right: 1px; + } + + &:hover { + color: $gl-text-color; + } + } + + &.is-showing-fly-out { + > a { + margin-right: 1px; + } + + .sidebar-sub-level-items { + @include media-breakpoint-up(sm) { + position: fixed; + top: 0; + left: 0; + min-width: 150px; + margin-top: -1px; + padding: 4px 1px; + background-color: $white; + box-shadow: 2px 1px 3px $dropdown-shadow-color; + border: 1px solid $gray-darker; + border-left: 0; + border-radius: 0 3px 3px 0; + + &::before { + content: ''; + position: absolute; + top: -30px; + bottom: -30px; + left: -10px; + right: -30px; + z-index: -1; + } + + &.is-above { + margin-top: 1px; + } + + .divider { + height: 1px; + margin: 4px -1px; + padding: 0; + background-color: $dropdown-divider-bg; + } + + > .active { + box-shadow: none; + + > a { + background-color: transparent; + } + } + + a { + padding: 8px 16px; + color: $gl-text-color; + + &:hover, + &:focus { + background-color: $gray-darker; + } + } + } + } + } + + .badge.badge-pill { + background-color: $inactive-badge-background; + color: $gl-text-color-secondary; + } + + &.active { + background: $link-active-background; + + > a { + margin-left: 4px; + // Subtract width of left border on active element + padding-left: $gl-padding-12; + } + + .badge.badge-pill { + font-weight: $gl-font-weight-bold; + } + + .sidebar-sub-level-items:not(.is-fly-out-only) { + display: block; + } + } + + &.active > a:hover, + &.is-over > a { + background-color: $link-hover-background; + } + } + } + + // Collapsed nav + + .toggle-sidebar-button, + .close-nav-button { + @include side-panel-toggle; + } + + .toggle-sidebar-button, + .close-nav-button { + position: fixed; + bottom: 0; + width: $contextual-sidebar-width - 1px; + border-top: 1px solid $border-color; + + svg { + margin-right: 8px; + } + + .icon-chevron-double-lg-right { + display: none; + } + } + + .collapse-text { + white-space: nowrap; + overflow: hidden; + } + + .sidebar-collapsed-desktop { + @include collapse-contextual-sidebar-content; + } + + .fly-out-top-item { + > a { + display: flex; + } + + .fly-out-badge { + margin-left: 8px; + } + } + + .fly-out-top-item-name { + flex: 1; + } + + // Mobile nav + + .close-nav-button { + display: none; + } + + @include media-breakpoint-down(sm) { + .close-nav-button { + display: flex; + } + + .toggle-sidebar-button { + display: none; + } + + .mobile-overlay { + display: none; + + &.mobile-nav-open { + display: block; + position: fixed; + background-color: $black-transparent; + height: 100%; + width: 100%; + z-index: $zindex-dropdown-menu; + } + } + } +} + diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss new file mode 100644 index 00000000000..154b8c31e8b --- /dev/null +++ b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss @@ -0,0 +1,514 @@ +// +// VARIABLES +// + +$top-level-item-color: $purple-900; + +// +// TEMPORARY OVERRIDES +// Needed while we serve both *_base and *_variant stylesheets +// TODO: These have to be removed during the ':sidebar_refactor' flag rollout +// +&.gl-dark .nav-sidebar li.active { + box-shadow: none; +} + +&.gl-dark .nav-sidebar li a, +&.gl-dark .toggle-sidebar-button .collapse-text, +&.gl-dark .toggle-sidebar-button .icon-chevron-double-lg-left, +&.gl-dark .toggle-sidebar-button .icon-chevron-double-lg-right, +&.gl-dark .sidebar-top-level-items .context-header a .sidebar-context-title, +&.gl-dark .nav-sidebar-inner-scroll > div.context-header a .sidebar-context-title { + color: $gray-darkest; +} + +&.ui-indigo .nav-sidebar li.active:not(.fly-out-top-item) > a { + color: $top-level-item-color; +} + +&.ui-indigo .nav-sidebar li.active .nav-icon-container svg { + fill: $top-level-item-color; +} + +.nav-sidebar { + box-shadow: none; + + li.active { + background-color: transparent; + box-shadow: none !important; // TODO: This should be updated in `theme_helper.scss` together with ':sidebar_refactor' rollout + } +} + +// +// MIXINS +// + +@mixin collapse-contextual-sidebar-content { + + @include context-header-collapsed; + + .context-header { + @include gl-h-auto; + + a { + @include gl-p-2; + } + } + + .sidebar-top-level-items > li { + .sidebar-sub-level-items { + &:not(.flyout-list) { + display: none; + } + } + } + + .nav-icon-container { + margin-right: 0; + } + + .toggle-sidebar-button { + width: $contextual-sidebar-collapsed-width; + + .collapse-text { + display: none; + } + + .icon-chevron-double-lg-left { + @include gl-rotate-180; + @include gl-display-block; // TODO: shouldn't be needed after the flag roll out + @include gl-m-0; + } + } +} + +@mixin collapse-contextual-sidebar { + width: $contextual-sidebar-collapsed-width; + + .nav-sidebar-inner-scroll { + overflow-x: hidden; + } + + .badge.badge-pill:not(.fly-out-badge), + .nav-item-name, + .collapse-text { + @include gl-sr-only; + } + + .sidebar-top-level-items > li > a { + min-height: unset; + } + + .fly-out-top-item:not(.divider) { + display: block !important; + } + + .avatar-container { + margin: 0 auto; + } + + li.active:not(.fly-out-top-item) > a { + background-color: $indigo-900-alpha-008; + } +} + +@mixin sub-level-items-flyout { + .sidebar-sub-level-items { + @include media-breakpoint-up(sm) { + @include gl-fixed; + @include gl-top-0; + @include gl-left-0; + @include gl-ml-3; + @include gl-mt-0; + @include gl-px-0; + @include gl-pb-2; + @include gl-pt-0; + min-width: 150px; + background-color: $gray-10; + box-shadow: 0 $gl-spacing-scale-2 $gl-spacing-scale-5 $t-gray-a-24, 0 0 $gl-spacing-scale-1 $t-gray-a-24; + border-style: none; + border-radius: $border-radius-default; + + .divider { + @include gl-display-none; + } + + .divider + li > a { + @include gl-mt-2; + } + + li:last-of-type a { + @include gl-mb-0; + } + + &.is-above { + @include gl-mt-0; + } + } + + a { + @include gl-px-4; + } + + .fly-out-top-item { + > a { + display: flex; + } + + .fly-out-badge { + margin-left: 8px; + } + } + + .fly-out-top-item-name { + flex: 1; + } + } +} + +@mixin context-header { + $avatar-box-shadow: inset 0 0 0 1px $t-gray-a-08; + + @include gl-p-2; + @include gl-mb-2; + @include gl-mt-0; + + .avatar-container { + @include gl-font-weight-normal; + flex: none; + box-shadow: $avatar-box-shadow; + + &.rect-avatar { + @include gl-border-none; + + .avatar.s32 { + @extend .rect-avatar.s32; + color: $gray-900; + box-shadow: $avatar-box-shadow; + } + } + } + + .sidebar-context-title { + color: $top-level-item-color; + } +} + +@mixin top-level-item { + @include gl-px-4; + @include gl-py-3; + @include gl-display-flex; + @include gl-align-items-center; + @include gl-rounded-base; + @include gl-w-auto; + @include gl-line-height-normal; + transition: none; + margin: $sidebar-top-item-tb-margin $sidebar-top-item-lr-margin; + + &:hover { + background-color: $indigo-900-alpha-008; + } +} + +@mixin fly-out-top-item($has-sub-items: false) { + @include gl-display-none; + + a, + a:hover, + &.active a, + .fly-out-top-item-container { + @include gl-mx-0; + @include gl-px-5; + @include gl-cursor-default; + @include gl-pointer-events-none; + @include gl-font-sm; + background-color: $purple-900; + color: $white; + + @if $has-sub-items { + @include gl-mt-n2; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } @else { + @include gl-my-n2; + @include gl-mt-0; + @include gl-relative; + background-color: $black; + + strong { + @include gl-font-weight-normal; + } + + &::before { + @include gl-absolute; + content: ''; + display: block; + top: 50%; + left: $gl-spacing-scale-3/-2; + margin-top: -$gl-spacing-scale-3; + width: 0; + height: 0; + border-top: $gl-spacing-scale-3 solid transparent; + border-bottom: $gl-spacing-scale-3 solid transparent; + border-right: $gl-spacing-scale-3 solid $black; + } + } + } +} + +// +// PAGE-LAYOUT +// + +.page-with-contextual-sidebar { + transition: padding-left $sidebar-transition-duration; + + @include media-breakpoint-up(md) { + padding-left: $contextual-sidebar-collapsed-width; + } + + @include media-breakpoint-up(xl) { + padding-left: $contextual-sidebar-width; + } + + .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { + padding: 10px 0 15px; + } +} + +.page-with-icon-sidebar { + @include media-breakpoint-up(md) { + padding-left: $contextual-sidebar-collapsed-width; + } +} + +// +// THE PANEL +// + +.nav-sidebar { + @include gl-fixed; + @include gl-bottom-0; + @include gl-left-0; + transition: width $sidebar-transition-duration, left $sidebar-transition-duration; + z-index: 600; + width: $contextual-sidebar-width; + top: $header-height; + background-color: $gray-50; + transform: translate3d(0, 0, 0); + + &.sidebar-collapsed-desktop { + @include collapse-contextual-sidebar; + } + + &.sidebar-expanded-mobile { + left: 0; + } + + a { + @include gl-text-decoration-none; + color: $top-level-item-color; + } + + li { + white-space: nowrap; + + .nav-item-name { + flex: 1; + } + + > a, + > .fly-out-top-item-container { + @include top-level-item; + } + + &.active { + > a { + font-weight: $gl-font-weight-bold; + } + + &:not(.fly-out-top-item) { + > a:not(.has-sub-items) { + background-color: $indigo-900-alpha-008; + } + } + } + } + + ul { + padding-left: 0; + list-style: none; + } + + @include media-breakpoint-down(sm) { + left: (-$contextual-sidebar-width); + } + + .nav-icon-container { + display: flex; + margin-right: 8px; + } + + a:not(.has-sub-items) + .sidebar-sub-level-items { + .fly-out-top-item { + @include fly-out-top-item($has-sub-items: false); + } + } + + a.has-sub-items + .sidebar-sub-level-items { + .fly-out-top-item { + @include fly-out-top-item($has-sub-items: true); + } + } + + @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) { + &:not(.sidebar-expanded-mobile) { + @include collapse-contextual-sidebar; + @include collapse-contextual-sidebar-content; + } + } +} + +.nav-sidebar-inner-scroll { + @include gl-h-full; + @include gl-w-full; + @include gl-overflow-auto; + + > div.context-header { + @include gl-mt-2; + + a { + @include top-level-item; + @include context-header; + } + } +} + +.sidebar-top-level-items { + @include gl-mt-2; + margin-bottom: 60px; + + .context-header a { + @include context-header; + } + + > li { + .badge.badge-pill { + @include gl-rounded-lg; + @include gl-py-1; + @include gl-px-3; + background-color: $blue-100; + color: $blue-700; + } + + &.active { + .sidebar-sub-level-items:not(.is-fly-out-only) { + display: block; + } + + .badge.badge-pill { + @include gl-font-weight-normal; // TODO: update in `theme_helper.scss` + color: $blue-700; // TODO: update in `theme_helper.scss` + } + } + } +} + +.sidebar-sub-level-items { + @include gl-py-0; + @include gl-display-none; + + &:not(.fly-out-list) { + li > a { + // The calculation formula: + // 12px: normal padding on the menu anchors + // + + // 16px: the width of the SVG icon in the top-level links + // + + // 8px: margin-right on the SVG icon in the top-level links + // = + // 36px (4.5 times the $grid-size) + padding-left: $grid-size * 4.5; + } + } +} + +.is-showing-fly-out { + @include sub-level-items-flyout; +} + +// +// COLLAPSED STATE +// + +.toggle-sidebar-button, +.close-nav-button { + @include side-panel-toggle; + background-color: $gray-50; + border-top: 1px solid $border-color; + color: $top-level-item-color; + position: fixed; + bottom: 0; + width: $contextual-sidebar-width; + + .collapse-text, + .icon-chevron-double-lg-left, + .icon-chevron-double-lg-right { + color: inherit; + } +} + +.collapse-text { + white-space: nowrap; + overflow: hidden; +} + +.sidebar-collapsed-desktop { + @include collapse-contextual-sidebar-content; +} + +// +// MOBILE PANEL +// + +.close-nav-button { + display: none; +} + +@include media-breakpoint-down(sm) { + .close-nav-button { + display: flex; + } + + .toggle-sidebar-button { + display: none; + } + + .mobile-overlay { + display: none; + + &.mobile-nav-open { + display: block; + position: fixed; + background-color: $black-transparent; + height: 100%; + width: 100%; + z-index: $zindex-dropdown-menu; + } + } +} + +// +// PANELS-SPECIFIC +// TODO: Check whether we can remove these in favor of the utility-classes +// + +.settings-avatar { + background-color: $white; + + svg { + fill: $gl-text-color-secondary; + margin: auto; + } +} + diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index a07e0b48cff..c0e9289309a 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -598,7 +598,9 @@ table.code { .diff-grid-left, .diff-grid-right { display: grid; - grid-template-columns: 50px 8px 1fr; + // Zero width column is a placeholder for the EE inline code quality diff + // see ee/.../diffs.scss for more details + grid-template-columns: 50px 8px 0 1fr; } .diff-grid-comments { @@ -628,7 +630,9 @@ table.code { .diff-grid-left, .diff-grid-right { - grid-template-columns: 50px 50px 8px 1fr; + // Zero width column is a placeholder for the EE inline code quality diff + // see ee/../diffs.scss for more details + grid-template-columns: 50px 50px 8px 0 1fr; } } } @@ -642,6 +646,10 @@ table.code { align-items: center; padding: 0 1rem; + .diff-stats-contents { + display: contents; + } + .diff-stats-group { padding: 0 0.25rem; } diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index 579a68ac8e4..40e11b50eba 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -4,7 +4,8 @@ .gfm-commit, .gfm-commit_range { - @extend .commit-sha; + @include gl-font-monospace; + font-size: 95%; } .gfm-project_member { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 7566a533911..8639b9a7f84 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -106,7 +106,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important &.menu-expanded { @include media-breakpoint-down(xs) { - .title-container { + .hide-when-menu-expanded { display: none; } @@ -665,3 +665,26 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important color: inherit !important; } } + +.top-nav-responsive { + @include gl-display-none; + color: var(--indigo-900, $theme-indigo-900); +} + +.top-nav-responsive-open { + .hide-when-top-nav-responsive-open { + @include media-breakpoint-down(xs) { + display: none !important; + } + } + + .top-nav-responsive { + @include media-breakpoint-down(xs) { + @include gl-display-block; + } + } + + .navbar-gitlab .header-content .title-container { + flex: 0; + } +} diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 73a2170fc68..b4a1d9f9977 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -33,6 +33,10 @@ padding-left: 10px; padding-right: 10px; white-space: pre; + + &:empty::before { + content: '\200b'; + } } } } @@ -46,7 +50,6 @@ a { font-family: $monospace-font; display: block; - font-size: $code-font-size !important; white-space: nowrap; i, diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 4f9896dd58a..e00bb83362a 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -38,8 +38,11 @@ body { } } -.content-wrapper { +.content-wrapper-margin { margin-top: $header-height; +} + +.content-wrapper { padding-bottom: 100px; } @@ -166,15 +169,8 @@ body { } .content-wrapper { - margin-top: 0; padding-bottom: 0; flex: 1; min-height: 0; } - - &.flash-shown { - .content-wrapper { - margin-top: 0; - } - } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index a3e8b2c245c..9fe9f9a845c 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -135,7 +135,7 @@ ul.content-list { float: right; > .control-text { - margin-right: $gl-padding-top; + margin-right: $grid-size; line-height: $list-text-height; &:last-child { @@ -148,8 +148,6 @@ ul.content-list { > .dropdown.inline { margin-right: $grid-size; display: inline-block; - margin-top: 3px; - margin-bottom: 4px; &.btn-ldap-override { @include media-breakpoint-up(sm) { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 1e2fc1445e8..fcf86680bb3 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -446,3 +446,19 @@ } } } + +@mixin side-panel-toggle { + transition: width $sidebar-transition-duration; + height: $toggle-sidebar-height; + padding: 0 $gl-padding; + background-color: $gray-light; + border: 0; + color: $gl-text-color-secondary; + display: flex; + align-items: center; + + &:hover { + background-color: $border-color; + color: $gl-text-color; + } +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index cb8a0c40f7f..e35feb8c62d 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -232,3 +232,8 @@ } } } + +.toggle-right-sidebar-button { + @include side-panel-toggle; + border-bottom: 1px solid $border-color; +} diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index 10796f319bf..437915d5034 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -52,7 +52,7 @@ top: $system-header-height + $header-height; } - .content-wrapper { + .content-wrapper-margin { margin-top: $system-header-height + $header-height; } @@ -90,7 +90,7 @@ bottom: $system-footer-height; } - .content-wrapper { + .content-wrapper-margin { margin-bottom: 16px; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index bfb21d7112b..d3976cfa8c7 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -9,7 +9,7 @@ $sidebar-transition-duration: 0.3s; $sidebar-breakpoint: 1024px; $default-transition-duration: 0.15s; $contextual-sidebar-width: 220px; -$contextual-sidebar-collapsed-width: 50px; +$contextual-sidebar-collapsed-width: 48px; $toggle-sidebar-height: 48px; /** @@ -573,6 +573,9 @@ $inactive-badge-background: rgba($black, 0.08); $sidebar-toggle-height: 60px; $sidebar-toggle-width: 40px; $sidebar-milestone-toggle-bottom-margin: 10px; +$sidebar-avatar-size: 32px; +$sidebar-top-item-lr-margin: 4px; +$sidebar-top-item-tb-margin: 1px; /* * Buttons @@ -714,6 +717,18 @@ $job-line-number-margin: 43px; $job-arrow-margin: 55px; /* + * Calendar + */ +// See https://gitlab.com/gitlab-org/gitlab/-/issues/332150 to align with Pajamas Design System +$calendar-activity-colors: ( + #ededed, + #acd5f2, + #7fa8c9, + #527ba0, + #254e77, +); + +/* * Commit Page */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index d6523265a43..2d180f49f97 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -131,6 +131,7 @@ $dark-il: #de935f; .diff-td.diff-line-num.hll:not(.empty-cell), .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line-codequality.hll:not(.empty-cell), .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), @@ -145,6 +146,7 @@ $dark-il: #de935f; .diff-line-num.new, .line-coverage.new, + .line-codequality.new, .line_content.new { @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border); @@ -156,6 +158,7 @@ $dark-il: #de935f; .diff-line-num.old, .line-coverage.old, + .line-codequality.old, .line_content.old { @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border); diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 027f2fa63d3..c0931188cc3 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -132,6 +132,7 @@ $monokai-gh: #75715e; .diff-td.diff-line-num.hll:not(.empty-cell), .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line-codequality.hll:not(.empty-cell), .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), @@ -146,6 +147,7 @@ $monokai-gh: #75715e; .diff-line-num.new, .line-coverage.new, + .line-codequality.new, .line_content.new { @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); @@ -157,6 +159,7 @@ $monokai-gh: #75715e; .diff-line-num.old, .line-coverage.old, + .line-codequality.old, .line_content.old { @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 5002726bbc5..ef7eb244b61 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -56,7 +56,10 @@ .line-coverage { @include line-coverage-border-color($green-500, $orange-500); + } + .line-coverage, + .line-codequality { &.old, &.new { background-color: $white-normal; diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index cd0cb65e4e2..8f09a178af1 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -135,6 +135,7 @@ $solarized-dark-il: #2aa198; .diff-td.diff-line-num.hll:not(.empty-cell), .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line-codequality.hll:not(.empty-cell), .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), @@ -156,6 +157,7 @@ $solarized-dark-il: #2aa198; .diff-line-num.new, .line-coverage.new, + .line-codequality.new, .line_content.new { @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); @@ -167,6 +169,7 @@ $solarized-dark-il: #2aa198; .diff-line-num.old, .line-coverage.old, + .line-codequality.old, .line_content.old { @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 77e88053424..747cc639f91 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -142,6 +142,7 @@ $solarized-light-il: #2aa198; .diff-td.diff-line-num.hll:not(.empty-cell), .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line-codequality.hll:not(.empty-cell), .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), @@ -156,6 +157,7 @@ $solarized-light-il: #2aa198; .diff-line-num.new, .line-coverage.new, + .line-codequality.new, .line_content.new { @include diff-background($solarized-light-new-bg, $solarized-light-new-idiff, $solarized-light-border); @@ -175,6 +177,7 @@ $solarized-light-il: #2aa198; .diff-line-num.old, .line-coverage.old, + .line-codequality.old, .line_content.old { @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 18b2f0a5d58..86b01926dd7 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -219,7 +219,10 @@ pre.code, .line-coverage { @include line-coverage-border-color($green-400, $red-400); + } + .line-coverage, + .line-codequality { &.old { background-color: $line-removed; } diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index 4f76deeb991..9d889f111dd 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -145,6 +145,27 @@ table.content { padding: 15px 5px; text-align: center; } + + td.mailer-align-left { + vertical-align: top; + padding: 16px 32px; + text-align: left; + + h4 { + margin: 0; + } + + ul { + list-style: none; + line-height: 1.6; + padding-left: 0; + margin: 8px 0 16px; + } + + .mailer-icon { + margin-bottom: -1px; + } + } } tr.footer td { diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss index 2742c95c6e1..2248d95ae24 100644 --- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss +++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss @@ -30,32 +30,12 @@ .col-headers { ul { - @include clearfix; margin: 0; padding: 0; } li { - display: inline-block; - float: left; line-height: 50px; - width: 20%; - } - - .stage-header { - width: 20.5%; - } - - .median-header { - width: 19.5%; - } - - .event-header { - width: 45%; - } - - .total-time-header { - width: 15%; } } @@ -120,7 +100,6 @@ } li { - @include clearfix; list-style-type: none; } @@ -169,7 +148,6 @@ .events-description { line-height: 65px; - padding: 0 $gl-padding; } .events-info { @@ -178,7 +156,6 @@ } .stage-events { - width: 60%; min-height: 467px; } @@ -190,8 +167,8 @@ .stage-event-item { @include clearfix; list-style-type: none; - padding: 0 0 $gl-padding; - margin: 0 $gl-padding $gl-padding; + padding-bottom: $gl-padding; + margin-bottom: $gl-padding; border-bottom: 1px solid var(--gray-50, $gray-50); &:last-child { diff --git a/app/assets/stylesheets/page_bundles/escalation_policies.scss b/app/assets/stylesheets/page_bundles/escalation_policies.scss new file mode 100644 index 00000000000..f188dde1183 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/escalation_policies.scss @@ -0,0 +1,37 @@ +@import 'mixins_and_variables_and_functions'; + +.escalation-policy-modal { + width: 640px; +} + +.rule-control { + width: 240px; +} + +.rule-close-icon { + right: 1rem; +} + +$stroke-size: 1px; + +.right-arrow { + @include gl-relative; + @include gl-mx-5; + @include gl-display-inline-block; + @include gl-vertical-align-middle; + height: $stroke-size; + background-color: var(--gray-900, $gray-900); + min-width: $gl-spacing-scale-7; + + &-head { + @include gl-absolute; + top: -2*$stroke-size; + left: calc(100% - #{5*$stroke-size}); + @include gl-display-inline-block; + @include gl-p-1; + @include gl-border-solid; + border-width: 0 $stroke-size $stroke-size 0; + border-color: var(--gray-900, $gray-900); + transform: rotate(-45deg); + } +} diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss new file mode 100644 index 00000000000..38dd07f617c --- /dev/null +++ b/app/assets/stylesheets/page_bundles/group.scss @@ -0,0 +1,107 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + +.group-home-panel { + margin-top: $gl-padding; + margin-bottom: $gl-padding; + + .home-panel-avatar { + width: $home-panel-title-row-height; + height: $home-panel-title-row-height; + flex-shrink: 0; + flex-basis: $home-panel-title-row-height; + } + + .home-panel-title { + font-size: 20px; + line-height: $gl-line-height-24; + font-weight: bold; + + .icon { + vertical-align: -1px; + } + + .home-panel-topic-list { + font-size: $gl-font-size; + font-weight: $gl-font-weight-normal; + + .icon { + position: relative; + top: 3px; + margin-right: $gl-padding-4; + } + } + } + + .home-panel-title-row { + @include media-breakpoint-down(sm) { + .home-panel-avatar { + width: $home-panel-avatar-mobile-size; + height: $home-panel-avatar-mobile-size; + flex-basis: $home-panel-avatar-mobile-size; + + .avatar { + font-size: 20px; + line-height: 46px; + } + } + + .home-panel-title { + margin-top: 4px; + margin-bottom: 2px; + font-size: $gl-font-size; + line-height: $gl-font-size-large; + } + + .home-panel-topic-list, + .home-panel-metadata { + font-size: $gl-font-size-small; + } + } + } + + .home-panel-metadata { + font-weight: normal; + font-size: 14px; + line-height: $gl-btn-line-height; + } + + .home-panel-description { + @include media-breakpoint-up(md) { + font-size: $gl-font-size-large; + } + } +} + +.group-nav-container .nav-controls { + .group-filter-form { + flex: 1 1 auto; + margin-right: $gl-padding-8; + } + + .dropdown-menu-right { + margin-top: 0; + } + + @include media-breakpoint-down(sm) { + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + display: block; + } + + .group-filter-form, + .dropdown { + margin-bottom: 10px; + margin-right: 0; + } + + &, + .group-filter-form, + .group-filter-form-field, + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + width: 100%; + } + } +} diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index ace91d197b6..1081dd8f6d8 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -116,7 +116,7 @@ position: absolute; right: -7px; top: 11px; - border-bottom: 2px solid $border-color; + border-bottom: 2px solid var(--border-color, $border-color); } } diff --git a/app/assets/stylesheets/page_bundles/project.scss b/app/assets/stylesheets/page_bundles/project.scss new file mode 100644 index 00000000000..7f044f081d4 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/project.scss @@ -0,0 +1,82 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + +.project-home-panel { + .home-panel-avatar { + flex-basis: $home-panel-title-row-height; + } + + .home-panel-title { + .icon { + vertical-align: -1px; + } + + .home-panel-topic-list { + .icon { + top: 3px; + } + } + } + + .home-panel-title-row { + @include media-breakpoint-down(sm) { + .home-panel-avatar { + width: $home-panel-avatar-mobile-size; + height: $home-panel-avatar-mobile-size; + flex-basis: $home-panel-avatar-mobile-size; + + .avatar { + font-size: 20px; + line-height: 46px; + } + } + + .home-panel-title { + margin-top: 4px; + margin-bottom: 2px; + font-size: $gl-font-size; + line-height: $gl-font-size-large; + } + } + } + + .home-panel-description { + @include media-breakpoint-up(md) { + font-size: $gl-font-size-large; + } + } +} + +.project-repo-buttons { + .btn { + svg { + fill: $gray-500; + } + } + + .download-button { + @include media-breakpoint-down(md) { + margin-left: 0; + } + } + + .project-clone-holder { + display: inline-block; + margin: $gl-padding 0 0; + + input { + height: $input-height; + } + } + + .clone-options-dropdown { + min-width: 240px; + + .dropdown-menu-inner-content { + min-width: 320px; + } + } + + .mobile-git-clone { + margin-top: $gl-padding-8; + } +} diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index 9f0fa137910..5525ad66e42 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -1,4 +1,5 @@ @import 'mixins_and_variables_and_functions'; +@import 'highlight.js/scss/a11y-light'; .title .edit-wiki-header { width: 780px; diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 2ec2da9241b..ca6c9b9a073 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -1,7 +1,3 @@ -.milestone-row { - @include str-truncated(90%); -} - .dashboard .side .card .card-header .input-group { .form-control { height: 42px; @@ -49,195 +45,6 @@ color: $gray-700; } -.group-nav-container .nav-controls { - .group-filter-form { - flex: 1 1 auto; - margin-right: $gl-padding-8; - } - - .dropdown-menu-right { - margin-top: 0; - } - - @include media-breakpoint-down(sm) { - .dropdown, - .dropdown .dropdown-toggle, - .btn-success { - display: block; - } - - .group-filter-form, - .dropdown { - margin-bottom: 10px; - margin-right: 0; - } - - &, - .group-filter-form, - .group-filter-form-field, - .dropdown, - .dropdown .dropdown-toggle, - .btn-success { - width: 100%; - } - } -} - -.group-home-panel { - margin-top: $gl-padding; - margin-bottom: $gl-padding; - - .home-panel-avatar { - width: $home-panel-title-row-height; - height: $home-panel-title-row-height; - flex-shrink: 0; - flex-basis: $home-panel-title-row-height; - } - - .home-panel-title { - font-size: 20px; - line-height: $gl-line-height-24; - font-weight: bold; - - .icon { - vertical-align: -1px; - } - - .home-panel-topic-list { - font-size: $gl-font-size; - font-weight: $gl-font-weight-normal; - - .icon { - position: relative; - top: 3px; - margin-right: $gl-padding-4; - } - } - } - - .home-panel-title-row { - @include media-breakpoint-down(sm) { - .home-panel-avatar { - width: $home-panel-avatar-mobile-size; - height: $home-panel-avatar-mobile-size; - flex-basis: $home-panel-avatar-mobile-size; - - .avatar { - font-size: 20px; - line-height: 46px; - } - } - - .home-panel-title { - margin-top: 4px; - margin-bottom: 2px; - font-size: $gl-font-size; - line-height: $gl-font-size-large; - } - - .home-panel-topic-list, - .home-panel-metadata { - font-size: $gl-font-size-small; - } - } - } - - .home-panel-metadata { - font-weight: normal; - font-size: 14px; - line-height: $gl-btn-line-height; - } - - .home-panel-description { - @include media-breakpoint-up(md) { - font-size: $gl-font-size-large; - } - } -} - -.home-panel-buttons { - .home-panel-action-button { - vertical-align: top; - } - - .new-project-subgroup { - .dropdown-primary { - min-width: 115px; - } - - .dropdown-toggle { - .dropdown-btn-icon { - pointer-events: none; - color: inherit; - margin-left: 0; - } - } - - .dropdown-menu { - min-width: 280px; - margin-top: 2px; - } - - li:not(.divider) { - padding: 0; - - &.droplab-item-selected { - .icon-container { - .list-item-checkmark { - visibility: visible; - } - } - } - - .menu-item { - padding: 8px 4px; - - &:hover { - background-color: $gray-darker; - color: $gray-900; - } - } - - .icon-container { - float: left; - padding-left: 6px; - - .list-item-checkmark { - visibility: hidden; - } - } - - .description { - font-size: 14px; - - strong { - display: block; - font-weight: $gl-font-weight-bold; - } - } - - @include media-breakpoint-down(sm) { - display: flex; - align-items: flex-start; - - .dropdown-primary { - flex: 1; - } - - .dropdown-toggle { - width: auto; - } - - .dropdown-menu { - width: 100%; - max-width: inherit; - min-width: inherit; - } - } - } - } -} - .card { .shared_runners_limit_under_quota { color: $green-500; @@ -269,28 +76,10 @@ } } -.user-settings-pipeline-quota { - margin-top: $gl-padding; - - .pipeline-quota { - border-top: 0; - } -} - table.pipeline-project-metrics tr td { padding: $gl-padding; } -.mattermost-team-name { - color: $gl-text-color-secondary; -} - -.mattermost-info { - display: block; - color: $gl-text-color-secondary; - margin-top: 10px; -} - .explore-groups.landing { .inner-content { padding: 0; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0437fa19752..1bc6dfbd84a 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -67,10 +67,6 @@ .emoji-block { padding: $gl-padding-4 0; - - @include media-breakpoint-down(md) { - padding: $gl-padding-8 0; - } } } @@ -271,11 +267,6 @@ .value { line-height: 1; - - .assign-yourself { - margin-top: 10px; - display: block; - } } .issuable-sidebar { @@ -292,10 +283,6 @@ } } - .assign-yourself .btn-link { - padding-left: 0; - } - .light { font-weight: $gl-font-weight-normal; } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 97c8182bab8..461d6a29b3a 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -130,10 +130,6 @@ ul.related-merge-requests > li { &:not(:only-child) { margin-right: $gl-padding-8; } - - @include media-breakpoint-down(md) { - margin-top: $gl-padding-8; - } } } @@ -154,10 +150,6 @@ ul.related-merge-requests > li { .btn-group:not(.hidden) { display: flex; - - @include media-breakpoint-down(md) { - margin-top: $gl-padding-8; - } } .js-create-merge-request { diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 9d437531e6d..b537a46a6f2 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -106,6 +106,10 @@ width: 100%; } } + + .omniauth-btn { + width: 100%; + } } .new-session-tabs { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 36d39c1a613..1abaff40bc9 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -43,8 +43,6 @@ $tabs-holder-z-index: 250; } .mr-widget-section { - border-radius: $border-radius-default $border-radius-default 0 0; - .code-text { flex: 1; } @@ -88,7 +86,6 @@ $tabs-holder-z-index: 250; .mr-section-container { border: 1px solid $border-color; border-radius: $border-radius-default; - border-top: 0; background: var(--white, $white); } @@ -110,11 +107,15 @@ $tabs-holder-z-index: 250; border-radius: $border-radius-default; } - .mr-widget-section, + .mr-widget-section:not(:first-child), .mr-widget-footer { border-top: solid 1px $border-color; } + .mr-widget-alert-container + .mr-widget-section { + border-top: 0; + } + .mr-fast-forward-message { padding-left: $gl-padding-50; padding-bottom: $gl-padding; @@ -525,9 +526,7 @@ $tabs-holder-z-index: 250; .mr-source-target { flex-wrap: wrap; - border-radius: $border-radius-default; padding: $gl-padding; - border: 1px solid $border-color; background: var(--white, $white); min-height: $mr-widget-min-height; @@ -1027,3 +1026,13 @@ $tabs-holder-z-index: 250; vertical-align: middle; } } + +.mr-widget-alert-container { + $radius: $border-radius-default - 1px; + + border-radius: $radius $radius 0 0; + + .gl-alert:not(:last-child) { + margin-bottom: 1px; + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 6a2fa2ee7a1..b52a3c445b5 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -5,6 +5,12 @@ } .avatar-image { + margin-bottom: $grid-size; + + .avatar { + float: none; + } + @include media-breakpoint-up(sm) { float: left; margin-bottom: 0; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index dfd64d0773c..c330e0709a6 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -79,91 +79,10 @@ } } -.project-home-panel { - .home-panel-avatar { - flex-basis: $home-panel-title-row-height; - } - - .home-panel-title { - .icon { - vertical-align: -1px; - } - - .home-panel-topic-list { - .icon { - top: 3px; - } - } - } - - .home-panel-title-row { - @include media-breakpoint-down(sm) { - .home-panel-avatar { - width: $home-panel-avatar-mobile-size; - height: $home-panel-avatar-mobile-size; - flex-basis: $home-panel-avatar-mobile-size; - - .avatar { - font-size: 20px; - line-height: 46px; - } - } - - .home-panel-title { - margin-top: 4px; - margin-bottom: 2px; - font-size: $gl-font-size; - line-height: $gl-font-size-large; - } - } - } - - .home-panel-description { - @include media-breakpoint-up(md) { - font-size: $gl-font-size-large; - } - } -} - .nav > .project-buttons { margin-top: 0; } -.project-repo-buttons { - .btn { - svg { - fill: $gray-500; - } - } - - .download-button { - @include media-breakpoint-down(md) { - margin-left: 0; - } - } - - .project-clone-holder { - display: inline-block; - margin: $gl-padding 0 0; - - input { - height: $input-height; - } - } - - .clone-options-dropdown { - min-width: 240px; - - .dropdown-menu-inner-content { - min-width: 320px; - } - } - - .mobile-git-clone { - margin-top: $gl-padding-8; - } -} - .save-project-loader { margin-top: 50px; margin-bottom: 50px; @@ -855,13 +774,6 @@ pre.light-well { } } -.project-tip-command { - > .input-group-prepend:first-child, - > .input-group-append:first-child { - width: auto; - } -} - .protected-branches-list, .protected-tags-list { margin-bottom: 30px; @@ -887,8 +799,7 @@ pre.light-well { } } -.project-refs-form .dropdown-menu, -.dropdown-menu-projects { +.project-refs-form .dropdown-menu { width: 300px; @include media-breakpoint-up(sm) { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index cd99c667001..5fbb2e6443f 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -45,7 +45,7 @@ input[type='checkbox']:hover { margin: 0 8px; form { - @extend .form-control; + display: block; margin: 0; padding: 4px; width: $search-input-width; @@ -139,7 +139,6 @@ input[type='checkbox']:hover { &.search-active { form { - @extend .form-control:focus; border-color: $blue-300; box-shadow: none; @@ -297,6 +296,16 @@ input[type='checkbox']:hover { @include str-truncated(10em); } +.global-search-dropdown-menu { + width: 100% !important; + max-width: 400px; + + @include media-breakpoint-up(md) { + // This is larger than the container so width: 100% doesn't work. + width: 400px !important; + } +} + // Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling /* stylelint-disable property-no-vendor-prefix */ input[type='search']::-webkit-search-decoration, diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index a371aa37e07..c6198315606 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -10,6 +10,10 @@ color: $gl-text-color-secondary; } + .tree-ref-holder { + margin-right: 15px; + } + @include media-breakpoint-up(sm) { display: flex; @@ -28,7 +32,6 @@ .tree-ref-holder { float: left; - margin-right: 15px; } .tree-ref-target-holder { @@ -44,8 +47,11 @@ } @include media-breakpoint-down(xs) { + .tree-ref-container { + justify-content: space-between; + } + .repo-breadcrumb { - margin-top: 10px; position: relative; .dropdown-menu { diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index bcc3c35e00e..f2874e67796 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -11,47 +11,40 @@ height: $performance-bar-height; background: $black; line-height: $performance-bar-height; - color: $gray-300; + color: $gray-100; select { - color: $white; width: 200px; } input { - color: $gray-300; width: $input-short-width - 60px; } + select, + input { + color: inherit; + background-color: inherit; + } + + option { + color: initial; + } + &.disabled { display: none; } &.production { background-color: $perf-bar-production; - - select, - input { - background: $perf-bar-production; - } } &.staging { background-color: $perf-bar-staging; - - select, - input { - background: $perf-bar-staging; - } } &.development { background-color: $perf-bar-development; - - select, - input { - background: $perf-bar-development; - } } // UI Elements @@ -61,7 +54,6 @@ padding: 4px 6px; font-family: Consolas, 'Liberation Mono', Courier, monospace; line-height: 1; - color: $gray-100; border-radius: 3px; box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, inset 0 1px 2px $perf-bar-bucket-box-shadow-to; @@ -135,3 +127,7 @@ #modal-peek-pg-queries-content { color: $black; } + +html.with-performance-bar .nav-sidebar { + top: $header-height + $performance-bar-height; +} diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 1d0333d1e2f..ab86a2f69dd 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -65,6 +65,6 @@ a[href]::after { margin-top: 0; } -.content-wrapper { +.content-wrapper-margin { margin-top: 0; } diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index e5d5ed0d48f..ad040f65f3c 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -45,7 +45,6 @@ .blob-content { pre { - height: 100%; padding: 10px; border: 0; border-radius: 0; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 9f7a8860e4d..c6f0b3a2ba7 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -1,4 +1,22 @@ +// DO NOT EDIT! This is auto-generated from "yarn run generate:startup_css" +// Please see the feedback issue for more details and help: +// https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; +body.gl-dark { + --gray-50: #303030; + --gray-100: #404040; + --gray-950: #fff; + --green-100: #0d532a; + --green-400: #108548; + --green-700: #91d4a8; + --blue-400: #1f75cb; + --orange-400: #ab6100; + --gl-text-color: #fafafa; + --border-color: #4f4f4f; +} +:root { + --white: #333; +} *, *::before, *::after { @@ -8,68 +26,45 @@ html { font-family: sans-serif; line-height: 1.15; } - header, nav, section { +aside, +header { display: block; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; color: #fafafa; text-align: left; - background-color: #2e2e2e; + background-color: #1f1f1f; } -h1, h2, h3 { +h1 { margin-top: 0; margin-bottom: 0.25rem; } -p { - margin-top: 0; - margin-bottom: 1rem; -} - ul { margin-top: 0; margin-bottom: 1rem; } - ul ul { margin-bottom: 0; } - strong { font-weight: bolder; } -sub { - position: relative; - font-size: 75%; - line-height: 0; - vertical-align: baseline; -} -sub { - bottom: -.25em; -} a { color: #007bff; text-decoration: none; background-color: transparent; } -a:not([href]) { +a:not([href]):not([class]) { color: inherit; text-decoration: none; } -pre, -code { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - font-size: 1em; -} -pre { - margin-top: 0; - margin-bottom: 1rem; - overflow: auto; -} img { vertical-align: middle; border-style: none; @@ -78,18 +73,11 @@ svg { overflow: hidden; vertical-align: middle; } -table { - border-collapse: collapse; -} -th { - text-align: inherit; -} button { border-radius: 0; } input, -button, -textarea { +button { margin: 0; font-family: inherit; font-size: inherit; @@ -102,103 +90,34 @@ input { button { text-transform: none; } +[role="button"] { + cursor: pointer; +} button:not(:disabled), -[type="button"]:not(:disabled), -[type="reset"]:not(:disabled) { +[type="button"]:not(:disabled) { cursor: pointer; } button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner { +[type="button"]::-moz-focus-inner { padding: 0; border-style: none; } -textarea { - overflow: auto; - resize: vertical; -} [type="search"] { outline-offset: -2px; } -summary { - display: list-item; - cursor: pointer; -} -template { - display: none; -} -[hidden] { - display: none !important; -} -h1, h2, h3, -.h1, .h2, .h3 { +h1 { margin-bottom: 0.25rem; font-weight: 600; line-height: 1.2; color: #fafafa; } -h1, .h1 { +h1 { font-size: 2.1875rem; } -h2, .h2 { - font-size: 1.75rem; -} -h3, .h3 { - font-size: 1.53125rem; -} .list-unstyled { padding-left: 0; list-style: none; } -code { - font-size: 90%; - color: #fff; - word-wrap: break-word; -} -a > code { - color: inherit; -} -pre { - display: block; - font-size: 90%; - color: #fafafa; -} -pre code { - font-size: inherit; - color: inherit; - word-break: normal; -} -.container { - width: 100%; - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} - -@media (min-width: 576px) { - .container { - max-width: 540px; - } -} - -@media (min-width: 768px) { - .container { - max-width: 720px; - } -} - -@media (min-width: 992px) { - .container { - max-width: 960px; - } -} - -@media (min-width: 1200px) { - .container { - max-width: 1140px; - } -} .container-fluid { width: 100%; padding-right: 15px; @@ -206,48 +125,7 @@ pre code { margin-right: auto; margin-left: auto; } - -@media (min-width: 576px) { - .container { - max-width: 540px; - } -} - -@media (min-width: 768px) { - .container { - max-width: 720px; - } -} - -@media (min-width: 992px) { - .container { - max-width: 960px; - } -} - -@media (min-width: 1200px) { - .container { - max-width: 1140px; - } -} -.row { - display: flex; - flex-wrap: wrap; - margin-right: -15px; - margin-left: -15px; -} -.table { - width: 100%; - margin-bottom: 0.5rem; - color: #fafafa; -} -.table th, -.table td { - padding: 0.75rem; - vertical-align: top; - border-top: 1px solid #4f4f4f; -} - .search form { +.form-control { display: block; width: 100%; height: 34px; @@ -256,24 +134,27 @@ pre code { font-weight: 400; line-height: 1.5; color: #fafafa; - background-color: #4f4f4f; + background-color: #333; background-clip: padding-box; - border: 1px solid #4f4f4f; + border: 1px solid #404040; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } - .search form:-moz-focusring { +.form-control:-moz-focusring { color: transparent; text-shadow: 0 0 0 #fafafa; } - .search form::placeholder { - color: #ccc; +.form-control::-ms-input-placeholder { + color: #bfbfbf; + opacity: 1; +} +.form-control::placeholder { + color: #bfbfbf; opacity: 1; } - .search form:disabled { - background-color: #2e2e2e; +.form-control:disabled { + background-color: #303030; opacity: 1; } .form-inline { @@ -281,9 +162,8 @@ pre code { flex-flow: row wrap; align-items: center; } - @media (min-width: 576px) { - .form-inline .search form, .search .form-inline form { + .form-inline .form-control { display: inline-block; width: auto; vertical-align: middle; @@ -295,7 +175,7 @@ pre code { color: #fafafa; text-align: center; vertical-align: middle; - cursor: pointer; + -moz-user-select: none; user-select: none; background-color: transparent; border: 1px solid transparent; @@ -304,26 +184,24 @@ pre code { line-height: 20px; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } -.btn.disabled, .btn:disabled { +.btn:disabled { opacity: 0.65; } -a.btn.disabled { - pointer-events: none; +.btn:not(:disabled):not(.disabled) { + cursor: pointer; } .collapse:not(.show) { display: none; } - .dropdown { position: relative; } - .dropdown-menu-toggle { +.dropdown-menu-toggle { white-space: nowrap; } - .dropdown-menu-toggle::after { +.dropdown-menu-toggle::after { display: inline-block; margin-left: 0.255em; vertical-align: 0.255em; @@ -333,7 +211,7 @@ a.btn.disabled { border-bottom: 0; border-left: 0.3em solid transparent; } - .dropdown-menu-toggle:empty::after { +.dropdown-menu-toggle:empty::after { margin-left: 0; } .dropdown-menu { @@ -355,19 +233,6 @@ a.btn.disabled { border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 0.25rem; } -.dropdown-menu-right { - right: 0; - left: auto; -} - .divider { - height: 0; - margin: 4px 0; - overflow: hidden; - border-top: 1px solid #4f4f4f; -} -.dropdown-menu.show { - display: block; -} .nav { display: flex; flex-wrap: wrap; @@ -383,7 +248,6 @@ a.btn.disabled { justify-content: space-between; padding: 0.25rem 0.5rem; } -.navbar .container, .navbar .container-fluid { display: flex; flex-wrap: wrap; @@ -414,15 +278,12 @@ a.btn.disabled { border: 1px solid transparent; border-radius: 0.25rem; } - @media (max-width: 575.98px) { - .navbar-expand-sm > .container, .navbar-expand-sm > .container-fluid { padding-right: 0; padding-left: 0; } } - @media (min-width: 576px) { .navbar-expand-sm { flex-flow: row nowrap; @@ -434,7 +295,6 @@ a.btn.disabled { .navbar-expand-sm .navbar-nav .dropdown-menu { position: absolute; } - .navbar-expand-sm > .container, .navbar-expand-sm > .container-fluid { flex-wrap: nowrap; } @@ -446,17 +306,6 @@ a.btn.disabled { display: none; } } -.card { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - word-wrap: break-word; - background-color: #333; - background-clip: border-box; - border: 1px solid #4f4f4f; - border-radius: 0.25rem; -} .badge { display: inline-block; padding: 0.25em 0.4em; @@ -468,7 +317,6 @@ a.btn.disabled { vertical-align: baseline; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } .badge:empty { @@ -483,66 +331,8 @@ a.btn.disabled { padding-left: 0.6em; border-radius: 10rem; } -.media { - display: flex; - align-items: flex-start; -} -.close { - float: right; - font-size: 1.5rem; - font-weight: 600; - line-height: 1; - color: #fff; - text-shadow: 0 1px 0 #333; - opacity: .5; -} -button.close { - padding: 0; - background-color: transparent; - border: 0; - appearance: none; -} -a.close.disabled { - pointer-events: none; -} -.modal-dialog { - position: relative; - width: auto; - margin: 0.5rem; - pointer-events: none; -} - -@media (min-width: 576px) { - .modal-dialog { - max-width: 500px; - margin: 1.75rem auto; - } -} -.bg-transparent { - background-color: transparent !important; -} -.border { - border: 1px solid #4f4f4f !important; -} -.border-top { - border-top: 1px solid #4f4f4f !important; -} -.border-right { - border-right: 1px solid #4f4f4f !important; -} -.border-bottom { - border-bottom: 1px solid #4f4f4f !important; -} -.border-left { - border-left: 1px solid #4f4f4f !important; -} -.rounded { - border-radius: 0.25rem !important; -} -.clearfix::after { - display: block; - clear: both; - content: ""; +.rounded-circle { + border-radius: 50% !important; } .d-none { display: none !important; @@ -553,19 +343,19 @@ a.close.disabled { .d-block { display: block !important; } - @media (min-width: 576px) { .d-sm-none { display: none !important; } + .d-sm-inline-block { + display: inline-block !important; + } } - @media (min-width: 768px) { .d-md-block { display: block !important; } } - @media (min-width: 992px) { .d-lg-none { display: none !important; @@ -574,18 +364,11 @@ a.close.disabled { display: block !important; } } - @media (min-width: 1200px) { .d-xl-block { display: block !important; } } -.flex-wrap { - flex-wrap: wrap !important; -} -.float-right { - float: right !important; -} .sr-only { position: absolute; width: 1px; @@ -600,72 +383,57 @@ a.close.disabled { .m-auto { margin: auto !important; } -.text-nowrap { - white-space: nowrap !important; +.gl-button { + display: inline-flex; } -.visible { - visibility: visible !important; +.gl-button:not(.btn-link):active { + text-decoration: none; } - .search form.focus { +.gl-button.gl-button { + border-width: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + background-color: transparent; + line-height: 1rem; color: #fafafa; - background-color: #4f4f4f; - border-color: #80bdff; - outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); -} -.gl-badge { - display: inline-flex; + fill: currentColor; + box-shadow: inset 0 0 0 1px #525252; + justify-content: center; align-items: center; - font-size: 0.75rem; - font-weight: 400; - line-height: 1rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; + font-size: 0.875rem; + border-radius: 0.25rem; +} +.gl-button.gl-button.btn-default { + background-color: #333; +} +.gl-button.gl-button.btn-default:active, +.gl-button.gl-button.btn-default.active { + box-shadow: inset 0 0 0 2px #bfbfbf, 0 0 0 1px rgba(51, 51, 51, 0.4), + 0 0 0 4px rgba(66, 143, 220, 0.48); outline: none; + background-color: #404040; } -body, .search form, +body, +.form-control, .search form { font-size: 0.875rem; } button, -html [type='button'], -[type='reset'], -[role='button'] { +html [type="button"], +[role="button"] { cursor: pointer; } -h1, -.h1, -h2, -.h2, -h3, -.h3 { +h1 { margin-top: 20px; margin-bottom: 10px; } -input[type='file'] { - line-height: 1; -} - strong { font-weight: bold; } a { - color: #418cd8; -} -code { - padding: 2px 4px; - color: #fff; - background-color: #2e2e2e; - border-radius: 4px; -} -.code > code { - background-color: inherit; - padding: unset; -} -table { - border-spacing: 0; + color: #63a6e9; } .hidden { display: none !important; @@ -674,7 +442,7 @@ table { .hide { display: none; } - .dropdown-menu-toggle::after { +.dropdown-menu-toggle::after { display: none; } .badge:not(.gl-badge) { @@ -684,13 +452,16 @@ table { font-weight: 400; display: inline-block; } -pre code { - white-space: pre-wrap; +.divider { + height: 0; + margin: 4px 0; + overflow: hidden; + border-top: 1px solid #404040; } .toggle-sidebar-button .collapse-text, .toggle-sidebar-button .icon-chevron-double-lg-left, .toggle-sidebar-button .icon-chevron-double-lg-right { - color: #bababa; + color: #999; } svg { vertical-align: baseline; @@ -701,42 +472,23 @@ html { body { text-decoration-skip: ink; } -.content-wrapper { - margin-top: 40px; - padding-bottom: 100px; -} -.container { - padding-top: 0; - z-index: 5; -} -.container .content { - margin: 0; -} - -@media (max-width: 575.98px) { - .container .content { - margin-top: 20px; - } -} - -@media (max-width: 575.98px) { - .container .container .title { - padding-left: 15px !important; - } -} .btn { border-radius: 4px; font-size: 0.875rem; font-weight: 400; padding: 6px 10px; background-color: #333; - border-color: #4f4f4f; + border-color: #404040; color: #fafafa; color: #fafafa; white-space: nowrap; } -.btn:active, .btn.active { - box-shadow: rgba(0, 0, 0, 0.16); +.btn:active { + background-color: #303030; + box-shadow: none; +} +.btn:active, +.btn.active { background-color: #444; border-color: #fafafa; color: #fafafa; @@ -745,112 +497,44 @@ body { height: 15px; width: 15px; } -.btn svg:not(:last-child), -.btn .fa:not(:last-child) { +.btn svg:not(:last-child) { margin-right: 5px; } .badge.badge-pill:not(.gl-badge) { font-weight: 400; - background-color: rgba(0, 0, 0, 0.07); - color: #dfdfdf; + background-color: rgba(255, 255, 255, 0.07); + color: #dbdbdb; vertical-align: baseline; } -.hint { - font-style: italic; - color: #707070; -} -.bold { - font-weight: 600; -} -pre.wrap { - word-break: break-word; - white-space: pre-wrap; -} -table a code { - position: relative; - top: -2px; - margin-right: 3px; -} -.loading { - margin: 20px auto; - height: 40px; - color: #dfdfdf; - font-size: 32px; - text-align: center; -} -.highlight { - text-shadow: none; -} -.chart { - overflow: hidden; - height: 220px; -} -.break-word { - word-wrap: break-word; -} -.center { - text-align: center; -} -.block { - display: block; -} -.flex { - display: flex; -} -.flex-grow { - flex-grow: 1; +.gl-font-sm { + font-size: 12px; } .dropdown { position: relative; } -.show.dropdown .dropdown-menu { - transform: translateY(0); - display: block; - min-height: 40px; - max-height: 312px; - overflow-y: auto; -} - -@media (max-width: 575.98px) { - .show.dropdown .dropdown-menu { - width: 100%; - } -} - .show.dropdown .dropdown-menu-toggle, -.show.dropdown .dropdown-menu-toggle { - border-color: #c4c4c4; -} -.show.dropdown [data-toggle='dropdown'] { - outline: 0; -} .search-input-container .dropdown-menu { margin-top: 11px; } - .dropdown-menu-toggle { +.dropdown-menu-toggle { padding: 6px 8px 6px 10px; background-color: #333; color: #fafafa; font-size: 14px; text-align: left; - border: 1px solid #4f4f4f; + border: 1px solid #404040; border-radius: 0.25rem; white-space: nowrap; } - .no-outline.dropdown-menu-toggle { +.no-outline.dropdown-menu-toggle { outline: 0; } - .dropdown-menu-toggle .fa { - color: #c4c4c4; -} -.dropdown-menu-toggle { +.dropdown-menu-toggle.dropdown-menu-toggle { + justify-content: flex-start; + overflow: hidden; padding-right: 25px; position: relative; - width: 160px; text-overflow: ellipsis; - overflow: hidden; -} -.dropdown-menu-toggle .fa { - position: absolute; + width: 160px; } .dropdown-menu { display: none; @@ -866,7 +550,7 @@ table a code { font-weight: 400; padding: 8px 0; background-color: #333; - border: 1px solid #4f4f4f; + border: 1px solid #404040; border-radius: 0.25rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } @@ -897,156 +581,56 @@ table a code { text-align: left; width: 100%; } +.dropdown-menu li > a:active, +.dropdown-menu li button:active { + background-color: #4f4f4f; + color: #fafafa; + outline: 0; + text-decoration: none; +} .dropdown-menu .divider { height: 1px; margin: 0.25rem 0; padding: 0; - background-color: #4f4f4f; + background-color: #404040; } .dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) { margin-right: 40px; } -.dropdown-select { - width: 300px; -} - -@media (max-width: 767.98px) { - .dropdown-select { - width: 100%; - } -} -.dropdown-content { - max-height: 252px; - overflow-y: auto; -} -.dropdown-loading { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: none; - z-index: 9; - background-color: rgba(51, 51, 51, 0.6); - font-size: 28px; -} -.dropdown-loading .fa { - position: absolute; - top: 50%; - left: 50%; - margin-top: -14px; - margin-left: -14px; -} - @media (max-width: 575.98px) { .navbar-gitlab li.dropdown { position: static; } + .navbar-gitlab li.dropdown.user-counter { + margin-left: 8px !important; + } + .navbar-gitlab li.dropdown.user-counter > a { + padding: 0 4px !important; + } header.navbar-gitlab .dropdown .dropdown-menu { width: 100%; min-width: 100%; } } - @media (max-width: 767.98px) { .dropdown-menu-toggle { width: 100%; } } -textarea { - resize: vertical; -} input { border-radius: 0.25rem; color: #fafafa; - background-color: #4f4f4f; + background-color: #333; } - .search form { +.form-control { border-radius: 4px; padding: 6px 10px; } - .search form::placeholder { - color: #a7a7a7; -} -body.ui-indigo .navbar-gitlab { - background-color: #292961; -} -body.ui-indigo .navbar-gitlab .navbar-collapse { - color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler { - border-left: 1px solid #6868b9; -} -body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler svg { - fill: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.active > a, -body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.active > button, body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.dropdown.show > a, -body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.dropdown.show > button, -body.ui-indigo .navbar-gitlab .navbar-nav > li.active > a, -body.ui-indigo .navbar-gitlab .navbar-nav > li.active > button, -body.ui-indigo .navbar-gitlab .navbar-nav > li.dropdown.show > a, -body.ui-indigo .navbar-gitlab .navbar-nav > li.dropdown.show > button { - color: #292961; - background-color: #333; -} -body.ui-indigo .navbar-gitlab .navbar-sub-nav { - color: #d1d1f0; +.form-control::-ms-input-placeholder { + color: #868686; } -body.ui-indigo .navbar-gitlab .nav > li { - color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .nav > li > a.header-user-dropdown-toggle .header-user-avatar { - border-color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .nav > li.active > a, -body.ui-indigo .navbar-gitlab .nav > li.dropdown.show > a { - color: #292961; - background-color: #333; -} -body.ui-indigo .search form { - background-color: rgba(209, 209, 240, 0.2); -} -body.ui-indigo .search .search-input::placeholder { - color: rgba(209, 209, 240, 0.8); -} -body.ui-indigo .search .search-input-wrap .search-icon, -body.ui-indigo .search .search-input-wrap .clear-icon { - fill: rgba(209, 209, 240, 0.8); -} -body.ui-indigo .nav-sidebar li.active { - box-shadow: inset 4px 0 0 #4b4ba3; -} -body.ui-indigo .nav-sidebar li.active > a { - color: #393982; -} -body.ui-indigo .nav-sidebar li.active .nav-icon-container svg { - fill: #393982; -} -body.ui-indigo .sidebar-top-level-items > li.active .badge.badge-pill { - color: #393982; -} -body.gl-dark .logo-text svg { - fill: #fafafa; -} -body.gl-dark .navbar-gitlab { - background-color: #2e2e2e; - box-shadow: 0 1px 0 0 var(--gray-100); -} -body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > a, -body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > button, -body.gl-dark .navbar-gitlab .navbar-sub-nav li.dropdown.show > a, -body.gl-dark .navbar-gitlab .navbar-sub-nav li.dropdown.show > button, -body.gl-dark .navbar-gitlab .navbar-nav li.active > a, -body.gl-dark .navbar-gitlab .navbar-nav li.active > button, -body.gl-dark .navbar-gitlab .navbar-nav li.dropdown.show > a, -body.gl-dark .navbar-gitlab .navbar-nav li.dropdown.show > button { - color: #fafafa; - background-color: #707070; -} -body.gl-dark .navbar-gitlab .search form { - background-color: #4f4f4f; - box-shadow: inset 0 0 0 1px #4f4f4f; +.form-control::placeholder { + color: #868686; } .navbar-gitlab { padding: 0 16px; @@ -1054,7 +638,6 @@ body.gl-dark .navbar-gitlab .search form { margin-bottom: 0; min-height: 40px; border: 0; - border-bottom: 1px solid #4f4f4f; position: fixed; top: 0; left: 0; @@ -1104,9 +687,6 @@ body.gl-dark .navbar-gitlab .search form { .navbar-gitlab .header-content .title img + .logo-text { margin-left: 8px; } -.navbar-gitlab .header-content .title.wrap { - white-space: normal; -} .navbar-gitlab .header-content .title a { display: flex; align-items: center; @@ -1114,9 +694,6 @@ body.gl-dark .navbar-gitlab .search form { margin: 5px 2px 5px -8px; border-radius: 4px; } -.navbar-gitlab .header-content .dropdown.open > a { - border-bottom-color: #333; -} .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } @@ -1125,7 +702,6 @@ body.gl-dark .navbar-gitlab .search form { border-top: 0; padding: 0; } - @media (max-width: 575.98px) { .navbar-gitlab .navbar-collapse { flex: 1 1 auto; @@ -1134,7 +710,6 @@ body.gl-dark .navbar-gitlab .search form { .navbar-gitlab .navbar-collapse .nav { flex-wrap: nowrap; } - @media (max-width: 575.98px) { .navbar-gitlab .navbar-collapse .nav > li:not(.d-none) a { margin-left: 0; @@ -1157,7 +732,10 @@ body.gl-dark .navbar-gitlab .search form { text-align: center; color: currentColor; } - +.navbar-gitlab .container-fluid .navbar-toggler.active { + color: currentColor; + background-color: transparent; +} @media (max-width: 575.98px) { .navbar-gitlab .container-fluid .navbar-nav { display: flex; @@ -1165,11 +743,14 @@ body.gl-dark .navbar-gitlab .search form { flex-direction: row; } } -.navbar-gitlab .container-fluid .navbar-nav li .badge.badge-pill { +.navbar-gitlab + .container-fluid + .navbar-nav + li + .badge.badge-pill:not(.merge-request-badge) { box-shadow: none; font-weight: 600; } - @media (max-width: 575.98px) { .navbar-gitlab .container-fluid .nav > li.header-user { padding-left: 10px; @@ -1181,7 +762,6 @@ body.gl-dark .navbar-gitlab .search form { padding: 6px 8px; height: 32px; } - @media (max-width: 575.98px) { .navbar-gitlab .container-fluid .nav > li > a { padding: 0; @@ -1190,7 +770,12 @@ body.gl-dark .navbar-gitlab .search form { .navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle { margin-left: 2px; } -.navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle .header-user-avatar { +.navbar-gitlab + .container-fluid + .nav + > li + > a.header-user-dropdown-toggle + .header-user-avatar { margin-right: 0; } .navbar-gitlab .container-fluid .nav > li .header-new-dropdown-toggle { @@ -1211,7 +796,9 @@ body.gl-dark .navbar-gitlab .search form { height: 32px; font-weight: 600; } +.navbar-sub-nav > li .top-nav-toggle, .navbar-sub-nav > li > button, +.navbar-nav > li .top-nav-toggle, .navbar-nav > li > button { background: transparent; border: 0; @@ -1249,31 +836,25 @@ body.gl-dark .navbar-gitlab .search form { font-weight: 400; margin-left: -6px; font-size: 11px; - color: #333; + color: var(--gray-950, #333); padding: 0 5px; line-height: 12px; border-radius: 7px; box-shadow: 0 1px 0 rgba(76, 78, 84, 0.2); } -.title-container .badge.badge-pill.green-badge, -.navbar-nav .badge.badge-pill.green-badge { - background-color: #1aaa55; -} -.title-container .badge.badge-pill.merge-requests-count, -.navbar-nav .badge.badge-pill.merge-requests-count { - background-color: #fca429; +.title-container .badge.badge-pill:not(.merge-request-badge).green-badge, +.navbar-nav .badge.badge-pill:not(.merge-request-badge).green-badge { + background-color: var(--green-400, #108548); } -.title-container .badge.badge-pill.todos-count, -.navbar-nav .badge.badge-pill.todos-count { - background-color: #1f78d1; +.title-container + .badge.badge-pill:not(.merge-request-badge).merge-requests-count, +.navbar-nav .badge.badge-pill:not(.merge-request-badge).merge-requests-count { + background-color: var(--orange-400, #ab6100); } -.title-container .canary-badge .badge, -.navbar-nav .canary-badge .badge { - font-size: 12px; - line-height: 16px; - padding: 0 0.5rem; +.title-container .badge.badge-pill:not(.merge-request-badge).todos-count, +.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count { + background-color: var(--blue-400, #1f75cb); } - @media (max-width: 575.98px) { .navbar-gitlab .container-fluid { font-size: 18px; @@ -1298,45 +879,35 @@ body.gl-dark .navbar-gitlab .search form { float: none; } } -.header-user.show .dropdown-menu { - margin-top: 4px; - color: #fafafa; - left: auto; - max-height: 445px; -} -.header-user.show .dropdown-menu svg { - vertical-align: text-top; -} .header-user-avatar { float: left; margin-right: 5px; border-radius: 50%; border: 1px solid #333; } -.media { - display: flex; - align-items: flex-start; -} -.card { - margin-bottom: 16px; +.notification-dot { + background-color: #9e5400; + height: 12px; + width: 12px; + margin-top: -15px; + pointer-events: none; + visibility: hidden; } -.content-wrapper { - width: 100%; +.top-nav-toggle .dropdown-icon { + margin-right: 0.5rem; } -.content-wrapper .container-fluid { - padding: 0 16px; +.tanuki-logo .tanuki-left-ear, +.tanuki-logo .tanuki-right-ear, +.tanuki-logo .tanuki-nose { + fill: #e24329; } - -@media (min-width: 768px) { - .page-with-contextual-sidebar { - padding-left: 50px; - } +.tanuki-logo .tanuki-left-eye, +.tanuki-logo .tanuki-right-eye { + fill: #fc6d26; } - -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - padding-left: 220px; - } +.tanuki-logo .tanuki-left-cheek, +.tanuki-logo .tanuki-right-cheek { + fill: #fca326; } .context-header { position: relative; @@ -1363,9 +934,20 @@ body.gl-dark .navbar-gitlab .search form { overflow: hidden; text-overflow: ellipsis; } -.context-header .sidebar-context-title.text-secondary { - font-weight: normal; - font-size: 0.8em; +@media (min-width: 768px) { + .page-with-contextual-sidebar { + padding-left: 48px; + } +} +@media (min-width: 1200px) { + .page-with-contextual-sidebar { + padding-left: 220px; + } +} +@media (min-width: 768px) { + .page-with-icon-sidebar { + padding-left: 48px; + } } .nav-sidebar { position: fixed; @@ -1374,24 +956,22 @@ body.gl-dark .navbar-gitlab .search form { top: 40px; bottom: 0; left: 0; - background-color: #2e2e2e; - box-shadow: inset -1px 0 0 #4f4f4f; + background-color: #303030; + box-shadow: inset -1px 0 0 #404040; transform: translate3d(0, 0, 0); } - @media (min-width: 576px) and (max-width: 576px) { .nav-sidebar:not(.sidebar-collapsed-desktop) { - box-shadow: inset -1px 0 0 #4f4f4f, 2px 1px 3px rgba(0, 0, 0, 0.1); + box-shadow: inset -1px 0 0 #404040, 2px 1px 3px rgba(0, 0, 0, 0.1); } } .nav-sidebar.sidebar-collapsed-desktop { - width: 50px; + width: 48px; } .nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll { overflow-x: hidden; } .nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge), -.nav-sidebar.sidebar-collapsed-desktop .sidebar-context-title, .nav-sidebar.sidebar-collapsed-desktop .nav-item-name { border: 0; clip: rect(0, 0, 0, 0); @@ -1412,9 +992,6 @@ body.gl-dark .navbar-gitlab .search form { .nav-sidebar.sidebar-collapsed-desktop .avatar-container { margin: 0 auto; } -.nav-sidebar.sidebar-expanded-mobile { - left: 0; -} .nav-sidebar a { text-decoration: none; } @@ -1429,7 +1006,7 @@ body.gl-dark .navbar-gitlab .search form { display: flex; align-items: center; padding: 12px 16px; - color: #bababa; + color: #999; } .nav-sidebar li .nav-item-name { flex: 1; @@ -1437,7 +1014,6 @@ body.gl-dark .navbar-gitlab .search form { .nav-sidebar li.active > a { font-weight: 600; } - @media (max-width: 767.98px) { .nav-sidebar { left: -220px; @@ -1454,16 +1030,15 @@ body.gl-dark .navbar-gitlab .search form { height: 16px; width: 16px; } - @media (min-width: 768px) and (max-width: 1199px) { .nav-sidebar:not(.sidebar-expanded-mobile) { - width: 50px; + width: 48px; } .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll { overflow-x: hidden; } - .nav-sidebar:not(.sidebar-expanded-mobile) .badge.badge-pill:not(.fly-out-badge), - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title, + .nav-sidebar:not(.sidebar-expanded-mobile) + .badge.badge-pill:not(.fly-out-badge), .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name { border: 0; clip: rect(0, 0, 0, 0); @@ -1486,12 +1061,26 @@ body.gl-dark .navbar-gitlab .search form { } .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { height: 60px; - width: 50px; + width: 48px; } .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { padding: 10px 4px; } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li .sidebar-sub-level-items:not(.flyout-list) { + .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + } + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { display: none; } .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container { @@ -1499,13 +1088,19 @@ body.gl-dark .navbar-gitlab .search form { } .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button { padding: 16px; - width: 49px; + width: 47px; } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .collapse-text, - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-left { + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .collapse-text, + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .icon-chevron-double-lg-left { display: none; } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-right { + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .icon-chevron-double-lg-right { display: block; margin: 0; } @@ -1522,10 +1117,12 @@ body.gl-dark .navbar-gitlab .search form { .sidebar-sub-level-items > li a { padding: 8px 16px 8px 40px; } +.sidebar-sub-level-items > li.active a { + background: rgba(255, 255, 255, 0.04); +} .sidebar-top-level-items { margin-bottom: 60px; } - @media (min-width: 576px) { .sidebar-top-level-items > li > a { margin-right: 1px; @@ -1533,7 +1130,7 @@ body.gl-dark .navbar-gitlab .search form { } .sidebar-top-level-items > li .badge.badge-pill { background-color: rgba(255, 255, 255, 0.08); - color: #bababa; + color: #999; } .sidebar-top-level-items > li.active { background: rgba(255, 255, 255, 0.04); @@ -1545,23 +1142,28 @@ body.gl-dark .navbar-gitlab .search form { .sidebar-top-level-items > li.active .badge.badge-pill { font-weight: 600; } -.sidebar-top-level-items > li.active .sidebar-sub-level-items:not(.is-fly-out-only) { +.sidebar-top-level-items + > li.active + .sidebar-sub-level-items:not(.is-fly-out-only) { display: block; } .toggle-sidebar-button, .close-nav-button { - width: 219px; - position: fixed; height: 48px; - bottom: 0; padding: 0 16px; - background-color: #2e2e2e; + background-color: #303030; border: 0; - border-top: 1px solid #4f4f4f; - color: #bababa; + color: #999; display: flex; align-items: center; } +.toggle-sidebar-button, +.close-nav-button { + position: fixed; + bottom: 0; + width: 219px; + border-top: 1px solid #404040; +} .toggle-sidebar-button svg, .close-nav-button svg { margin-right: 8px; @@ -1576,12 +1178,26 @@ body.gl-dark .navbar-gitlab .search form { } .sidebar-collapsed-desktop .context-header { height: 60px; - width: 50px; + width: 48px; } .sidebar-collapsed-desktop .context-header a { padding: 10px 4px; } -.sidebar-collapsed-desktop .sidebar-top-level-items > li .sidebar-sub-level-items:not(.flyout-list) { +.sidebar-collapsed-desktop .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} +.sidebar-collapsed-desktop + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { display: none; } .sidebar-collapsed-desktop .nav-icon-container { @@ -1589,13 +1205,15 @@ body.gl-dark .navbar-gitlab .search form { } .sidebar-collapsed-desktop .toggle-sidebar-button { padding: 16px; - width: 49px; + width: 47px; } .sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text, .sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left { display: none; } -.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-right { +.sidebar-collapsed-desktop + .toggle-sidebar-button + .icon-chevron-double-lg-right { display: block; margin: 0; } @@ -1611,7 +1229,6 @@ body.gl-dark .navbar-gitlab .search form { .close-nav-button { display: none; } - @media (max-width: 767.98px) { .close-nav-button { display: flex; @@ -1620,128 +1237,671 @@ body.gl-dark .navbar-gitlab .search form { display: none; } } -table.table { - margin-bottom: 16px; +body.sidebar-refactoring.gl-dark .nav-sidebar li.active { + box-shadow: none; +} +body.sidebar-refactoring.gl-dark .nav-sidebar li a, +body.sidebar-refactoring.gl-dark .toggle-sidebar-button .collapse-text, +body.sidebar-refactoring.gl-dark + .toggle-sidebar-button + .icon-chevron-double-lg-left, +body.sidebar-refactoring.gl-dark + .toggle-sidebar-button + .icon-chevron-double-lg-right, +body.sidebar-refactoring.gl-dark + .sidebar-top-level-items + .context-header + a + .sidebar-context-title, +body.sidebar-refactoring.gl-dark + .nav-sidebar-inner-scroll + > div.context-header + a + .sidebar-context-title { + color: #c4c4c4; } -table.table .dropdown-menu a { +body.sidebar-refactoring.ui-indigo + .nav-sidebar + li.active:not(.fly-out-top-item) + > a { + color: #2f2a6b; +} +body.sidebar-refactoring.ui-indigo + .nav-sidebar + li.active + .nav-icon-container + svg { + fill: #2f2a6b; +} +body.sidebar-refactoring .nav-sidebar { + box-shadow: none; +} +body.sidebar-refactoring .nav-sidebar li.active { + background-color: transparent; + box-shadow: none !important; +} +@media (min-width: 768px) { + body.sidebar-refactoring .page-with-contextual-sidebar { + padding-left: 48px; + } +} +@media (min-width: 1200px) { + body.sidebar-refactoring .page-with-contextual-sidebar { + padding-left: 220px; + } +} +@media (min-width: 768px) { + body.sidebar-refactoring .page-with-icon-sidebar { + padding-left: 48px; + } +} +body.sidebar-refactoring .nav-sidebar { + position: fixed; + bottom: 0; + left: 0; + z-index: 600; + width: 220px; + top: 40px; + background-color: #303030; + transform: translate3d(0, 0, 0); +} +body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop { + width: 48px; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .nav-sidebar-inner-scroll { + overflow-x: hidden; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .badge.badge-pill:not(.fly-out-badge), +body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .nav-item-name, +body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .collapse-text { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .sidebar-top-level-items + > li + > a { + min-height: unset; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .fly-out-top-item:not(.divider) { + display: block !important; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .avatar-container { + margin: 0 auto; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + li.active:not(.fly-out-top-item) + > a { + background-color: rgba(41, 41, 97, 0.08); +} +body.sidebar-refactoring .nav-sidebar a { text-decoration: none; + color: #2f2a6b; } -table.table .success, -table.table .info { - color: #333; +body.sidebar-refactoring .nav-sidebar li { + white-space: nowrap; +} +body.sidebar-refactoring .nav-sidebar li .nav-item-name { + flex: 1; +} +body.sidebar-refactoring .nav-sidebar li > a, +body.sidebar-refactoring .nav-sidebar li > .fly-out-top-item-container { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + display: flex; + align-items: center; + border-radius: 0.25rem; + width: auto; + line-height: 1rem; + margin: 1px 4px; +} +body.sidebar-refactoring .nav-sidebar li.active > a { + font-weight: 600; } -table.table .success a:not(.btn), -table.table .info a:not(.btn) { - text-decoration: underline; +body.sidebar-refactoring + .nav-sidebar + li.active:not(.fly-out-top-item) + > a:not(.has-sub-items) { + background-color: rgba(41, 41, 97, 0.08); +} +body.sidebar-refactoring .nav-sidebar ul { + padding-left: 0; + list-style: none; +} +@media (max-width: 767.98px) { + body.sidebar-refactoring .nav-sidebar { + left: -220px; + } +} +body.sidebar-refactoring .nav-sidebar .nav-icon-container { + display: flex; + margin-right: 8px; +} +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item { + display: none; +} +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + a, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item.active + a, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container { + margin-left: 0; + margin-right: 0; + padding-left: 1rem; + padding-right: 1rem; + cursor: default; + pointer-events: none; + font-size: 0.75rem; + background-color: #2f2a6b; color: #333; + margin-top: -0.25rem; + margin-bottom: -0.25rem; + margin-top: 0; + position: relative; + background-color: #fff; +} +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + a + strong, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item.active + a + strong, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container + strong { + font-weight: 400; } -pre { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + a::before, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item.active + a::before, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container::before { + position: absolute; + content: ""; display: block; - padding: 8px 12px; - margin: 0 0 8px; - font-size: 13px; - word-break: break-all; - word-wrap: break-word; + top: 50%; + left: -0.25rem; + margin-top: -0.5rem; + width: 0; + height: 0; + border-top: 0.5rem solid transparent; + border-bottom: 0.5rem solid transparent; + border-right: 0.5rem solid #fff; +} +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item { + display: none; +} +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item + a, +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item.active + a, +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container { + margin-left: 0; + margin-right: 0; + padding-left: 1rem; + padding-right: 1rem; + cursor: default; + pointer-events: none; + font-size: 0.75rem; + background-color: #2f2a6b; + color: #333; + margin-top: -0.25rem; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +@media (min-width: 768px) and (max-width: 1199px) { + body.sidebar-refactoring .nav-sidebar:not(.sidebar-expanded-mobile) { + width: 48px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .nav-sidebar-inner-scroll { + overflow-x: hidden; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .badge.badge-pill:not(.fly-out-badge), + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .nav-item-name, + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .collapse-text { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-top-level-items + > li + > a { + min-height: unset; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .fly-out-top-item:not(.divider) { + display: block !important; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .avatar-container { + margin: 0 auto; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + li.active:not(.fly-out-top-item) + > a { + background-color: rgba(41, 41, 97, 0.08); + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header { + height: 60px; + width: 48px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header + a { + padding: 10px 4px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header { + height: auto; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header + a { + padding: 0.25rem; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { + display: none; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .nav-icon-container { + margin-right: 0; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button { + width: 48px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .collapse-text { + display: none; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .icon-chevron-double-lg-left { + transform: rotate(180deg); + display: block; + margin: 0; + } +} +body.sidebar-refactoring .nav-sidebar-inner-scroll { + height: 100%; + width: 100%; + overflow: auto; +} +body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header { + margin-top: 0.25rem; +} +body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header a { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + display: flex; + align-items: center; + border-radius: 0.25rem; + width: auto; + line-height: 1rem; + margin: 1px 4px; + padding: 0.25rem; + margin-bottom: 0.25rem; + margin-top: 0; +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container { + font-weight: 400; + flex: none; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container.rect-avatar { + border-style: none; +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container.rect-avatar + .avatar.s32 { color: #fafafa; - background-color: #2e2e2e; - border: 1px solid #4f4f4f; - border-radius: 2px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .sidebar-context-title { + color: #2f2a6b; +} +body.sidebar-refactoring .sidebar-top-level-items { + margin-top: 0.25rem; + margin-bottom: 60px; +} +body.sidebar-refactoring .sidebar-top-level-items .context-header a { + padding: 0.25rem; + margin-bottom: 0.25rem; + margin-top: 0; +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container { + font-weight: 400; + flex: none; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container.rect-avatar { + border-style: none; +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container.rect-avatar + .avatar.s32 { + color: #fafafa; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .sidebar-context-title { + color: #2f2a6b; +} +body.sidebar-refactoring .sidebar-top-level-items > li .badge.badge-pill { + border-radius: 0.5rem; + padding-top: 0.125rem; + padding-bottom: 0.125rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: #064787; + color: #9dc7f1; +} +body.sidebar-refactoring + .sidebar-top-level-items + > li.active + .sidebar-sub-level-items:not(.is-fly-out-only) { + display: block; +} +body.sidebar-refactoring + .sidebar-top-level-items + > li.active + .badge.badge-pill { + font-weight: 400; + color: #9dc7f1; +} +body.sidebar-refactoring .sidebar-sub-level-items { + padding-top: 0; + padding-bottom: 0; + display: none; +} +body.sidebar-refactoring .sidebar-sub-level-items:not(.fly-out-list) li > a { + padding-left: 2.25rem; +} +body.sidebar-refactoring .toggle-sidebar-button, +body.sidebar-refactoring .close-nav-button { + height: 48px; + padding: 0 16px; + background-color: #303030; + border: 0; + color: #999; + display: flex; + align-items: center; + background-color: #303030; + border-top: 1px solid #404040; + color: #2f2a6b; + position: fixed; + bottom: 0; + width: 220px; } -.monospace { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; +body.sidebar-refactoring .toggle-sidebar-button .collapse-text, +body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-left, +body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-right, +body.sidebar-refactoring .close-nav-button .collapse-text, +body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-left, +body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-right { + color: inherit; +} +body.sidebar-refactoring .collapse-text { + white-space: nowrap; + overflow: hidden; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header { + height: 60px; + width: 48px; } -input::-moz-placeholder, -textarea::-moz-placeholder { - color: #a7a7a7; +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a { + padding: 10px 4px; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header { + height: auto; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a { + padding: 0.25rem; +} +body.sidebar-refactoring + .sidebar-collapsed-desktop + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { + display: none; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .nav-icon-container { + margin-right: 0; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .toggle-sidebar-button { + width: 48px; +} +body.sidebar-refactoring + .sidebar-collapsed-desktop + .toggle-sidebar-button + .collapse-text { + display: none; +} +body.sidebar-refactoring + .sidebar-collapsed-desktop + .toggle-sidebar-button + .icon-chevron-double-lg-left { + transform: rotate(180deg); + display: block; + margin: 0; +} +body.sidebar-refactoring .close-nav-button { + display: none; +} +@media (max-width: 767.98px) { + body.sidebar-refactoring .close-nav-button { + display: flex; + } + body.sidebar-refactoring .toggle-sidebar-button { + display: none; + } +} +input::-moz-placeholder { + color: #868686; opacity: 1; } -input::-ms-input-placeholder, -textarea::-ms-input-placeholder { - color: #a7a7a7; +input::-ms-input-placeholder { + color: #868686; } -input:-ms-input-placeholder, -textarea:-ms-input-placeholder { - color: #a7a7a7; +input:-ms-input-placeholder { + color: #868686; } svg { fill: currentColor; } - svg.s12 { width: 12px; height: 12px; } - svg.s16 { width: 16px; height: 16px; } - svg.s18 { width: 18px; height: 18px; } - +svg.s32 { + width: 32px; + height: 32px; +} svg.s12 { vertical-align: -1px; } - svg.s16 { vertical-align: -3px; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} -table.code { - width: 100%; - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - border: 0; - border-collapse: separate; - margin: 0; - padding: 0; - table-layout: fixed; - border-radius: 0 0 4px 4px; -} -.frame .badge.badge-pill { - position: absolute; - background-color: #1b69b6; - color: #333; - border: #333 1px solid; - min-height: 16px; - padding: 5px 8px; - border-radius: 12px; -} -.frame .badge.badge-pill { - transform: translate(-50%, -50%); -} -.color-label { - padding: 0 0.5rem; - line-height: 16px; - border-radius: 100px; - color: #333; -} -.label-link { - display: inline-flex; - vertical-align: text-bottom; -} -.milestones { - padding: 8px; - margin-top: 8px; - border-radius: 4px; - background-color: #4f4f4f; -} .search { margin: 0 8px; } .search form { + display: block; margin: 0; padding: 4px; width: 200px; @@ -1750,7 +1910,6 @@ table.code { border: 0; border-radius: 4px; } - @media (min-width: 1200px) { .search form { width: 320px; @@ -1794,43 +1953,43 @@ table.code { max-height: 400px; overflow: auto; } - @media (min-width: 1200px) { .search .search-input-wrap .dropdown-menu { width: 320px; } } -.search .search-input-wrap .dropdown-content { - max-height: 382px; -} -.settings { - border-top: 1px solid #4f4f4f; -} -.settings:first-of-type { - margin-top: 10px; - border: 0; -} -.settings + div .settings:first-of-type { - margin-top: 0; - border-top: 1px solid #4f4f4f; +.search .identicon { + flex-basis: 16px; + flex-shrink: 0; + margin-right: 4px; } -.avatar, .avatar-container { +.avatar, +.avatar-container { float: left; margin-right: 16px; border-radius: 50%; border: 1px solid #333; } -.s16.avatar, .s16.avatar-container { +.avatar.s16, +.avatar-container.s16 { width: 16px; height: 16px; margin-right: 8px; } -.s18.avatar, .s18.avatar-container { +.avatar.s18, +.avatar-container.s18 { width: 18px; height: 18px; margin-right: 8px; } -.s40.avatar, .s40.avatar-container { +.avatar.s32, +.avatar-container.s32 { + width: 32px; + height: 32px; + margin-right: 8px; +} +.avatar.s40, +.avatar-container.s40 { width: 40px; height: 40px; margin-right: 8px; @@ -1844,15 +2003,49 @@ table.code { overflow: hidden; border-color: rgba(255, 255, 255, 0.1); } -.avatar.center { - font-size: 14px; - line-height: 1.8em; - text-align: center; -} .avatar.avatar-tile { border-radius: 0; border: 0; } +.identicon { + text-align: center; + vertical-align: top; + color: #525252; + background-color: #eee; +} +.identicon.s16 { + font-size: 10px; + line-height: 16px; +} +.identicon.s32 { + font-size: 14px; + line-height: 32px; +} +.identicon.s40 { + font-size: 16px; + line-height: 38px; +} +.identicon.bg1 { + background-color: #ffebee; +} +.identicon.bg2 { + background-color: #f3e5f5; +} +.identicon.bg3 { + background-color: #e8eaf6; +} +.identicon.bg4 { + background-color: #e3f2fd; +} +.identicon.bg5 { + background-color: #e0f2f1; +} +.identicon.bg6 { + background-color: #fbe9e7; +} +.identicon.bg7 { + background-color: #eee; +} .avatar-container { overflow: hidden; display: flex; @@ -1884,9 +2077,204 @@ table.code { .rect-avatar.s18 { border-radius: 2px; } +.rect-avatar.s32, +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container.rect-avatar + .avatar.s32, +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container.rect-avatar + .avatar.s32 { + border-radius: 4px; +} .rect-avatar.s40 { border-radius: 4px; } +body.gl-dark .navbar-gitlab { + background-color: #fafafa; +} +body.gl-dark .navbar-gitlab .navbar-collapse { + color: #fafafa; +} +body.gl-dark .navbar-gitlab .container-fluid .navbar-toggler { + border-left: 1px solid #b3b3b3; + color: #fafafa; +} +body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > a, +body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > button, +body.gl-dark .navbar-gitlab .navbar-nav > li.active > a, +body.gl-dark .navbar-gitlab .navbar-nav > li.active > button { + color: #fafafa; + background-color: #333; +} +body.gl-dark .navbar-gitlab .navbar-sub-nav { + color: #fafafa; +} +body.gl-dark .navbar-gitlab .nav > li { + color: #fafafa; +} +body.gl-dark .navbar-gitlab .nav > li > a .notification-dot { + border: 2px solid #fafafa; +} +body.gl-dark + .navbar-gitlab + .nav + > li + > a.header-help-dropdown-toggle + .notification-dot { + background-color: #fafafa; +} +body.gl-dark + .navbar-gitlab + .nav + > li + > a.header-user-dropdown-toggle + .header-user-avatar { + border-color: #fafafa; +} +body.gl-dark .navbar-gitlab .nav > li.active > a { + color: #fafafa; + background-color: #333; +} +body.gl-dark .navbar-gitlab .nav > li.active > a .notification-dot { + border-color: #333; +} +body.gl-dark + .navbar-gitlab + .nav + > li.active + > a.header-help-dropdown-toggle + .notification-dot { + background-color: #fafafa; +} +body.gl-dark .search form { + background-color: rgba(250, 250, 250, 0.2); +} +body.gl-dark .search .search-input::-ms-input-placeholder { + color: rgba(250, 250, 250, 0.8); +} +body.gl-dark .search .search-input::placeholder { + color: rgba(250, 250, 250, 0.8); +} +body.gl-dark .search .search-input-wrap .search-icon, +body.gl-dark .search .search-input-wrap .clear-icon { + fill: rgba(250, 250, 250, 0.8); +} +body.gl-dark .nav-sidebar li.active { + box-shadow: inset 4px 0 0 #999; +} +body.gl-dark .nav-sidebar li.active > a { + color: #f0f0f0; +} +body.gl-dark .nav-sidebar li.active .nav-icon-container svg { + fill: #f0f0f0; +} +body.gl-dark .sidebar-top-level-items > li.active .badge.badge-pill { + color: #f0f0f0; +} +body.gl-dark .logo-text svg { + fill: var(--gl-text-color); +} +body.gl-dark .navbar-gitlab { + background-color: var(--gray-50); + box-shadow: 0 1px 0 0 var(--gray-100); +} +body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > a, +body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > button, +body.gl-dark .navbar-gitlab .navbar-nav li.active > a, +body.gl-dark .navbar-gitlab .navbar-nav li.active > button { + color: var(--gl-text-color); + background-color: var(--gray-200); +} +body.gl-dark .navbar-gitlab .search form { + background-color: var(--gray-100); + box-shadow: inset 0 0 0 1px var(--border-color); +} +body.gl-dark .navbar-gitlab .search form:active { + background-color: var(--gray-100); + box-shadow: inset 0 0 0 1px var(--blue-200); +} + +body.gl-dark { + --gray-10: #1f1f1f; + --gray-50: #303030; + --gray-100: #404040; + --gray-200: #525252; + --gray-300: #5e5e5e; + --gray-400: #868686; + --gray-500: #999; + --gray-600: #bfbfbf; + --gray-700: #dbdbdb; + --gray-800: #f0f0f0; + --gray-900: #fafafa; + --gray-950: #fff; + --green-50: #0a4020; + --green-100: #0d532a; + --green-200: #24663b; + --green-300: #217645; + --green-400: #108548; + --green-500: #2da160; + --green-600: #52b87a; + --green-700: #91d4a8; + --green-800: #c3e6cd; + --green-900: #ecf4ee; + --green-950: #f1fdf6; + --blue-50: #033464; + --blue-100: #064787; + --blue-200: #0b5cad; + --blue-300: #1068bf; + --blue-400: #1f75cb; + --blue-500: #428fdc; + --blue-600: #63a6e9; + --blue-700: #9dc7f1; + --blue-800: #cbe2f9; + --blue-900: #e9f3fc; + --blue-950: #f2f9ff; + --orange-50: #5c2900; + --orange-100: #703800; + --orange-200: #8f4700; + --orange-300: #9e5400; + --orange-400: #ab6100; + --orange-500: #c17d10; + --orange-600: #d99530; + --orange-700: #e9be74; + --orange-800: #f5d9a8; + --orange-900: #fdf1dd; + --orange-950: #fff4e1; + --red-50: #660e00; + --red-100: #8d1300; + --red-200: #ae1800; + --red-300: #c91c00; + --red-400: #dd2b0e; + --red-500: #ec5941; + --red-600: #f57f6c; + --red-700: #fcb5aa; + --red-800: #fdd4cd; + --red-900: #fcf1ef; + --red-950: #fff4f3; + --indigo-50: #1a1a40; + --indigo-100: #292961; + --indigo-200: #393982; + --indigo-300: #4b4ba3; + --indigo-400: #5b5bbd; + --indigo-500: #6666c4; + --indigo-600: #7c7ccc; + --indigo-700: #a6a6de; + --indigo-800: #d1d1f0; + --indigo-900: #ebebfa; + --indigo-950: #f7f7ff; + --indigo-900-alpha-008: rgba(235, 235, 250, 0.08); + --gl-text-color: #fafafa; + --border-color: #4f4f4f; + --white: #333; + --black: #fff; + --svg-status-bg: #333; +} .tab-width-8 { -moz-tab-size: 8; tab-size: 8; @@ -1902,12 +2290,40 @@ table.code { white-space: nowrap; width: 1px; } +.gl-border-none\! { + border-style: none !important; +} +.gl-display-none { + display: none; +} +@media (min-width: 36rem) { + .gl-sm-display-block { + display: block; + } +} +.gl-absolute { + position: absolute; +} +.gl-px-3 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.gl-pr-2 { + padding-right: 0.25rem; +} .gl-ml-3 { margin-left: 0.5rem; } -.content-wrapper > .alert-wrapper, -#content-body, .modal-dialog { - display: block; +.gl-mx-0\! { + margin-left: 0 !important; + margin-right: 0 !important; } -@import 'cloaking'; +.gl-font-sm { + font-size: 0.75rem; +} +.gl-font-weight-bold { + font-weight: 600; +} + +@import "startup/cloaking"; @include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 44da509481d..a05e27b6af0 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -1,3 +1,6 @@ +// DO NOT EDIT! This is auto-generated from "yarn run generate:startup_css" +// Please see the feedback issue for more details and help: +// https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; *, *::before, @@ -8,12 +11,15 @@ html { font-family: sans-serif; line-height: 1.15; } - header, nav, section { +aside, +header { display: block; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -21,55 +27,29 @@ body { text-align: left; background-color: #fff; } -h1, h2, h3 { +h1 { margin-top: 0; margin-bottom: 0.25rem; } -p { - margin-top: 0; - margin-bottom: 1rem; -} - ul { margin-top: 0; margin-bottom: 1rem; } - ul ul { margin-bottom: 0; } - strong { font-weight: bolder; } -sub { - position: relative; - font-size: 75%; - line-height: 0; - vertical-align: baseline; -} -sub { - bottom: -.25em; -} a { color: #007bff; text-decoration: none; background-color: transparent; } -a:not([href]) { +a:not([href]):not([class]) { color: inherit; text-decoration: none; } -pre, -code { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - font-size: 1em; -} -pre { - margin-top: 0; - margin-bottom: 1rem; - overflow: auto; -} img { vertical-align: middle; border-style: none; @@ -78,18 +58,11 @@ svg { overflow: hidden; vertical-align: middle; } -table { - border-collapse: collapse; -} -th { - text-align: inherit; -} button { border-radius: 0; } input, -button, -textarea { +button { margin: 0; font-family: inherit; font-size: inherit; @@ -102,103 +75,34 @@ input { button { text-transform: none; } +[role="button"] { + cursor: pointer; +} button:not(:disabled), -[type="button"]:not(:disabled), -[type="reset"]:not(:disabled) { +[type="button"]:not(:disabled) { cursor: pointer; } button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner { +[type="button"]::-moz-focus-inner { padding: 0; border-style: none; } -textarea { - overflow: auto; - resize: vertical; -} [type="search"] { outline-offset: -2px; } -summary { - display: list-item; - cursor: pointer; -} -template { - display: none; -} -[hidden] { - display: none !important; -} -h1, h2, h3, -.h1, .h2, .h3 { +h1 { margin-bottom: 0.25rem; font-weight: 600; line-height: 1.2; color: #303030; } -h1, .h1 { +h1 { font-size: 2.1875rem; } -h2, .h2 { - font-size: 1.75rem; -} -h3, .h3 { - font-size: 1.53125rem; -} .list-unstyled { padding-left: 0; list-style: none; } -code { - font-size: 90%; - color: #1f1f1f; - word-wrap: break-word; -} -a > code { - color: inherit; -} -pre { - display: block; - font-size: 90%; - color: #303030; -} -pre code { - font-size: inherit; - color: inherit; - word-break: normal; -} -.container { - width: 100%; - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} - -@media (min-width: 576px) { - .container { - max-width: 540px; - } -} - -@media (min-width: 768px) { - .container { - max-width: 720px; - } -} - -@media (min-width: 992px) { - .container { - max-width: 960px; - } -} - -@media (min-width: 1200px) { - .container { - max-width: 1140px; - } -} .container-fluid { width: 100%; padding-right: 15px; @@ -206,48 +110,7 @@ pre code { margin-right: auto; margin-left: auto; } - -@media (min-width: 576px) { - .container { - max-width: 540px; - } -} - -@media (min-width: 768px) { - .container { - max-width: 720px; - } -} - -@media (min-width: 992px) { - .container { - max-width: 960px; - } -} - -@media (min-width: 1200px) { - .container { - max-width: 1140px; - } -} -.row { - display: flex; - flex-wrap: wrap; - margin-right: -15px; - margin-left: -15px; -} -.table { - width: 100%; - margin-bottom: 0.5rem; - color: #303030; -} -.table th, -.table td { - padding: 0.75rem; - vertical-align: top; - border-top: 1px solid #dbdbdb; -} - .search form { +.form-control { display: block; width: 100%; height: 34px; @@ -261,18 +124,21 @@ pre code { border: 1px solid #dbdbdb; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } - .search form:-moz-focusring { +.form-control:-moz-focusring { color: transparent; text-shadow: 0 0 0 #303030; } - .search form::placeholder { +.form-control::-ms-input-placeholder { color: #5e5e5e; opacity: 1; } - .search form:disabled { +.form-control::placeholder { + color: #5e5e5e; + opacity: 1; +} +.form-control:disabled { background-color: #fafafa; opacity: 1; } @@ -281,9 +147,8 @@ pre code { flex-flow: row wrap; align-items: center; } - @media (min-width: 576px) { - .form-inline .search form, .search .form-inline form { + .form-inline .form-control { display: inline-block; width: auto; vertical-align: middle; @@ -295,7 +160,7 @@ pre code { color: #303030; text-align: center; vertical-align: middle; - cursor: pointer; + -moz-user-select: none; user-select: none; background-color: transparent; border: 1px solid transparent; @@ -304,26 +169,24 @@ pre code { line-height: 20px; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } -.btn.disabled, .btn:disabled { +.btn:disabled { opacity: 0.65; } -a.btn.disabled { - pointer-events: none; +.btn:not(:disabled):not(.disabled) { + cursor: pointer; } .collapse:not(.show) { display: none; } - .dropdown { position: relative; } - .dropdown-menu-toggle { +.dropdown-menu-toggle { white-space: nowrap; } - .dropdown-menu-toggle::after { +.dropdown-menu-toggle::after { display: inline-block; margin-left: 0.255em; vertical-align: 0.255em; @@ -333,7 +196,7 @@ a.btn.disabled { border-bottom: 0; border-left: 0.3em solid transparent; } - .dropdown-menu-toggle:empty::after { +.dropdown-menu-toggle:empty::after { margin-left: 0; } .dropdown-menu { @@ -355,19 +218,6 @@ a.btn.disabled { border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 0.25rem; } -.dropdown-menu-right { - right: 0; - left: auto; -} - .divider { - height: 0; - margin: 4px 0; - overflow: hidden; - border-top: 1px solid #dbdbdb; -} -.dropdown-menu.show { - display: block; -} .nav { display: flex; flex-wrap: wrap; @@ -383,7 +233,6 @@ a.btn.disabled { justify-content: space-between; padding: 0.25rem 0.5rem; } -.navbar .container, .navbar .container-fluid { display: flex; flex-wrap: wrap; @@ -414,15 +263,12 @@ a.btn.disabled { border: 1px solid transparent; border-radius: 0.25rem; } - @media (max-width: 575.98px) { - .navbar-expand-sm > .container, .navbar-expand-sm > .container-fluid { padding-right: 0; padding-left: 0; } } - @media (min-width: 576px) { .navbar-expand-sm { flex-flow: row nowrap; @@ -434,7 +280,6 @@ a.btn.disabled { .navbar-expand-sm .navbar-nav .dropdown-menu { position: absolute; } - .navbar-expand-sm > .container, .navbar-expand-sm > .container-fluid { flex-wrap: nowrap; } @@ -446,17 +291,6 @@ a.btn.disabled { display: none; } } -.card { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - word-wrap: break-word; - background-color: #fff; - background-clip: border-box; - border: 1px solid #dbdbdb; - border-radius: 0.25rem; -} .badge { display: inline-block; padding: 0.25em 0.4em; @@ -468,7 +302,6 @@ a.btn.disabled { vertical-align: baseline; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } .badge:empty { @@ -483,66 +316,8 @@ a.btn.disabled { padding-left: 0.6em; border-radius: 10rem; } -.media { - display: flex; - align-items: flex-start; -} -.close { - float: right; - font-size: 1.5rem; - font-weight: 600; - line-height: 1; - color: #000; - text-shadow: 0 1px 0 #fff; - opacity: .5; -} -button.close { - padding: 0; - background-color: transparent; - border: 0; - appearance: none; -} -a.close.disabled { - pointer-events: none; -} -.modal-dialog { - position: relative; - width: auto; - margin: 0.5rem; - pointer-events: none; -} - -@media (min-width: 576px) { - .modal-dialog { - max-width: 500px; - margin: 1.75rem auto; - } -} -.bg-transparent { - background-color: transparent !important; -} -.border { - border: 1px solid #dbdbdb !important; -} -.border-top { - border-top: 1px solid #dbdbdb !important; -} -.border-right { - border-right: 1px solid #dbdbdb !important; -} -.border-bottom { - border-bottom: 1px solid #dbdbdb !important; -} -.border-left { - border-left: 1px solid #dbdbdb !important; -} -.rounded { - border-radius: 0.25rem !important; -} -.clearfix::after { - display: block; - clear: both; - content: ""; +.rounded-circle { + border-radius: 50% !important; } .d-none { display: none !important; @@ -553,19 +328,19 @@ a.close.disabled { .d-block { display: block !important; } - @media (min-width: 576px) { .d-sm-none { display: none !important; } + .d-sm-inline-block { + display: inline-block !important; + } } - @media (min-width: 768px) { .d-md-block { display: block !important; } } - @media (min-width: 992px) { .d-lg-none { display: none !important; @@ -574,18 +349,11 @@ a.close.disabled { display: block !important; } } - @media (min-width: 1200px) { .d-xl-block { display: block !important; } } -.flex-wrap { - flex-wrap: wrap !important; -} -.float-right { - float: right !important; -} .sr-only { position: absolute; width: 1px; @@ -600,73 +368,58 @@ a.close.disabled { .m-auto { margin: auto !important; } -.text-nowrap { - white-space: nowrap !important; +.gl-button { + display: inline-flex; } -.visible { - visibility: visible !important; +.gl-button:not(.btn-link):active { + text-decoration: none; } - .search form.focus { +.gl-button.gl-button { + border-width: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + background-color: transparent; + line-height: 1rem; color: #303030; + fill: currentColor; + box-shadow: inset 0 0 0 1px #bfbfbf; + justify-content: center; + align-items: center; + font-size: 0.875rem; + border-radius: 0.25rem; +} +.gl-button.gl-button.btn-default { background-color: #fff; - border-color: #80bdff; - outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } -.gl-badge { - display: inline-flex; - align-items: center; - font-size: 0.75rem; - font-weight: 400; - line-height: 1rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; +.gl-button.gl-button.btn-default:active, +.gl-button.gl-button.btn-default.active { + box-shadow: inset 0 0 0 2px #5e5e5e, 0 0 0 1px rgba(255, 255, 255, 0.4), + 0 0 0 4px rgba(31, 117, 203, 0.48); outline: none; + background-color: #dbdbdb; } -body, .search form, +body, +.form-control, .search form { font-size: 0.875rem; } button, -html [type='button'], -[type='reset'], -[role='button'] { +html [type="button"], +[role="button"] { cursor: pointer; } -h1, -.h1, -h2, -.h2, -h3, -.h3 { +h1 { margin-top: 20px; margin-bottom: 10px; } -input[type='file'] { - line-height: 1; -} - strong { font-weight: bold; } a { color: #1068bf; } -code { - padding: 2px 4px; - color: #1f1f1f; - background-color: #f0f0f0; - border-radius: 4px; -} -.code > code { - background-color: inherit; - padding: unset; -} -table { - border-spacing: 0; -} .hidden { display: none !important; visibility: hidden !important; @@ -674,7 +427,7 @@ table { .hide { display: none; } - .dropdown-menu-toggle::after { +.dropdown-menu-toggle::after { display: none; } .badge:not(.gl-badge) { @@ -684,8 +437,11 @@ table { font-weight: 400; display: inline-block; } -pre code { - white-space: pre-wrap; +.divider { + height: 0; + margin: 4px 0; + overflow: hidden; + border-top: 1px solid #dbdbdb; } .toggle-sidebar-button .collapse-text, .toggle-sidebar-button .icon-chevron-double-lg-left, @@ -701,29 +457,6 @@ html { body { text-decoration-skip: ink; } -.content-wrapper { - margin-top: 40px; - padding-bottom: 100px; -} -.container { - padding-top: 0; - z-index: 5; -} -.container .content { - margin: 0; -} - -@media (max-width: 575.98px) { - .container .content { - margin-top: 20px; - } -} - -@media (max-width: 575.98px) { - .container .container .title { - padding-left: 15px !important; - } -} .btn { border-radius: 4px; font-size: 0.875rem; @@ -735,7 +468,12 @@ body { color: #303030; white-space: nowrap; } -.btn:active, .btn.active { +.btn:active { + background-color: #f0f0f0; + box-shadow: none; +} +.btn:active, +.btn.active { background-color: #eaeaea; border-color: #e3e3e3; color: #303030; @@ -744,8 +482,7 @@ body { height: 15px; width: 15px; } -.btn svg:not(:last-child), -.btn .fa:not(:last-child) { +.btn svg:not(:last-child) { margin-right: 5px; } .badge.badge-pill:not(.gl-badge) { @@ -754,78 +491,16 @@ body { color: #525252; vertical-align: baseline; } -.hint { - font-style: italic; - color: #bfbfbf; -} -.bold { - font-weight: 600; -} -pre.wrap { - word-break: break-word; - white-space: pre-wrap; -} -table a code { - position: relative; - top: -2px; - margin-right: 3px; -} -.loading { - margin: 20px auto; - height: 40px; - color: #525252; - font-size: 32px; - text-align: center; -} -.highlight { - text-shadow: none; -} -.chart { - overflow: hidden; - height: 220px; -} -.break-word { - word-wrap: break-word; -} -.center { - text-align: center; -} -.block { - display: block; -} -.flex { - display: flex; -} -.flex-grow { - flex-grow: 1; +.gl-font-sm { + font-size: 12px; } .dropdown { position: relative; } -.show.dropdown .dropdown-menu { - transform: translateY(0); - display: block; - min-height: 40px; - max-height: 312px; - overflow-y: auto; -} - -@media (max-width: 575.98px) { - .show.dropdown .dropdown-menu { - width: 100%; - } -} - .show.dropdown .dropdown-menu-toggle, -.show.dropdown .dropdown-menu-toggle { - border-color: #c4c4c4; -} -.show.dropdown [data-toggle='dropdown'] { - outline: 0; -} .search-input-container .dropdown-menu { margin-top: 11px; } - .dropdown-menu-toggle { +.dropdown-menu-toggle { padding: 6px 8px 6px 10px; background-color: #fff; color: #303030; @@ -835,21 +510,16 @@ table a code { border-radius: 0.25rem; white-space: nowrap; } - .no-outline.dropdown-menu-toggle { +.no-outline.dropdown-menu-toggle { outline: 0; } - .dropdown-menu-toggle .fa { - color: #c4c4c4; -} -.dropdown-menu-toggle { +.dropdown-menu-toggle.dropdown-menu-toggle { + justify-content: flex-start; + overflow: hidden; padding-right: 25px; position: relative; - width: 160px; text-overflow: ellipsis; - overflow: hidden; -} -.dropdown-menu-toggle .fa { - position: absolute; + width: 160px; } .dropdown-menu { display: none; @@ -896,6 +566,13 @@ table a code { text-align: left; width: 100%; } +.dropdown-menu li > a:active, +.dropdown-menu li button:active { + background-color: #eee; + color: #303030; + outline: 0; + text-decoration: none; +} .dropdown-menu .divider { height: 1px; margin: 0.25rem 0; @@ -905,66 +582,39 @@ table a code { .dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) { margin-right: 40px; } -.dropdown-select { - width: 300px; -} - -@media (max-width: 767.98px) { - .dropdown-select { - width: 100%; - } -} -.dropdown-content { - max-height: 252px; - overflow-y: auto; -} -.dropdown-loading { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: none; - z-index: 9; - background-color: rgba(255, 255, 255, 0.6); - font-size: 28px; -} -.dropdown-loading .fa { - position: absolute; - top: 50%; - left: 50%; - margin-top: -14px; - margin-left: -14px; -} - @media (max-width: 575.98px) { .navbar-gitlab li.dropdown { position: static; } + .navbar-gitlab li.dropdown.user-counter { + margin-left: 8px !important; + } + .navbar-gitlab li.dropdown.user-counter > a { + padding: 0 4px !important; + } header.navbar-gitlab .dropdown .dropdown-menu { width: 100%; min-width: 100%; } } - @media (max-width: 767.98px) { .dropdown-menu-toggle { width: 100%; } } -textarea { - resize: vertical; -} input { border-radius: 0.25rem; color: #303030; background-color: #fff; } - .search form { +.form-control { border-radius: 4px; padding: 6px 10px; } - .search form::placeholder { +.form-control::-ms-input-placeholder { + color: #868686; +} +.form-control::placeholder { color: #868686; } .navbar-gitlab { @@ -973,7 +623,6 @@ input { margin-bottom: 0; min-height: 40px; border: 0; - border-bottom: 1px solid #dbdbdb; position: fixed; top: 0; left: 0; @@ -1023,9 +672,6 @@ input { .navbar-gitlab .header-content .title img + .logo-text { margin-left: 8px; } -.navbar-gitlab .header-content .title.wrap { - white-space: normal; -} .navbar-gitlab .header-content .title a { display: flex; align-items: center; @@ -1033,9 +679,6 @@ input { margin: 5px 2px 5px -8px; border-radius: 4px; } -.navbar-gitlab .header-content .dropdown.open > a { - border-bottom-color: #fff; -} .navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { margin: 0 2px; } @@ -1044,7 +687,6 @@ input { border-top: 0; padding: 0; } - @media (max-width: 575.98px) { .navbar-gitlab .navbar-collapse { flex: 1 1 auto; @@ -1053,7 +695,6 @@ input { .navbar-gitlab .navbar-collapse .nav { flex-wrap: nowrap; } - @media (max-width: 575.98px) { .navbar-gitlab .navbar-collapse .nav > li:not(.d-none) a { margin-left: 0; @@ -1076,7 +717,10 @@ input { text-align: center; color: currentColor; } - +.navbar-gitlab .container-fluid .navbar-toggler.active { + color: currentColor; + background-color: transparent; +} @media (max-width: 575.98px) { .navbar-gitlab .container-fluid .navbar-nav { display: flex; @@ -1084,11 +728,14 @@ input { flex-direction: row; } } -.navbar-gitlab .container-fluid .navbar-nav li .badge.badge-pill { +.navbar-gitlab + .container-fluid + .navbar-nav + li + .badge.badge-pill:not(.merge-request-badge) { box-shadow: none; font-weight: 600; } - @media (max-width: 575.98px) { .navbar-gitlab .container-fluid .nav > li.header-user { padding-left: 10px; @@ -1100,7 +747,6 @@ input { padding: 6px 8px; height: 32px; } - @media (max-width: 575.98px) { .navbar-gitlab .container-fluid .nav > li > a { padding: 0; @@ -1109,7 +755,12 @@ input { .navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle { margin-left: 2px; } -.navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle .header-user-avatar { +.navbar-gitlab + .container-fluid + .nav + > li + > a.header-user-dropdown-toggle + .header-user-avatar { margin-right: 0; } .navbar-gitlab .container-fluid .nav > li .header-new-dropdown-toggle { @@ -1130,7 +781,9 @@ input { height: 32px; font-weight: 600; } +.navbar-sub-nav > li .top-nav-toggle, .navbar-sub-nav > li > button, +.navbar-nav > li .top-nav-toggle, .navbar-nav > li > button { background: transparent; border: 0; @@ -1168,31 +821,25 @@ input { font-weight: 400; margin-left: -6px; font-size: 11px; - color: #fff; + color: var(--gray-950, #fff); padding: 0 5px; line-height: 12px; border-radius: 7px; box-shadow: 0 1px 0 rgba(76, 78, 84, 0.2); } -.title-container .badge.badge-pill.green-badge, -.navbar-nav .badge.badge-pill.green-badge { - background-color: #108548; +.title-container .badge.badge-pill:not(.merge-request-badge).green-badge, +.navbar-nav .badge.badge-pill:not(.merge-request-badge).green-badge { + background-color: var(--green-400, #2da160); } -.title-container .badge.badge-pill.merge-requests-count, -.navbar-nav .badge.badge-pill.merge-requests-count { - background-color: #de7e00; +.title-container + .badge.badge-pill:not(.merge-request-badge).merge-requests-count, +.navbar-nav .badge.badge-pill:not(.merge-request-badge).merge-requests-count { + background-color: var(--orange-400, #c17d10); } -.title-container .badge.badge-pill.todos-count, -.navbar-nav .badge.badge-pill.todos-count { - background-color: #1f75cb; +.title-container .badge.badge-pill:not(.merge-request-badge).todos-count, +.navbar-nav .badge.badge-pill:not(.merge-request-badge).todos-count { + background-color: var(--blue-400, #428fdc); } -.title-container .canary-badge .badge, -.navbar-nav .canary-badge .badge { - font-size: 12px; - line-height: 16px; - padding: 0 0.5rem; -} - @media (max-width: 575.98px) { .navbar-gitlab .container-fluid { font-size: 18px; @@ -1217,45 +864,35 @@ input { float: none; } } -.header-user.show .dropdown-menu { - margin-top: 4px; - color: #303030; - left: auto; - max-height: 445px; -} -.header-user.show .dropdown-menu svg { - vertical-align: text-top; -} .header-user-avatar { float: left; margin-right: 5px; border-radius: 50%; border: 1px solid #f5f5f5; } -.media { - display: flex; - align-items: flex-start; -} -.card { - margin-bottom: 16px; +.notification-dot { + background-color: #d99530; + height: 12px; + width: 12px; + margin-top: -15px; + pointer-events: none; + visibility: hidden; } -.content-wrapper { - width: 100%; +.top-nav-toggle .dropdown-icon { + margin-right: 0.5rem; } -.content-wrapper .container-fluid { - padding: 0 16px; +.tanuki-logo .tanuki-left-ear, +.tanuki-logo .tanuki-right-ear, +.tanuki-logo .tanuki-nose { + fill: #e24329; } - -@media (min-width: 768px) { - .page-with-contextual-sidebar { - padding-left: 50px; - } +.tanuki-logo .tanuki-left-eye, +.tanuki-logo .tanuki-right-eye { + fill: #fc6d26; } - -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - padding-left: 220px; - } +.tanuki-logo .tanuki-left-cheek, +.tanuki-logo .tanuki-right-cheek { + fill: #fca326; } .context-header { position: relative; @@ -1282,9 +919,20 @@ input { overflow: hidden; text-overflow: ellipsis; } -.context-header .sidebar-context-title.text-secondary { - font-weight: normal; - font-size: 0.8em; +@media (min-width: 768px) { + .page-with-contextual-sidebar { + padding-left: 48px; + } +} +@media (min-width: 1200px) { + .page-with-contextual-sidebar { + padding-left: 220px; + } +} +@media (min-width: 768px) { + .page-with-icon-sidebar { + padding-left: 48px; + } } .nav-sidebar { position: fixed; @@ -1297,20 +945,18 @@ input { box-shadow: inset -1px 0 0 #dbdbdb; transform: translate3d(0, 0, 0); } - @media (min-width: 576px) and (max-width: 576px) { .nav-sidebar:not(.sidebar-collapsed-desktop) { box-shadow: inset -1px 0 0 #dbdbdb, 2px 1px 3px rgba(0, 0, 0, 0.1); } } .nav-sidebar.sidebar-collapsed-desktop { - width: 50px; + width: 48px; } .nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll { overflow-x: hidden; } .nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge), -.nav-sidebar.sidebar-collapsed-desktop .sidebar-context-title, .nav-sidebar.sidebar-collapsed-desktop .nav-item-name { border: 0; clip: rect(0, 0, 0, 0); @@ -1331,9 +977,6 @@ input { .nav-sidebar.sidebar-collapsed-desktop .avatar-container { margin: 0 auto; } -.nav-sidebar.sidebar-expanded-mobile { - left: 0; -} .nav-sidebar a { text-decoration: none; } @@ -1356,7 +999,6 @@ input { .nav-sidebar li.active > a { font-weight: 600; } - @media (max-width: 767.98px) { .nav-sidebar { left: -220px; @@ -1373,16 +1015,15 @@ input { height: 16px; width: 16px; } - @media (min-width: 768px) and (max-width: 1199px) { .nav-sidebar:not(.sidebar-expanded-mobile) { - width: 50px; + width: 48px; } .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll { overflow-x: hidden; } - .nav-sidebar:not(.sidebar-expanded-mobile) .badge.badge-pill:not(.fly-out-badge), - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title, + .nav-sidebar:not(.sidebar-expanded-mobile) + .badge.badge-pill:not(.fly-out-badge), .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name { border: 0; clip: rect(0, 0, 0, 0); @@ -1405,12 +1046,26 @@ input { } .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { height: 60px; - width: 50px; + width: 48px; } .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { padding: 10px 4px; } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li .sidebar-sub-level-items:not(.flyout-list) { + .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + } + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { display: none; } .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container { @@ -1418,13 +1073,19 @@ input { } .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button { padding: 16px; - width: 49px; + width: 47px; } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .collapse-text, - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-left { + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .collapse-text, + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .icon-chevron-double-lg-left { display: none; } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-right { + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .icon-chevron-double-lg-right { display: block; margin: 0; } @@ -1441,10 +1102,12 @@ input { .sidebar-sub-level-items > li a { padding: 8px 16px 8px 40px; } +.sidebar-sub-level-items > li.active a { + background: rgba(0, 0, 0, 0.04); +} .sidebar-top-level-items { margin-bottom: 60px; } - @media (min-width: 576px) { .sidebar-top-level-items > li > a { margin-right: 1px; @@ -1464,23 +1127,28 @@ input { .sidebar-top-level-items > li.active .badge.badge-pill { font-weight: 600; } -.sidebar-top-level-items > li.active .sidebar-sub-level-items:not(.is-fly-out-only) { +.sidebar-top-level-items + > li.active + .sidebar-sub-level-items:not(.is-fly-out-only) { display: block; } .toggle-sidebar-button, .close-nav-button { - width: 219px; - position: fixed; height: 48px; - bottom: 0; padding: 0 16px; background-color: #fafafa; border: 0; - border-top: 1px solid #dbdbdb; color: #666; display: flex; align-items: center; } +.toggle-sidebar-button, +.close-nav-button { + position: fixed; + bottom: 0; + width: 219px; + border-top: 1px solid #dbdbdb; +} .toggle-sidebar-button svg, .close-nav-button svg { margin-right: 8px; @@ -1495,12 +1163,26 @@ input { } .sidebar-collapsed-desktop .context-header { height: 60px; - width: 50px; + width: 48px; } .sidebar-collapsed-desktop .context-header a { padding: 10px 4px; } -.sidebar-collapsed-desktop .sidebar-top-level-items > li .sidebar-sub-level-items:not(.flyout-list) { +.sidebar-collapsed-desktop .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} +.sidebar-collapsed-desktop + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { display: none; } .sidebar-collapsed-desktop .nav-icon-container { @@ -1508,13 +1190,15 @@ input { } .sidebar-collapsed-desktop .toggle-sidebar-button { padding: 16px; - width: 49px; + width: 47px; } .sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text, .sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left { display: none; } -.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-right { +.sidebar-collapsed-desktop + .toggle-sidebar-button + .icon-chevron-double-lg-right { display: block; margin: 0; } @@ -1530,7 +1214,6 @@ input { .close-nav-button { display: none; } - @media (max-width: 767.98px) { .close-nav-button { display: flex; @@ -1539,128 +1222,648 @@ input { display: none; } } -table.table { - margin-bottom: 16px; +body.sidebar-refactoring.ui-indigo + .nav-sidebar + li.active:not(.fly-out-top-item) + > a { + color: #2f2a6b; +} +body.sidebar-refactoring.ui-indigo + .nav-sidebar + li.active + .nav-icon-container + svg { + fill: #2f2a6b; +} +body.sidebar-refactoring .nav-sidebar { + box-shadow: none; +} +body.sidebar-refactoring .nav-sidebar li.active { + background-color: transparent; + box-shadow: none !important; } -table.table .dropdown-menu a { +@media (min-width: 768px) { + body.sidebar-refactoring .page-with-contextual-sidebar { + padding-left: 48px; + } +} +@media (min-width: 1200px) { + body.sidebar-refactoring .page-with-contextual-sidebar { + padding-left: 220px; + } +} +@media (min-width: 768px) { + body.sidebar-refactoring .page-with-icon-sidebar { + padding-left: 48px; + } +} +body.sidebar-refactoring .nav-sidebar { + position: fixed; + bottom: 0; + left: 0; + z-index: 600; + width: 220px; + top: 40px; + background-color: #f0f0f0; + transform: translate3d(0, 0, 0); +} +body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop { + width: 48px; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .nav-sidebar-inner-scroll { + overflow-x: hidden; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .badge.badge-pill:not(.fly-out-badge), +body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .nav-item-name, +body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .collapse-text { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .sidebar-top-level-items + > li + > a { + min-height: unset; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .fly-out-top-item:not(.divider) { + display: block !important; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + .avatar-container { + margin: 0 auto; +} +body.sidebar-refactoring + .nav-sidebar.sidebar-collapsed-desktop + li.active:not(.fly-out-top-item) + > a { + background-color: rgba(41, 41, 97, 0.08); +} +body.sidebar-refactoring .nav-sidebar a { text-decoration: none; + color: #2f2a6b; } -table.table .success, -table.table .info { - color: #fff; +body.sidebar-refactoring .nav-sidebar li { + white-space: nowrap; +} +body.sidebar-refactoring .nav-sidebar li .nav-item-name { + flex: 1; } -table.table .success a:not(.btn), -table.table .info a:not(.btn) { - text-decoration: underline; +body.sidebar-refactoring .nav-sidebar li > a, +body.sidebar-refactoring .nav-sidebar li > .fly-out-top-item-container { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + display: flex; + align-items: center; + border-radius: 0.25rem; + width: auto; + line-height: 1rem; + margin: 1px 4px; +} +body.sidebar-refactoring .nav-sidebar li.active > a { + font-weight: 600; +} +body.sidebar-refactoring + .nav-sidebar + li.active:not(.fly-out-top-item) + > a:not(.has-sub-items) { + background-color: rgba(41, 41, 97, 0.08); +} +body.sidebar-refactoring .nav-sidebar ul { + padding-left: 0; + list-style: none; +} +@media (max-width: 767.98px) { + body.sidebar-refactoring .nav-sidebar { + left: -220px; + } +} +body.sidebar-refactoring .nav-sidebar .nav-icon-container { + display: flex; + margin-right: 8px; +} +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item { + display: none; +} +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + a, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item.active + a, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container { + margin-left: 0; + margin-right: 0; + padding-left: 1rem; + padding-right: 1rem; + cursor: default; + pointer-events: none; + font-size: 0.75rem; + background-color: #2f2a6b; color: #fff; + margin-top: -0.25rem; + margin-bottom: -0.25rem; + margin-top: 0; + position: relative; + background-color: #000; +} +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + a + strong, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item.active + a + strong, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container + strong { + font-weight: 400; } -pre { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + a::before, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item.active + a::before, +body.sidebar-refactoring + .nav-sidebar + a:not(.has-sub-items) + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container::before { + position: absolute; + content: ""; display: block; - padding: 8px 12px; - margin: 0 0 8px; - font-size: 13px; - word-break: break-all; - word-wrap: break-word; + top: 50%; + left: -0.25rem; + margin-top: -0.5rem; + width: 0; + height: 0; + border-top: 0.5rem solid transparent; + border-bottom: 0.5rem solid transparent; + border-right: 0.5rem solid #000; +} +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item { + display: none; +} +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item + a, +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item.active + a, +body.sidebar-refactoring + .nav-sidebar + a.has-sub-items + + .sidebar-sub-level-items + .fly-out-top-item + .fly-out-top-item-container { + margin-left: 0; + margin-right: 0; + padding-left: 1rem; + padding-right: 1rem; + cursor: default; + pointer-events: none; + font-size: 0.75rem; + background-color: #2f2a6b; + color: #fff; + margin-top: -0.25rem; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +@media (min-width: 768px) and (max-width: 1199px) { + body.sidebar-refactoring .nav-sidebar:not(.sidebar-expanded-mobile) { + width: 48px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .nav-sidebar-inner-scroll { + overflow-x: hidden; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .badge.badge-pill:not(.fly-out-badge), + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .nav-item-name, + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .collapse-text { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-top-level-items + > li + > a { + min-height: unset; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .fly-out-top-item:not(.divider) { + display: block !important; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .avatar-container { + margin: 0 auto; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + li.active:not(.fly-out-top-item) + > a { + background-color: rgba(41, 41, 97, 0.08); + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header { + height: 60px; + width: 48px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header + a { + padding: 10px 4px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header { + height: auto; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .context-header + a { + padding: 0.25rem; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { + display: none; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .nav-icon-container { + margin-right: 0; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button { + width: 48px; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .collapse-text { + display: none; + } + body.sidebar-refactoring + .nav-sidebar:not(.sidebar-expanded-mobile) + .toggle-sidebar-button + .icon-chevron-double-lg-left { + transform: rotate(180deg); + display: block; + margin: 0; + } +} +body.sidebar-refactoring .nav-sidebar-inner-scroll { + height: 100%; + width: 100%; + overflow: auto; +} +body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header { + margin-top: 0.25rem; +} +body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header a { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + display: flex; + align-items: center; + border-radius: 0.25rem; + width: auto; + line-height: 1rem; + margin: 1px 4px; + padding: 0.25rem; + margin-bottom: 0.25rem; + margin-top: 0; +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container { + font-weight: 400; + flex: none; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container.rect-avatar { + border-style: none; +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container.rect-avatar + .avatar.s32 { color: #303030; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); +} +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .sidebar-context-title { + color: #2f2a6b; +} +body.sidebar-refactoring .sidebar-top-level-items { + margin-top: 0.25rem; + margin-bottom: 60px; +} +body.sidebar-refactoring .sidebar-top-level-items .context-header a { + padding: 0.25rem; + margin-bottom: 0.25rem; + margin-top: 0; +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container { + font-weight: 400; + flex: none; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container.rect-avatar { + border-style: none; +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container.rect-avatar + .avatar.s32 { + color: #303030; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); +} +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .sidebar-context-title { + color: #2f2a6b; +} +body.sidebar-refactoring .sidebar-top-level-items > li .badge.badge-pill { + border-radius: 0.5rem; + padding-top: 0.125rem; + padding-bottom: 0.125rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: #cbe2f9; + color: #0b5cad; +} +body.sidebar-refactoring + .sidebar-top-level-items + > li.active + .sidebar-sub-level-items:not(.is-fly-out-only) { + display: block; +} +body.sidebar-refactoring + .sidebar-top-level-items + > li.active + .badge.badge-pill { + font-weight: 400; + color: #0b5cad; +} +body.sidebar-refactoring .sidebar-sub-level-items { + padding-top: 0; + padding-bottom: 0; + display: none; +} +body.sidebar-refactoring .sidebar-sub-level-items:not(.fly-out-list) li > a { + padding-left: 2.25rem; +} +body.sidebar-refactoring .toggle-sidebar-button, +body.sidebar-refactoring .close-nav-button { + height: 48px; + padding: 0 16px; background-color: #fafafa; - border: 1px solid #dbdbdb; - border-radius: 2px; + border: 0; + color: #666; + display: flex; + align-items: center; + background-color: #f0f0f0; + border-top: 1px solid #dbdbdb; + color: #2f2a6b; + position: fixed; + bottom: 0; + width: 220px; +} +body.sidebar-refactoring .toggle-sidebar-button .collapse-text, +body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-left, +body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-right, +body.sidebar-refactoring .close-nav-button .collapse-text, +body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-left, +body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-right { + color: inherit; } -.monospace { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; +body.sidebar-refactoring .collapse-text { + white-space: nowrap; + overflow: hidden; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header { + height: 60px; + width: 48px; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a { + padding: 10px 4px; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .sidebar-context-title { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header { + height: auto; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a { + padding: 0.25rem; +} +body.sidebar-refactoring + .sidebar-collapsed-desktop + .sidebar-top-level-items + > li + .sidebar-sub-level-items:not(.flyout-list) { + display: none; } -input::-moz-placeholder, -textarea::-moz-placeholder { +body.sidebar-refactoring .sidebar-collapsed-desktop .nav-icon-container { + margin-right: 0; +} +body.sidebar-refactoring .sidebar-collapsed-desktop .toggle-sidebar-button { + width: 48px; +} +body.sidebar-refactoring + .sidebar-collapsed-desktop + .toggle-sidebar-button + .collapse-text { + display: none; +} +body.sidebar-refactoring + .sidebar-collapsed-desktop + .toggle-sidebar-button + .icon-chevron-double-lg-left { + transform: rotate(180deg); + display: block; + margin: 0; +} +body.sidebar-refactoring .close-nav-button { + display: none; +} +@media (max-width: 767.98px) { + body.sidebar-refactoring .close-nav-button { + display: flex; + } + body.sidebar-refactoring .toggle-sidebar-button { + display: none; + } +} +input::-moz-placeholder { color: #868686; opacity: 1; } -input::-ms-input-placeholder, -textarea::-ms-input-placeholder { +input::-ms-input-placeholder { color: #868686; } -input:-ms-input-placeholder, -textarea:-ms-input-placeholder { +input:-ms-input-placeholder { color: #868686; } svg { fill: currentColor; } - svg.s12 { width: 12px; height: 12px; } - svg.s16 { width: 16px; height: 16px; } - svg.s18 { width: 18px; height: 18px; } - +svg.s32 { + width: 32px; + height: 32px; +} svg.s12 { vertical-align: -1px; } - svg.s16 { vertical-align: -3px; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} -table.code { - width: 100%; - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - border: 0; - border-collapse: separate; - margin: 0; - padding: 0; - table-layout: fixed; - border-radius: 0 0 4px 4px; -} -.frame .badge.badge-pill { - position: absolute; - background-color: #428fdc; - color: #fff; - border: #fff 1px solid; - min-height: 16px; - padding: 5px 8px; - border-radius: 12px; -} -.frame .badge.badge-pill { - transform: translate(-50%, -50%); -} -.color-label { - padding: 0 0.5rem; - line-height: 16px; - border-radius: 100px; - color: #fff; -} -.label-link { - display: inline-flex; - vertical-align: text-bottom; -} -.milestones { - padding: 8px; - margin-top: 8px; - border-radius: 4px; - background-color: #dbdbdb; -} .search { margin: 0 8px; } .search form { + display: block; margin: 0; padding: 4px; width: 200px; @@ -1669,7 +1872,6 @@ table.code { border: 0; border-radius: 4px; } - @media (min-width: 1200px) { .search form { width: 320px; @@ -1713,43 +1915,43 @@ table.code { max-height: 400px; overflow: auto; } - @media (min-width: 1200px) { .search .search-input-wrap .dropdown-menu { width: 320px; } } -.search .search-input-wrap .dropdown-content { - max-height: 382px; +.search .identicon { + flex-basis: 16px; + flex-shrink: 0; + margin-right: 4px; } -.settings { - border-top: 1px solid #dbdbdb; -} -.settings:first-of-type { - margin-top: 10px; - border: 0; -} -.settings + div .settings:first-of-type { - margin-top: 0; - border-top: 1px solid #dbdbdb; -} -.avatar, .avatar-container { +.avatar, +.avatar-container { float: left; margin-right: 16px; border-radius: 50%; border: 1px solid #f5f5f5; } -.s16.avatar, .s16.avatar-container { +.avatar.s16, +.avatar-container.s16 { width: 16px; height: 16px; margin-right: 8px; } -.s18.avatar, .s18.avatar-container { +.avatar.s18, +.avatar-container.s18 { width: 18px; height: 18px; margin-right: 8px; } -.s40.avatar, .s40.avatar-container { +.avatar.s32, +.avatar-container.s32 { + width: 32px; + height: 32px; + margin-right: 8px; +} +.avatar.s40, +.avatar-container.s40 { width: 40px; height: 40px; margin-right: 8px; @@ -1763,15 +1965,49 @@ table.code { overflow: hidden; border-color: rgba(0, 0, 0, 0.1); } -.avatar.center { - font-size: 14px; - line-height: 1.8em; - text-align: center; -} .avatar.avatar-tile { border-radius: 0; border: 0; } +.identicon { + text-align: center; + vertical-align: top; + color: #525252; + background-color: #eee; +} +.identicon.s16 { + font-size: 10px; + line-height: 16px; +} +.identicon.s32 { + font-size: 14px; + line-height: 32px; +} +.identicon.s40 { + font-size: 16px; + line-height: 38px; +} +.identicon.bg1 { + background-color: #ffebee; +} +.identicon.bg2 { + background-color: #f3e5f5; +} +.identicon.bg3 { + background-color: #e8eaf6; +} +.identicon.bg4 { + background-color: #e3f2fd; +} +.identicon.bg5 { + background-color: #e0f2f1; +} +.identicon.bg6 { + background-color: #fbe9e7; +} +.identicon.bg7 { + background-color: #eee; +} .avatar-container { overflow: hidden; display: flex; @@ -1803,9 +2039,25 @@ table.code { .rect-avatar.s18 { border-radius: 2px; } +.rect-avatar.s32, +body.sidebar-refactoring + .nav-sidebar-inner-scroll + > div.context-header + a + .avatar-container.rect-avatar + .avatar.s32, +body.sidebar-refactoring + .sidebar-top-level-items + .context-header + a + .avatar-container.rect-avatar + .avatar.s32 { + border-radius: 4px; +} .rect-avatar.s40 { border-radius: 4px; } + .tab-width-8 { -moz-tab-size: 8; tab-size: 8; @@ -1821,12 +2073,40 @@ table.code { white-space: nowrap; width: 1px; } +.gl-border-none\! { + border-style: none !important; +} +.gl-display-none { + display: none; +} +@media (min-width: 36rem) { + .gl-sm-display-block { + display: block; + } +} +.gl-absolute { + position: absolute; +} +.gl-px-3 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.gl-pr-2 { + padding-right: 0.25rem; +} .gl-ml-3 { margin-left: 0.5rem; } -.content-wrapper > .alert-wrapper, -#content-body, .modal-dialog { - display: block; +.gl-mx-0\! { + margin-left: 0 !important; + margin-right: 0 !important; } -@import 'cloaking'; +.gl-font-sm { + font-size: 0.75rem; +} +.gl-font-weight-bold { + font-weight: 600; +} + +@import "startup/cloaking"; @include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index 6b78abdb5e0..81a87742850 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -1,3 +1,6 @@ +// DO NOT EDIT! This is auto-generated from "yarn run generate:startup_css" +// Please see the feedback issue for more details and help: +// https://gitlab.com/gitlab-org/gitlab/-/issues/331812 @charset "UTF-8"; *, *::before, @@ -8,12 +11,14 @@ html { font-family: sans-serif; line-height: 1.15; } - header, nav, section { +header { display: block; } body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; font-weight: 400; line-height: 1.5; @@ -26,7 +31,8 @@ hr { height: 0; overflow: visible; } -h1, h2, h3 { +h1, +h3 { margin-top: 0; margin-bottom: 0.25rem; } @@ -34,52 +40,15 @@ p { margin-top: 0; margin-bottom: 1rem; } -address { - margin-bottom: 1rem; - font-style: normal; - line-height: inherit; -} - -ul { - margin-top: 0; - margin-bottom: 1rem; -} - -ul ul { - margin-bottom: 0; -} - -strong { - font-weight: bolder; -} -sub { - position: relative; - font-size: 75%; - line-height: 0; - vertical-align: baseline; -} -sub { - bottom: -.25em; -} a { color: #007bff; text-decoration: none; background-color: transparent; } -a:not([href]) { +a:not([href]):not([class]) { color: inherit; text-decoration: none; } -pre, -code { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - font-size: 1em; -} -pre { - margin-top: 0; - margin-bottom: 1rem; - overflow: auto; -} img { vertical-align: middle; border-style: none; @@ -88,89 +57,46 @@ svg { overflow: hidden; vertical-align: middle; } -table { - border-collapse: collapse; -} -th { - text-align: inherit; -} label { display: inline-block; margin-bottom: 0.5rem; } -button { - border-radius: 0; -} -input, -button, -textarea { +input { margin: 0; font-family: inherit; font-size: inherit; line-height: inherit; } -button, input { overflow: visible; } -button { - text-transform: none; -} -button:not(:disabled), -[type="button"]:not(:disabled), -[type="reset"]:not(:disabled), [type="submit"]:not(:disabled) { cursor: pointer; } -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { padding: 0; border-style: none; } - -input[type="checkbox"] { - box-sizing: border-box; - padding: 0; -} -textarea { - overflow: auto; - resize: vertical; -} fieldset { min-width: 0; padding: 0; margin: 0; border: 0; } -[type="search"] { - outline-offset: -2px; -} -summary { - display: list-item; - cursor: pointer; -} -template { - display: none; -} [hidden] { display: none !important; } -h1, h2, h3, -.h1, .h2, .h3 { +h1, +h3 { margin-bottom: 0.25rem; font-weight: 600; line-height: 1.2; color: #303030; } -h1, .h1 { +h1 { font-size: 2.1875rem; } -h2, .h2 { - font-size: 1.75rem; -} -h3, .h3 { +h3 { font-size: 1.53125rem; } hr { @@ -179,28 +105,6 @@ hr { border: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); } -.list-unstyled { - padding-left: 0; - list-style: none; -} -code { - font-size: 90%; - color: #1f1f1f; - word-wrap: break-word; -} -a > code { - color: inherit; -} -pre { - display: block; - font-size: 90%; - color: #303030; -} -pre code { - font-size: inherit; - color: inherit; - word-break: normal; -} .container { width: 100%; padding-right: 15px; @@ -208,56 +112,21 @@ pre code { margin-right: auto; margin-left: auto; } - @media (min-width: 576px) { .container { max-width: 540px; } } - @media (min-width: 768px) { .container { max-width: 720px; } } - @media (min-width: 992px) { .container { max-width: 960px; } } - -@media (min-width: 1200px) { - .container { - max-width: 1140px; - } -} -.container-fluid { - width: 100%; - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} - -@media (min-width: 576px) { - .container { - max-width: 540px; - } -} - -@media (min-width: 768px) { - .container { - max-width: 720px; - } -} - -@media (min-width: 992px) { - .container { - max-width: 960px; - } -} - @media (min-width: 1200px) { .container { max-width: 1140px; @@ -269,19 +138,26 @@ pre code { margin-right: -15px; margin-left: -15px; } - .col-sm-5, .col-sm-7, .col-sm-12 { +.col, +.col-sm-5, +.col-sm-7, +.col-sm-12 { position: relative; width: 100%; padding-right: 15px; padding-left: 15px; } +.col { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; +} .order-1 { order: 1; } .order-12 { order: 12; } - @media (min-width: 576px) { .col-sm-5 { flex: 0 0 41.66667%; @@ -302,18 +178,7 @@ pre code { order: 12; } } -.table { - width: 100%; - margin-bottom: 0.5rem; - color: #303030; -} -.table th, -.table td { - padding: 0.75rem; - vertical-align: top; - border-top: 1px solid #dbdbdb; -} -.form-control, .search form { +.form-control { display: block; width: 100%; height: 34px; @@ -327,52 +192,36 @@ pre code { border: 1px solid #dbdbdb; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } -.form-control:-moz-focusring, .search form:-moz-focusring { +.form-control:-moz-focusring { color: transparent; text-shadow: 0 0 0 #303030; } -.form-control::placeholder, .search form::placeholder { +.form-control::-ms-input-placeholder { color: #5e5e5e; opacity: 1; } -.form-control:disabled, .search form:disabled { - background-color: #fafafa; +.form-control::placeholder { + color: #5e5e5e; opacity: 1; } -textarea.form-control { - height: auto; +.form-control:disabled { + background-color: #fafafa; + opacity: 1; } .form-group { margin-bottom: 1rem; } -.form-inline { +.form-row { display: flex; - flex-flow: row wrap; - align-items: center; + flex-wrap: wrap; + margin-right: -5px; + margin-left: -5px; } - -@media (min-width: 576px) { - .form-inline label { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 0; - } - .form-inline .form-group { - display: flex; - flex: 0 0 auto; - flex-flow: row wrap; - align-items: center; - margin-bottom: 0; - } - .form-inline .form-control, .form-inline .search form, .search .form-inline form { - display: inline-block; - width: auto; - vertical-align: middle; - } +.form-row > .col { + padding-right: 5px; + padding-left: 5px; } .btn { display: inline-block; @@ -380,7 +229,7 @@ textarea.form-control { color: #303030; text-align: center; vertical-align: middle; - cursor: pointer; + -moz-user-select: none; user-select: none; background-color: transparent; border: 1px solid transparent; @@ -389,147 +238,16 @@ textarea.form-control { line-height: 20px; border-radius: 0.25rem; } - @media (prefers-reduced-motion: reduce) { } -.btn.disabled, .btn:disabled { +.btn:disabled { opacity: 0.65; } -a.btn.disabled, -fieldset:disabled a.btn { - pointer-events: none; -} -.btn-success { - color: #fff; - background-color: #108548; - border-color: #108548; -} -.btn-success.disabled, .btn-success:disabled { - color: #fff; - background-color: #108548; - border-color: #108548; -} -.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active, -.show > .btn-success.dropdown-menu-toggle { - color: #fff; - background-color: #0b572f; - border-color: #094c29; -} - .login-page input[type='submit'] { - display: block; - width: 100%; -} - .login-page input[type='submit'] + input[type='submit'] { - margin-top: 0.5rem; -} - .login-page input[type="submit"][type='submit'], -.login-page input[type="reset"][type='submit'], -.login-page input[type="button"][type='submit'] { - width: 100%; -} -.collapse:not(.show) { - display: none; -} - -.dropdown { - position: relative; -} - .dropdown-menu-toggle { - white-space: nowrap; -} - .dropdown-menu-toggle::after { - display: inline-block; - margin-left: 0.255em; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid; - border-right: 0.3em solid transparent; - border-bottom: 0; - border-left: 0.3em solid transparent; -} - .dropdown-menu-toggle:empty::after { - margin-left: 0; -} -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: 1000; - display: none; - float: left; - min-width: 10rem; - padding: 0.5rem 0; - margin: 0.125rem 0 0; - font-size: 1rem; - color: #303030; - text-align: left; - list-style: none; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.15); - border-radius: 0.25rem; -} -.dropdown-menu-right { - right: 0; - left: auto; -} - .divider { - height: 0; - margin: 4px 0; - overflow: hidden; - border-top: 1px solid #dbdbdb; -} -.dropdown-menu.show { - display: block; -} -.nav { - display: flex; - flex-wrap: wrap; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.nav-link { - display: block; - padding: 0.5rem 1rem; +.btn:not(:disabled):not(.disabled) { + cursor: pointer; } -.nav-link.disabled { - color: #5e5e5e; +fieldset:disabled a.btn { pointer-events: none; - cursor: default; -} -.nav-tabs { - border-bottom: 1px solid #999; -} -.nav-tabs .nav-item { - margin-bottom: -1px; -} -.nav-tabs .nav-link { - border: 1px solid transparent; - border-top-left-radius: 0.25rem; - border-top-right-radius: 0.25rem; -} -.nav-tabs .nav-link.disabled { - color: #5e5e5e; - background-color: transparent; - border-color: transparent; -} -.nav-tabs .nav-link.active, -.nav-tabs .nav-item.show .nav-link { - color: #525252; - background-color: #fff; - border-color: #999 #999 #fff; -} -.nav-tabs .dropdown-menu { - margin-top: -1px; - border-top-left-radius: 0; - border-top-right-radius: 0; -} -.tab-content > .tab-pane { - display: none; -} -.tab-content > .active { - display: block; } .navbar { position: relative; @@ -539,218 +257,18 @@ fieldset:disabled a.btn { justify-content: space-between; padding: 0.25rem 0.5rem; } -.navbar .container, -.navbar .container-fluid { +.navbar .container { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; } -.navbar-nav { - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar-nav .nav-link { - padding-right: 0; - padding-left: 0; -} -.navbar-nav .dropdown-menu { - position: static; - float: none; -} -.navbar-collapse { - flex-basis: 100%; - flex-grow: 1; - align-items: center; -} -.navbar-toggler { - padding: 0.25rem 0.75rem; - font-size: 1.25rem; - line-height: 1; - background-color: transparent; - border: 1px solid transparent; - border-radius: 0.25rem; -} - -@media (max-width: 575.98px) { - .navbar-expand-sm > .container, - .navbar-expand-sm > .container-fluid { - padding-right: 0; - padding-left: 0; - } -} - -@media (min-width: 576px) { - .navbar-expand-sm { - flex-flow: row nowrap; - justify-content: flex-start; - } - .navbar-expand-sm .navbar-nav { - flex-direction: row; - } - .navbar-expand-sm .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-sm .navbar-nav .nav-link { - padding-right: 0.5rem; - padding-left: 0.5rem; - } - .navbar-expand-sm > .container, - .navbar-expand-sm > .container-fluid { - flex-wrap: nowrap; - } - .navbar-expand-sm .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-sm .navbar-toggler { - display: none; - } -} -.card { - position: relative; - display: flex; - flex-direction: column; - min-width: 0; - word-wrap: break-word; - background-color: #fff; - background-clip: border-box; - border: 1px solid #dbdbdb; - border-radius: 0.25rem; -} -.card > hr { - margin-right: 0; - margin-left: 0; -} -.badge { - display: inline-block; - padding: 0.25em 0.4em; - font-size: 75%; - font-weight: 600; - line-height: 1; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: 0.25rem; -} - -@media (prefers-reduced-motion: reduce) { -} -.badge:empty { - display: none; -} -.btn .badge { - position: relative; - top: -1px; -} -.badge-pill { - padding-right: 0.6em; - padding-left: 0.6em; - border-radius: 10rem; -} -.media { - display: flex; - align-items: flex-start; -} -.close { - float: right; - font-size: 1.5rem; - font-weight: 600; - line-height: 1; - color: #000; - text-shadow: 0 1px 0 #fff; - opacity: .5; -} -button.close { - padding: 0; - background-color: transparent; - border: 0; - appearance: none; -} -a.close.disabled { - pointer-events: none; -} -.modal-dialog { - position: relative; - width: auto; - margin: 0.5rem; - pointer-events: none; -} - -@media (min-width: 576px) { - .modal-dialog { - max-width: 500px; - margin: 1.75rem auto; - } -} -.bg-transparent { - background-color: transparent !important; -} -.border { - border: 1px solid #dbdbdb !important; -} -.border-top { - border-top: 1px solid #dbdbdb !important; -} -.border-right { - border-right: 1px solid #dbdbdb !important; -} -.border-bottom { - border-bottom: 1px solid #dbdbdb !important; -} -.border-left { - border-left: 1px solid #dbdbdb !important; -} -.rounded { - border-radius: 0.25rem !important; -} -.clearfix::after { - display: block; - clear: both; - content: ""; -} -.d-none { - display: none !important; -} -.d-inline-block { - display: inline-block !important; -} .d-block { display: block !important; } .d-flex { display: flex !important; } - -@media (min-width: 576px) { - .d-sm-none { - display: none !important; - } -} - -@media (min-width: 768px) { - .d-md-block { - display: block !important; - } -} - -@media (min-width: 992px) { - .d-lg-none { - display: none !important; - } - .d-lg-block { - display: block !important; - } -} - -@media (min-width: 1200px) { - .d-xl-block { - display: block !important; - } -} .flex-wrap { flex-wrap: wrap !important; } @@ -760,9 +278,6 @@ a.close.disabled { .align-items-center { align-items: center !important; } -.float-right { - float: right !important; -} .fixed-top { position: fixed; top: 0; @@ -770,16 +285,8 @@ a.close.disabled { left: 0; z-index: 1030; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; +.ml-2 { + margin-left: 0.5rem !important; } .mt-3 { margin-top: 1rem !important; @@ -787,100 +294,129 @@ a.close.disabled { .mb-3 { margin-bottom: 1rem !important; } -.m-auto { - margin: auto !important; -} - @media (min-width: 576px) { .mt-sm-0 { margin-top: 0 !important; } } -.text-nowrap { - white-space: nowrap !important; -} -.text-left { - text-align: left !important; +.text-center { + text-align: center !important; } .font-weight-normal { font-weight: 400 !important; } -.visible { - visibility: visible !important; -} -.form-control.focus, .search form.focus { - color: #303030; +.gl-form-input, +.gl-form-input.form-control { background-color: #fff; - border-color: #80bdff; - outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 0.875rem; + line-height: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + height: auto; + color: #303030; + box-shadow: inset 0 0 0 1px #868686; + border-style: none; + appearance: none; + -moz-appearance: none; } -input[type="color"].form-control { - height: 34px; - padding: 0.125rem 0.25rem; +.gl-form-input:not(.form-control-plaintext):-moz-read-only, +.gl-form-input.form-control:not(.form-control-plaintext):-moz-read-only { + background-color: #fafafa; + color: #868686; + box-shadow: inset 0 0 0 1px #dbdbdb; + cursor: not-allowed; } -input[type="color"].form-control:disabled { - background-color: #666; - opacity: 0.65; +.gl-form-input:disabled, +.gl-form-input:not(.form-control-plaintext):read-only, +.gl-form-input.form-control:disabled, +.gl-form-input.form-control:not(.form-control-plaintext):read-only { + background-color: #fafafa; + color: #868686; + box-shadow: inset 0 0 0 1px #dbdbdb; + cursor: not-allowed; +} +.gl-form-input::-ms-input-placeholder, +.gl-form-input.form-control::-ms-input-placeholder { + color: #868686; } -.gl-badge { +.gl-form-input::placeholder, +.gl-form-input.form-control::placeholder { + color: #868686; +} +.gl-button { display: inline-flex; - align-items: center; - font-size: 0.75rem; - font-weight: 400; +} +.gl-button:not(.btn-link):active { + text-decoration: none; +} +.gl-button.gl-button { + border-width: 0; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + background-color: transparent; line-height: 1rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; + color: #303030; + fill: currentColor; + box-shadow: inset 0 0 0 1px #bfbfbf; + justify-content: center; + align-items: center; + font-size: 0.875rem; + border-radius: 0.25rem; +} +.gl-button.gl-button .gl-button-icon { + height: 1rem; + width: 1rem; + flex-shrink: 0; + margin-right: 0.25rem; + top: auto; +} +.gl-button.gl-button.btn-default { + background-color: #fff; +} +.gl-button.gl-button.btn-default:active { + box-shadow: inset 0 0 0 2px #5e5e5e, 0 0 0 1px rgba(255, 255, 255, 0.4), + 0 0 0 4px rgba(31, 117, 203, 0.48); outline: none; + background-color: #dbdbdb; +} +.gl-button.gl-button.btn-confirm { + color: #fff; } -body, .form-control, .search form, -.search form { +.gl-button.gl-button.btn-confirm { + background-color: #1f75cb; + box-shadow: inset 0 0 0 1px #1068bf; +} +.gl-button.gl-button.btn-confirm:active { + box-shadow: inset 0 0 0 2px #033464, 0 0 0 1px rgba(255, 255, 255, 0.4), + 0 0 0 4px rgba(31, 117, 203, 0.48); + outline: none; + background-color: #0b5cad; +} +body, +.form-control { font-size: 0.875rem; } -button, -html [type='button'], -[type='reset'], -[type='submit'], -[role='button'] { +[type="submit"] { cursor: pointer; } h1, -.h1, -h2, -.h2, -h3, -.h3 { +h3 { margin-top: 20px; margin-bottom: 10px; } -input[type='file'] { - line-height: 1; -} - -strong { - font-weight: bold; -} a { color: #1068bf; } hr { overflow: hidden; } -code { - padding: 2px 4px; - color: #1f1f1f; - background-color: #f0f0f0; - border-radius: 4px; -} -.code > code { - background-color: inherit; - padding: unset; -} -table { - border-spacing: 0; -} .hidden { display: none !important; visibility: hidden !important; @@ -888,39 +424,6 @@ table { .hide { display: none; } - .dropdown-menu-toggle::after { - display: none; -} -.badge:not(.gl-badge), -.label { - padding: 4px 5px; - font-size: 12px; - font-style: normal; - font-weight: 400; - display: inline-block; -} -.nav-tabs { - border-bottom: 0; -} -.nav-tabs .nav-link { - border-top: 0; - border-left: 0; - border-right: 0; -} -.nav-tabs .nav-item { - margin-bottom: 0; -} -pre code { - white-space: pre-wrap; -} -input[type="color"].form-control { - height: 34px; -} -.toggle-sidebar-button .collapse-text, -.toggle-sidebar-button .icon-chevron-double-lg-left, -.toggle-sidebar-button .icon-chevron-double-lg-right { - color: #666; -} svg { vertical-align: baseline; } @@ -933,10 +436,6 @@ body { body.navless { background-color: #fff !important; } -.content-wrapper { - margin-top: 40px; - padding-bottom: 100px; -} .container { padding-top: 0; z-index: 5; @@ -944,18 +443,11 @@ body.navless { .container .content { margin: 0; } - @media (max-width: 575.98px) { .container .content { margin-top: 20px; } } - -@media (max-width: 575.98px) { - .container .container .title { - padding-left: 15px !important; - } -} .navless-container { margin-top: 40px; padding-top: 32px; @@ -971,259 +463,35 @@ body.navless { color: #303030; white-space: nowrap; } -.btn:active, .btn.active { - box-shadow: rgba(0, 0, 0, 0.16); +.btn:active { + background-color: #f0f0f0; + box-shadow: none; +} +.btn:active { background-color: #eaeaea; border-color: #e3e3e3; color: #303030; } -.btn.btn-success { - background-color: #108548; - border-color: #217645; - color: #fff; -} -.btn.btn-success:active, .btn.btn-success.active { - box-shadow: rgba(0, 0, 0, 0.16); - background-color: #24663b; - border-color: #0d532a; - color: #fff; -} .btn svg { height: 15px; width: 15px; } -.btn svg:not(:last-child), -.btn .fa:not(:last-child) { +.btn svg:not(:last-child) { margin-right: 5px; } - .login-page input[type='submit'] { - width: 100%; - margin: 0; - margin-bottom: 15px; -} - .login-page input.btn[type='submit'] { - padding: 6px 0; -} -.badge.badge-pill:not(.gl-badge) { - font-weight: 400; - background-color: rgba(0, 0, 0, 0.07); - color: #525252; - vertical-align: baseline; -} -.hint { - font-style: italic; - color: #bfbfbf; -} -.bold { - font-weight: 600; -} -.tab-content { - overflow: visible; -} -pre.wrap { - word-break: break-word; - white-space: pre-wrap; +.light { + color: #303030; } hr { - margin: 24px 0; + margin: 1.5rem 0; border-top: 1px solid #eee; } -table a code { - position: relative; - top: -2px; - margin-right: 3px; -} -.loading { - margin: 20px auto; - height: 40px; - color: #525252; - font-size: 32px; - text-align: center; -} -.highlight { - text-shadow: none; -} -.chart { - overflow: hidden; - height: 220px; -} .footer-links { margin-bottom: 20px; } .footer-links a { margin-right: 15px; } -.break-word { - word-wrap: break-word; -} -.append-bottom-20 { - margin-bottom: 20px; -} -.center { - text-align: center; -} -.block { - display: block; -} -.flex { - display: flex; -} -.flex-grow { - flex-grow: 1; -} -.dropdown { - position: relative; -} -.show.dropdown .dropdown-menu { - transform: translateY(0); - display: block; - min-height: 40px; - max-height: 312px; - overflow-y: auto; -} - -@media (max-width: 575.98px) { - .show.dropdown .dropdown-menu { - width: 100%; - } -} - .show.dropdown .dropdown-menu-toggle, -.show.dropdown .dropdown-menu-toggle { - border-color: #c4c4c4; -} -.show.dropdown [data-toggle='dropdown'] { - outline: 0; -} -.search-input-container .dropdown-menu { - margin-top: 11px; -} - .dropdown-menu-toggle { - padding: 6px 8px 6px 10px; - background-color: #fff; - color: #303030; - font-size: 14px; - text-align: left; - border: 1px solid #dbdbdb; - border-radius: 0.25rem; - white-space: nowrap; -} - .no-outline.dropdown-menu-toggle { - outline: 0; -} - .dropdown-menu-toggle .fa { - color: #c4c4c4; -} -.dropdown-menu-toggle { - padding-right: 25px; - position: relative; - width: 160px; - text-overflow: ellipsis; - overflow: hidden; -} -.dropdown-menu-toggle .fa { - position: absolute; -} -.dropdown-menu { - display: none; - position: absolute; - width: auto; - top: 100%; - z-index: 300; - min-width: 240px; - max-width: 500px; - margin-top: 4px; - margin-bottom: 24px; - font-size: 14px; - font-weight: 400; - padding: 8px 0; - background-color: #fff; - border: 1px solid #dbdbdb; - border-radius: 0.25rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} -.dropdown-menu ul { - margin: 0; - padding: 0; -} -.dropdown-menu li { - display: block; - text-align: left; - list-style: none; - padding: 0 1px; -} -.dropdown-menu li > a, -.dropdown-menu li button { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - display: block; - font-weight: 400; - position: relative; - padding: 8px 12px; - color: #303030; - line-height: 16px; - white-space: normal; - overflow: hidden; - text-align: left; - width: 100%; -} -.dropdown-menu .divider { - height: 1px; - margin: 0.25rem 0; - padding: 0; - background-color: #dbdbdb; -} -.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) { - margin-right: 40px; -} -.dropdown-select { - width: 300px; -} - -@media (max-width: 767.98px) { - .dropdown-select { - width: 100%; - } -} -.dropdown-content { - max-height: 252px; - overflow-y: auto; -} -.dropdown-loading { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: none; - z-index: 9; - background-color: rgba(255, 255, 255, 0.6); - font-size: 28px; -} -.dropdown-loading .fa { - position: absolute; - top: 50%; - left: 50%; - margin-top: -14px; - margin-left: -14px; -} - -@media (max-width: 575.98px) { - .navbar-gitlab li.dropdown { - position: static; - } - header.navbar-gitlab .dropdown .dropdown-menu { - width: 100%; - min-width: 100%; - } -} - -@media (max-width: 767.98px) { - .dropdown-menu-toggle { - width: 100%; - } -} .flash-container { margin: 0; margin-bottom: 16px; @@ -1232,8 +500,8 @@ table a code { z-index: 1; } .flash-container.sticky { - position: sticky; position: -webkit-sticky; + position: sticky; top: 48px; z-index: 251; } @@ -1243,9 +511,6 @@ table a code { .flash-container:empty { margin: 0; } -textarea { - resize: vertical; -} input { border-radius: 0.25rem; color: #303030; @@ -1257,809 +522,58 @@ label { label.label-bold { font-weight: 600; } -.form-control, .search form { +.form-control { border-radius: 4px; padding: 6px 10px; } -.form-control::placeholder, .search form::placeholder { +.form-control::-ms-input-placeholder { color: #868686; } -.gl-field-error { - color: #dd2b0e; - font-size: 0.875rem; +.form-control::placeholder { + color: #868686; } -.gl-show-field-errors .form-control:not(textarea), .gl-show-field-errors .search form:not(textarea), .search .gl-show-field-errors form:not(textarea) { +.gl-show-field-errors .form-control:not(textarea) { height: 34px; } .gl-show-field-errors .gl-field-hint { color: #303030; } - -@media (max-width: 575.98px) { - .remember-me .remember-me-checkbox { - margin-top: 0; - } -} -body.ui-indigo .navbar-gitlab { - background-color: #292961; -} -body.ui-indigo .navbar-gitlab .navbar-collapse { - color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler { - border-left: 1px solid #6868b9; -} -body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler svg { - fill: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.active > a, -body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.active > button, body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.dropdown.show > a, -body.ui-indigo .navbar-gitlab .navbar-sub-nav > li.dropdown.show > button, -body.ui-indigo .navbar-gitlab .navbar-nav > li.active > a, -body.ui-indigo .navbar-gitlab .navbar-nav > li.active > button, -body.ui-indigo .navbar-gitlab .navbar-nav > li.dropdown.show > a, -body.ui-indigo .navbar-gitlab .navbar-nav > li.dropdown.show > button { - color: #292961; - background-color: #fff; -} -body.ui-indigo .navbar-gitlab .navbar-sub-nav { - color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .nav > li { - color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .nav > li > a.header-user-dropdown-toggle .header-user-avatar { - border-color: #d1d1f0; -} -body.ui-indigo .navbar-gitlab .nav > li.active > a, -body.ui-indigo .navbar-gitlab .nav > li.dropdown.show > a { - color: #292961; - background-color: #fff; -} -body.ui-indigo .search form { - background-color: rgba(209, 209, 240, 0.2); -} -body.ui-indigo .search .search-input::placeholder { - color: rgba(209, 209, 240, 0.8); -} -body.ui-indigo .search .search-input-wrap .search-icon, -body.ui-indigo .search .search-input-wrap .clear-icon { - fill: rgba(209, 209, 240, 0.8); -} -body.ui-indigo .nav-sidebar li.active { - box-shadow: inset 4px 0 0 #4b4ba3; -} -body.ui-indigo .nav-sidebar li.active > a { - color: #393982; -} -body.ui-indigo .nav-sidebar li.active .nav-icon-container svg { - fill: #393982; -} -body.ui-indigo .sidebar-top-level-items > li.active .badge.badge-pill { - color: #393982; -} -body.ui-indigo .nav-links li.active a, -body.ui-indigo .nav-links li a.active { - border-bottom: 2px solid #6666c4; -} -body.ui-indigo .nav-links li.active a .badge.badge-pill, -body.ui-indigo .nav-links li a.active .badge.badge-pill { - font-weight: 600; -} -.navbar-gitlab { - padding: 0 16px; - z-index: 1000; - margin-bottom: 0; - min-height: 40px; - border: 0; - border-bottom: 1px solid #dbdbdb; - position: fixed; - top: 0; - left: 0; - right: 0; - border-radius: 0; -} -.navbar-gitlab .logo-text { - line-height: initial; -} -.navbar-gitlab .logo-text svg { - width: 55px; - height: 14px; - margin: 0; - fill: #fff; -} -.navbar-gitlab .close-icon { - display: none; -} -.navbar-gitlab .header-content { - width: 100%; - display: flex; - justify-content: space-between; - position: relative; - min-height: 40px; - padding-left: 0; -} -.navbar-gitlab .header-content .title-container { - display: flex; - align-items: stretch; - flex: 1 1 auto; - padding-top: 0; - overflow: visible; -} -.navbar-gitlab .header-content .title { - padding-right: 0; - color: currentColor; - display: flex; - position: relative; - margin: 0; - font-size: 18px; - vertical-align: top; - white-space: nowrap; -} -.navbar-gitlab .header-content .title img { - height: 28px; -} -.navbar-gitlab .header-content .title img + .logo-text { - margin-left: 8px; -} -.navbar-gitlab .header-content .title.wrap { - white-space: normal; -} -.navbar-gitlab .header-content .title a { - display: flex; - align-items: center; - padding: 2px 8px; - margin: 5px 2px 5px -8px; - border-radius: 4px; -} -.navbar-gitlab .header-content .dropdown.open > a { - border-bottom-color: #fff; -} -.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { - margin: 0 2px; -} -.navbar-gitlab .navbar-collapse { - flex: 0 0 auto; - border-top: 0; - padding: 0; -} - -@media (max-width: 575.98px) { - .navbar-gitlab .navbar-collapse { - flex: 1 1 auto; - } -} -.navbar-gitlab .navbar-collapse .nav { - flex-wrap: nowrap; -} - -@media (max-width: 575.98px) { - .navbar-gitlab .navbar-collapse .nav > li:not(.d-none) a { - margin-left: 0; - } -} -.navbar-gitlab .container-fluid { - padding: 0; -} -.navbar-gitlab .container-fluid .user-counter svg { - margin-right: 3px; -} -.navbar-gitlab .container-fluid .navbar-toggler { - position: relative; - right: -10px; - border-radius: 0; - min-width: 45px; - padding: 0; - margin: 8px -7px 8px 0; - font-size: 14px; - text-align: center; - color: currentColor; -} - -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .navbar-nav { - display: flex; - padding-right: 10px; - flex-direction: row; - } -} -.navbar-gitlab .container-fluid .navbar-nav li .badge.badge-pill { - box-shadow: none; - font-weight: 600; -} - -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .nav > li.header-user { - padding-left: 10px; - } -} -.navbar-gitlab .container-fluid .nav > li > a { - will-change: color; - margin: 4px 0; - padding: 6px 8px; - height: 32px; -} - -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .nav > li > a { - padding: 0; - } -} -.navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle { - margin-left: 2px; -} -.navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle .header-user-avatar { - margin-right: 0; -} -.navbar-gitlab .container-fluid .nav > li .header-new-dropdown-toggle { - margin-right: 0; -} -.navbar-sub-nav > li > a, -.navbar-sub-nav > li > button, -.navbar-nav > li > a, -.navbar-nav > li > button { - display: flex; - align-items: center; - justify-content: center; - padding: 6px 8px; - margin: 4px 2px; - font-size: 12px; - color: currentColor; - border-radius: 4px; - height: 32px; - font-weight: 600; -} -.navbar-sub-nav > li > button, -.navbar-nav > li > button { - background: transparent; - border: 0; -} -.navbar-sub-nav .dropdown-menu, -.navbar-nav .dropdown-menu { - position: absolute; -} -.navbar-sub-nav { - display: flex; - margin: 0 0 0 6px; -} -.caret-down, -.btn .caret-down { - top: 0; - height: 11px; - width: 11px; - margin-left: 4px; - fill: currentColor; -} -.header-user .dropdown-menu, -.header-new .dropdown-menu { - margin-top: 4px; -} -.btn-sign-in { - background-color: #ebebfa; - color: #292961; - font-weight: 600; - line-height: 18px; - margin: 4px 0 4px 2px; -} -.title-container .badge.badge-pill, -.navbar-nav .badge.badge-pill { - position: inherit; - font-weight: 400; - margin-left: -6px; - font-size: 11px; - color: #fff; - padding: 0 5px; - line-height: 12px; - border-radius: 7px; - box-shadow: 0 1px 0 rgba(76, 78, 84, 0.2); -} -.title-container .badge.badge-pill.green-badge, -.navbar-nav .badge.badge-pill.green-badge { - background-color: #108548; -} -.title-container .badge.badge-pill.merge-requests-count, -.navbar-nav .badge.badge-pill.merge-requests-count { - background-color: #de7e00; -} -.title-container .badge.badge-pill.todos-count, -.navbar-nav .badge.badge-pill.todos-count { - background-color: #1f75cb; -} -.title-container .canary-badge .badge, -.navbar-nav .canary-badge .badge { - font-size: 12px; - line-height: 16px; - padding: 0 0.5rem; -} - -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid { - font-size: 18px; - } - .navbar-gitlab .container-fluid .navbar-nav { - table-layout: fixed; - width: 100%; - margin: 0; - text-align: right; - } - .navbar-gitlab .container-fluid .navbar-collapse { - margin-left: -8px; - margin-right: -10px; - } - .navbar-gitlab .container-fluid .navbar-collapse .nav > li:not(.d-none) { - flex: 1; - } - .header-user-dropdown-toggle { - text-align: center; - } - .header-user-avatar { - float: none; - } -} -.header-user.show .dropdown-menu { - margin-top: 4px; - color: #303030; - left: auto; - max-height: 445px; -} -.header-user.show .dropdown-menu svg { - vertical-align: text-top; -} -.header-user-avatar { - float: left; - margin-right: 5px; - border-radius: 50%; - border: 1px solid #f5f5f5; -} .navbar-empty { justify-content: center; height: 40px; background: #fff; border-bottom: 1px solid #f0f0f0; } - -@media (max-width: 575.98px) { - .nav-links > li > a .badge.badge-pill { - display: none; - } -} - -@media (max-width: 575.98px) { - .nav-links > li > a { - margin-right: 3px; - } -} -.media { - display: flex; - align-items: flex-start; -} -.card { - margin-bottom: 16px; -} -.nav-links:not(.quick-links) { - display: flex; - padding: 0; - margin: 0; - list-style: none; - height: auto; - border-bottom: 1px solid #dbdbdb; -} -.content-wrapper { - width: 100%; -} -.content-wrapper .container-fluid { - padding: 0 16px; -} - -@media (min-width: 768px) { - .page-with-contextual-sidebar { - padding-left: 50px; - } -} - -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - padding-left: 220px; - } -} -.context-header { - position: relative; - margin-right: 2px; - width: 220px; -} -.context-header > a, -.context-header > button { - font-weight: 600; - display: flex; - width: 100%; - align-items: center; - padding: 10px 16px 10px 10px; - color: #303030; - background-color: transparent; - border: 0; - text-align: left; -} -.context-header .avatar-container { - flex: 0 0 40px; - background-color: #fff; -} -.context-header .sidebar-context-title { - overflow: hidden; - text-overflow: ellipsis; -} -.context-header .sidebar-context-title.text-secondary { - font-weight: normal; - font-size: 0.8em; -} -.nav-sidebar { - position: fixed; - z-index: 600; - width: 220px; - top: 40px; - bottom: 0; - left: 0; - background-color: #fafafa; - box-shadow: inset -1px 0 0 #dbdbdb; - transform: translate3d(0, 0, 0); -} - -@media (min-width: 576px) and (max-width: 576px) { - .nav-sidebar:not(.sidebar-collapsed-desktop) { - box-shadow: inset -1px 0 0 #dbdbdb, 2px 1px 3px rgba(0, 0, 0, 0.1); - } -} -.nav-sidebar.sidebar-collapsed-desktop { - width: 50px; -} -.nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll { - overflow-x: hidden; -} -.nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge), -.nav-sidebar.sidebar-collapsed-desktop .sidebar-context-title, -.nav-sidebar.sidebar-collapsed-desktop .nav-item-name { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.nav-sidebar.sidebar-collapsed-desktop .sidebar-top-level-items > li > a { - min-height: 45px; -} -.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item { - display: block; -} -.nav-sidebar.sidebar-collapsed-desktop .avatar-container { - margin: 0 auto; -} -.nav-sidebar.sidebar-expanded-mobile { - left: 0; -} -.nav-sidebar a { - text-decoration: none; -} -.nav-sidebar ul { - padding-left: 0; - list-style: none; -} -.nav-sidebar li { - white-space: nowrap; -} -.nav-sidebar li a { - display: flex; - align-items: center; - padding: 12px 16px; - color: #666; -} -.nav-sidebar li .nav-item-name { - flex: 1; -} -.nav-sidebar li.active > a { - font-weight: 600; -} - -@media (max-width: 767.98px) { - .nav-sidebar { - left: -220px; - } -} -.nav-sidebar .nav-icon-container { - display: flex; - margin-right: 8px; -} -.nav-sidebar .fly-out-top-item { - display: none; -} -.nav-sidebar svg { - height: 16px; - width: 16px; -} - -@media (min-width: 768px) and (max-width: 1199px) { - .nav-sidebar:not(.sidebar-expanded-mobile) { - width: 50px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll { - overflow-x: hidden; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .badge.badge-pill:not(.fly-out-badge), - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title, - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a { - min-height: 45px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item { - display: block; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container { - margin: 0 auto; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { - height: 60px; - width: 50px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { - padding: 10px 4px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li .sidebar-sub-level-items:not(.flyout-list) { - display: none; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container { - margin-right: 0; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button { - padding: 16px; - width: 49px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .collapse-text, - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-left { - display: none; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-right { - display: block; - margin: 0; - } +.navbar-empty .tanuki-logo, +.navbar-empty .brand-header-logo { + max-height: 100%; } -.nav-sidebar-inner-scroll { - height: 100%; - width: 100%; - overflow: auto; -} -.sidebar-sub-level-items { - display: none; - padding-bottom: 8px; +.tanuki-logo .tanuki-left-ear, +.tanuki-logo .tanuki-right-ear, +.tanuki-logo .tanuki-nose { + fill: #e24329; } -.sidebar-sub-level-items > li a { - padding: 8px 16px 8px 40px; +.tanuki-logo .tanuki-left-eye, +.tanuki-logo .tanuki-right-eye { + fill: #fc6d26; } -.sidebar-top-level-items { - margin-bottom: 60px; -} - -@media (min-width: 576px) { - .sidebar-top-level-items > li > a { - margin-right: 1px; - } -} -.sidebar-top-level-items > li .badge.badge-pill { - background-color: rgba(0, 0, 0, 0.08); - color: #666; -} -.sidebar-top-level-items > li.active { - background: rgba(0, 0, 0, 0.04); -} -.sidebar-top-level-items > li.active > a { - margin-left: 4px; - padding-left: 12px; -} -.sidebar-top-level-items > li.active .badge.badge-pill { - font-weight: 600; -} -.sidebar-top-level-items > li.active .sidebar-sub-level-items:not(.is-fly-out-only) { - display: block; -} -.toggle-sidebar-button, -.close-nav-button { - width: 219px; - position: fixed; - height: 48px; - bottom: 0; - padding: 0 16px; - background-color: #fafafa; - border: 0; - border-top: 1px solid #dbdbdb; - color: #666; - display: flex; - align-items: center; -} -.toggle-sidebar-button svg, -.close-nav-button svg { - margin-right: 8px; -} -.toggle-sidebar-button .icon-chevron-double-lg-right, -.close-nav-button .icon-chevron-double-lg-right { - display: none; -} -.collapse-text { - white-space: nowrap; - overflow: hidden; +.tanuki-logo .tanuki-left-cheek, +.tanuki-logo .tanuki-right-cheek { + fill: #fca326; } -.sidebar-collapsed-desktop .context-header { - height: 60px; - width: 50px; -} -.sidebar-collapsed-desktop .context-header a { - padding: 10px 4px; -} -.sidebar-collapsed-desktop .sidebar-top-level-items > li .sidebar-sub-level-items:not(.flyout-list) { - display: none; -} -.sidebar-collapsed-desktop .nav-icon-container { - margin-right: 0; -} -.sidebar-collapsed-desktop .toggle-sidebar-button { - padding: 16px; - width: 49px; -} -.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text, -.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left { - display: none; -} -.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-right { - display: block; - margin: 0; -} -.fly-out-top-item > a { - display: flex; -} -.fly-out-top-item .fly-out-badge { - margin-left: 8px; -} -.fly-out-top-item-name { - flex: 1; -} -.close-nav-button { - display: none; -} - -@media (max-width: 767.98px) { - .close-nav-button { - display: flex; - } - .toggle-sidebar-button { - display: none; - } -} -table.table { - margin-bottom: 16px; -} -table.table .dropdown-menu a { - text-decoration: none; -} -table.table .success, -table.table .info { - color: #fff; -} -table.table .success a:not(.btn), -table.table .info a:not(.btn) { - text-decoration: underline; - color: #fff; -} -pre { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - display: block; - padding: 8px 12px; - margin: 0 0 8px; - font-size: 13px; - word-break: break-all; - word-wrap: break-word; - color: #303030; - background-color: #fafafa; - border: 1px solid #dbdbdb; - border-radius: 2px; -} -.monospace { - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; -} -input::-moz-placeholder, -textarea::-moz-placeholder { +input::-moz-placeholder { color: #868686; opacity: 1; } -input::-ms-input-placeholder, -textarea::-ms-input-placeholder { +input::-ms-input-placeholder { color: #868686; } -input:-ms-input-placeholder, -textarea:-ms-input-placeholder { +input:-ms-input-placeholder { color: #868686; } svg { fill: currentColor; } - -svg.s12 { - width: 12px; - height: 12px; -} - -svg.s16 { - width: 16px; - height: 16px; -} - -svg.s18 { - width: 18px; - height: 18px; -} - -svg.s12 { - vertical-align: -1px; -} - -svg.s16 { - vertical-align: -3px; -} -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} -table.code { - width: 100%; - font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace; - border: 0; - border-collapse: separate; - margin: 0; - padding: 0; - table-layout: fixed; - border-radius: 0 0 4px 4px; -} -.frame .badge.badge-pill { - position: absolute; - background-color: #428fdc; - color: #fff; - border: #fff 1px solid; - min-height: 16px; - padding: 5px 8px; - border-radius: 12px; -} -.frame .badge.badge-pill { - transform: translate(-50%, -50%); -} -.color-label { - padding: 0 0.5rem; - line-height: 16px; - border-radius: 100px; - color: #fff; -} -.label-link { - display: inline-flex; - vertical-align: text-bottom; -} -.label-link .label { - vertical-align: inherit; - font-size: 12px; -} .login-page .container { max-width: 960px; } @@ -2096,6 +610,25 @@ table.code { border-radius: 0.25rem; padding: 15px; } +.login-page .login-box .login-heading h3, +.login-page .omniauth-container .login-heading h3 { + font-weight: 400; + line-height: 1.5; + margin: 0 0 10px; +} +.login-page .login-box .login-footer, +.login-page .omniauth-container .login-footer { + margin-top: 10px; +} +.login-page .login-box .login-footer p:last-child, +.login-page .omniauth-container .login-footer p:last-child { + margin-bottom: 0; +} +.login-page .login-box a.forgot, +.login-page .omniauth-container a.forgot { + float: right; + padding-top: 6px; +} .login-page .login-box .nav .active a, .login-page .omniauth-container .nav .active a { background: transparent; @@ -2132,7 +665,6 @@ table.code { background: none; margin-bottom: 16px; } - @media (max-width: 991.98px) { .login-page .omniauth-container form { width: 100%; @@ -2140,7 +672,6 @@ table.code { } .login-page .omniauth-container .omniauth-btn { width: 100%; - padding: 8px; } .login-page .new-session-tabs { display: flex; @@ -2148,6 +679,19 @@ table.code { border-top-right-radius: 4px; border-top-left-radius: 4px; } +.login-page .new-session-tabs.custom-provider-tabs { + flex-wrap: wrap; +} +.login-page .new-session-tabs.custom-provider-tabs li { + min-width: 85px; + flex-basis: auto; +} +.login-page .new-session-tabs.custom-provider-tabs li:nth-child(n + 5) { + border-top: 1px solid #dbdbdb; +} +.login-page .new-session-tabs.custom-provider-tabs a { + font-size: 16px; +} .login-page .new-session-tabs li { flex: 1; text-align: center; @@ -2170,17 +714,29 @@ table.code { .login-page .new-session-tabs li.active > a { cursor: default; } +.login-page .form-control:active, +.login-page .form-control:focus { + background-color: #fff; +} .login-page .submit-container { margin-top: 16px; } -.login-page input[type='submit'] { +.login-page input[type="submit"] { margin-bottom: 0; + display: block; + width: 100%; } .login-page .devise-errors h2 { margin-top: 0; font-size: 14px; color: #ae1800; } +@media (max-width: 575.98px) { + .login-page .col-md-5.float-right { + float: none !important; + margin-bottom: 45px; + } +} .devise-layout-html { margin: 0; padding: 0; @@ -2213,191 +769,57 @@ table.code { .devise-layout-html body .navless-container { padding: 65px 15px; } - @media (max-width: 575.98px) { .devise-layout-html body .navless-container { padding: 0 15px 65px; } } -.milestones { - padding: 8px; - margin-top: 8px; - border-radius: 4px; - background-color: #dbdbdb; -} -.search { - margin: 0 8px; -} -.search form { - margin: 0; - padding: 4px; - width: 200px; - line-height: 24px; - height: 32px; - border: 0; - border-radius: 4px; -} - -@media (min-width: 1200px) { - .search form { - width: 320px; - } -} -.search .search-input { - border: 0; - font-size: 14px; - padding: 0 20px 0 0; - margin-left: 5px; - line-height: 25px; - width: 98%; - color: #fff; - background: none; -} -.search .search-input-container { - display: flex; - position: relative; -} -.search .search-input-wrap { - width: 100%; -} -.search .search-input-wrap .search-icon, -.search .search-input-wrap .clear-icon { - position: absolute; - right: 5px; - top: 4px; -} -.search .search-input-wrap .search-icon { - -moz-user-select: none; - user-select: none; -} -.search .search-input-wrap .clear-icon { - display: none; -} -.search .search-input-wrap .dropdown { - position: static; -} -.search .search-input-wrap .dropdown-menu { - left: -5px; - max-height: 400px; - overflow: auto; -} -@media (min-width: 1200px) { - .search .search-input-wrap .dropdown-menu { - width: 320px; - } +.gl-border-solid { + border-style: solid; } -.search .search-input-wrap .dropdown-content { - max-height: 382px; -} -.settings { - border-top: 1px solid #dbdbdb; -} -.settings:first-of-type { - margin-top: 10px; - border: 0; -} -.settings + div .settings:first-of-type { - margin-top: 0; - border-top: 1px solid #dbdbdb; -} -.avatar, .avatar-container { - float: left; - margin-right: 16px; - border-radius: 50%; - border: 1px solid #f5f5f5; -} -.s16.avatar, .s16.avatar-container { - width: 16px; - height: 16px; - margin-right: 8px; -} -.s18.avatar, .s18.avatar-container { - width: 18px; - height: 18px; - margin-right: 8px; -} -.s40.avatar, .s40.avatar-container { - width: 40px; - height: 40px; - margin-right: 8px; +.gl-border-gray-100 { + border-color: #dbdbdb; } -.avatar { - transition-property: none; - width: 40px; - height: 40px; - padding: 0; - background: #fdfdfd; - overflow: hidden; - border-color: rgba(0, 0, 0, 0.1); +.gl-border-1 { + border-width: 1px; } -.avatar.center { - font-size: 14px; - line-height: 1.8em; - text-align: center; +.gl-rounded-base { + border-radius: 0.25rem; } -.avatar.avatar-tile { - border-radius: 0; - border: 0; +.gl-text-green-600 { + color: #217645; } -.avatar-container { - overflow: hidden; - display: flex; +.gl-text-red-500 { + color: #dd2b0e; } -.avatar-container a { - width: 100%; - height: 100%; +.gl-display-flex { display: flex; - text-decoration: none; -} -.avatar-container .avatar { - border-radius: 0; - border: 0; - height: auto; - width: 100%; - margin: 0; - align-self: center; -} -.avatar-container.s40 { - min-width: 40px; - min-height: 40px; -} -.rect-avatar { - border-radius: 2px; } -.rect-avatar.s16 { - border-radius: 2px; +.gl-align-items-center { + align-items: center; } -.rect-avatar.s18 { - border-radius: 2px; +.gl-p-2 { + padding: 0.25rem; } -.rect-avatar.s40 { - border-radius: 4px; +.gl-p-4 { + padding: 0.75rem; } -.tab-width-8 { - -moz-tab-size: 8; - tab-size: 8; +.gl-mt-2 { + margin-top: 0.25rem; } -.gl-sr-only { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; +.gl-mb-2 { + margin-bottom: 0.25rem; } -.gl-mt-5 { - margin-top: 1rem; +.gl-mb-3 { + margin-bottom: 0.5rem; } -.gl-ml-3 { - margin-left: 0.5rem; +.gl-mb-5 { + margin-bottom: 1rem; } -.content-wrapper > .alert-wrapper, -#content-body, .modal-dialog { - display: block; +.gl-text-left { + text-align: left; } -@import 'cloaking'; + +@import "startup/cloaking"; @include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 5b3e2ab4cd0..6a60978b954 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -22,10 +22,7 @@ .container-fluid { .navbar-toggler { border-left: 1px solid lighten($border-and-box-shadow, 10%); - - svg { - fill: $search-and-nav-links; - } + color: $search-and-nav-links; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index c22a1ae1187..cabbe5834cb 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -77,13 +77,6 @@ // https://gitlab.com/groups/gitlab-org/-/epics/2882 .gl-h-200\! { height: px-to-rem($grid-size * 25) !important; } -.d-sm-table-column { - @include media-breakpoint-up(sm) { - display: table-column !important; - } -} - -.gl-text-purple { color: $purple; } .gl-bg-purple-light { background-color: $purple-light; } // move this to GitLab UI once onboarding experiment is considered a success diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 5e613c47fc5..9d1c68eea89 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -19,7 +19,7 @@ class AbuseReportsController < ApplicationController @abuse_report.notify message = _("Thank you for your report. A GitLab administrator will look into it shortly.") - redirect_to @abuse_report.user, notice: message + redirect_to root_path, notice: message else render :new end diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/application_settings/appearances_controller.rb index c2614a158b7..47b2356a60f 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/application_settings/appearances_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Admin::AppearancesController < Admin::ApplicationController +class Admin::ApplicationSettings::AppearancesController < Admin::ApplicationController before_action :set_appearance, except: :create feature_category :navigation @@ -16,7 +16,7 @@ class Admin::AppearancesController < Admin::ApplicationController @appearance = Appearance.new(appearance_params) if @appearance.save - redirect_to admin_appearances_path, notice: _('Appearance was successfully created.') + redirect_to admin_application_settings_appearances_path, notice: _('Appearance was successfully created.') else render action: 'show' end @@ -24,7 +24,7 @@ class Admin::AppearancesController < Admin::ApplicationController def update if @appearance.update(appearance_params) - redirect_to admin_appearances_path, notice: _('Appearance was successfully updated.') + redirect_to admin_application_settings_appearances_path, notice: _('Appearance was successfully updated.') else render action: 'show' end @@ -35,21 +35,21 @@ class Admin::AppearancesController < Admin::ApplicationController @appearance.save - redirect_to admin_appearances_path, notice: _('Logo was successfully removed.') + redirect_to admin_application_settings_appearances_path, notice: _('Logo was successfully removed.') end def header_logos @appearance.remove_header_logo! @appearance.save - redirect_to admin_appearances_path, notice: _('Header logo was successfully removed.') + redirect_to admin_application_settings_appearances_path, notice: _('Header logo was successfully removed.') end def favicon @appearance.remove_favicon! @appearance.save - redirect_to admin_appearances_path, notice: _('Favicon was successfully removed.') + redirect_to admin_application_settings_appearances_path, notice: _('Favicon was successfully removed.') end private diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 80cb04ac496..5ddeb9630ba 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -253,7 +253,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def perform_update - successful = ApplicationSettings::UpdateService + successful = ::ApplicationSettings::UpdateService .new(@application_setting, current_user, application_setting_params) .execute diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb new file mode 100644 index 00000000000..c1dffbf423d --- /dev/null +++ b/app/controllers/admin/background_migrations_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Admin::BackgroundMigrationsController < Admin::ApplicationController + feature_category :database + + def index + @relations_by_tab = { + 'queued' => batched_migration_class.queued.queue_order, + 'failed' => batched_migration_class.failed.queue_order, + 'finished' => batched_migration_class.finished.queue_order.reverse_order + } + + @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' + @migrations = @relations_by_tab[@current_tab].page(params[:page]) + @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) + end + + private + + def batched_migration_class + Gitlab::Database::BackgroundMigration::BatchedMigration + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 46e5a508a1b..ba24e3e619b 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -15,7 +15,7 @@ class Admin::DashboardController < Admin::ApplicationController @groups = Group.order_id_desc.with_route.limit(10) @notices = Gitlab::ConfigChecker::PumaRuggedChecker.check @notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check - @redis_versions = [Gitlab::Redis::Queues, Gitlab::Redis::SharedState, Gitlab::Redis::Cache].map(&:version).uniq + @redis_versions = [Gitlab::Redis::Queues, Gitlab::Redis::SharedState, Gitlab::Redis::Cache, Gitlab::Redis::TraceChunks].map(&:version).uniq end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 5b33ee78e8c..9abb244bc92 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -62,7 +62,7 @@ class Admin::GroupsController < Admin::ApplicationController def members_update member_params = params.permit(:user_ids, :access_level, :expires_at) - result = Members::CreateService.new(current_user, member_params.merge(limit: -1, source: @group)).execute + result = Members::CreateService.new(current_user, member_params.merge(limit: -1, source: @group, invite_source: 'admin-group-page')).execute if result[:status] == :success redirect_to [:admin, @group], notice: _('Users were successfully added.') diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 40ec68c1d46..bf9cfa3acff 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController include RunnerSetupScripts before_action :runner, except: [:index, :tag_list, :runner_setup_scripts] + before_action only: [:index] do + push_frontend_feature_flag(:runner_list_view_vue_ui, current_user, default_enabled: :yaml) + end feature_category :continuous_integration diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 2e9229db56c..e397ecbadaf 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -209,6 +209,9 @@ class Admin::UsersController < Admin::ApplicationController user_params_with_pass.merge!(password_params) end + cc_validation_params = process_credit_card_validation_params(user_params_with_pass.delete(:credit_card_validation_attributes)) + user_params_with_pass.merge!(cc_validation_params) + respond_to do |format| result = Users::UpdateService.new(current_user, user_params_with_pass.merge(user: user)).execute do |user| user.skip_reconfirmation! @@ -253,6 +256,27 @@ class Admin::UsersController < Admin::ApplicationController protected + def process_credit_card_validation_params(cc_validation_params) + return unless cc_validation_params && cc_validation_params[:credit_card_validated_at] + + cc_validation = cc_validation_params[:credit_card_validated_at] + + if cc_validation == "1" && !user.credit_card_validated_at + { + credit_card_validation_attributes: { + credit_card_validated_at: Time.zone.now + } + } + + elsif cc_validation == "0" && user.credit_card_validated_at + { + credit_card_validation_attributes: { + _destroy: true + } + } + end + end + def paginate_without_count? counts = Gitlab::Database::Count.approximate_counts([User]) @@ -330,7 +354,8 @@ class Admin::UsersController < Admin::ApplicationController :twitter, :username, :website_url, - :note + :note, + credit_card_validation_attributes: [:credit_card_validated_at] ] end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 00b9fb1060d..07ecde1181f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -263,7 +263,6 @@ class ApplicationController < ActionController::Base headers['X-XSS-Protection'] = '1; mode=block' headers['X-UA-Compatible'] = 'IE=edge' headers['X-Content-Type-Options'] = 'nosniff' - headers[Gitlab::Metrics::RequestsRackMiddleware::FEATURE_CATEGORY_HEADER] = feature_category end def default_cache_headers @@ -456,19 +455,17 @@ class ApplicationController < ActionController::Base end def set_current_context(&block) - Gitlab::ApplicationContext.with_context( - # Avoid loading the auth_user again after the request. Otherwise calling - # `auth_user` again would also trigger the Warden callbacks again - user: -> { auth_user if strong_memoized?(:auth_user) }, + Gitlab::ApplicationContext.push( + user: -> { context_user }, project: -> { @project if @project&.persisted? }, namespace: -> { @group if @group&.persisted? }, caller_id: caller_id, remote_ip: request.ip, - feature_category: feature_category) do - yield - ensure - @current_context = Gitlab::ApplicationContext.current - end + feature_category: feature_category + ) + yield + ensure + @current_context = Gitlab::ApplicationContext.current end def set_locale(&block) @@ -542,6 +539,12 @@ class ApplicationController < ActionController::Base end end + # Avoid loading the auth_user again after the request. Otherwise calling + # `auth_user` again would also trigger the Warden callbacks again + def context_user + auth_user if strong_memoized?(:auth_user) + end + def caller_id "#{self.class.name}##{action_name}" end diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index c533fe007d7..91003e9580d 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController end def cluster_application_params - params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol, :waf_log_enabled, :cilium_log_enabled) + params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :host, :port, :protocol) end def cluster_application_destroy_params diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 7bfcda67aa2..0fb77e2aaf4 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -18,7 +18,7 @@ module CreatesCommit @start_branch ||= @ref || @branch_name - start_project = Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml) ? @project_to_commit_into : @project + start_project = @project_to_commit_into commit_params = @commit_params.merge( start_project: start_project, diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index 09087257888..f1fa5c845e2 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -48,12 +48,11 @@ module IntegrationsActions private - # rubocop: disable Gitlab/ModuleWithInstanceVariables + # rubocop:disable Gitlab/ModuleWithInstanceVariables def integration @integration ||= find_or_initialize_non_project_specific_integration(params[:id]) - @service ||= @integration # TODO: remove references to @service https://gitlab.com/gitlab-org/gitlab/-/issues/329759 end - # rubocop: enable Gitlab/ModuleWithInstanceVariables + # rubocop:enable Gitlab/ModuleWithInstanceVariables def success_message if integration.active? diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 20861afbb88..8fd4e98d557 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -6,7 +6,7 @@ module MembershipActions def create create_params = params.permit(:user_ids, :access_level, :expires_at) - result = Members::CreateService.new(current_user, create_params.merge({ source: membershipable })).execute + result = Members::CreateService.new(current_user, create_params.merge({ source: membershipable, invite_source: "#{plain_source_type}-members-page" })).execute if result[:status] == :success redirect_to members_page_url, notice: _('Users were successfully added.') @@ -108,7 +108,7 @@ module MembershipActions respond_to do |format| format.html do - redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize] + redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize.to_sym] redirect_to redirect_path, notice: notice end @@ -140,39 +140,19 @@ module MembershipActions end def root_params_key - case membershipable - when Namespace - :group_member - when Project - :project_member - else - raise "Unknown membershipable type: #{membershipable}!" - end + raise NotImplementedError end def members_page_url - case membershipable - when Namespace - polymorphic_url([membershipable, :members]) - when Project - project_project_members_path(membershipable) - else - raise "Unknown membershipable type: #{membershipable}!" - end + raise NotImplementedError end def source_type - @source_type ||= - begin - case membershipable - when Namespace - _("group") - when Project - _("project") - else - raise "Unknown membershipable type: #{membershipable}!" - end - end + raise NotImplementedError + end + + def plain_source_type + raise NotImplementedError end def requested_relations diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index fc4f9aa3409..b7f6691ef4b 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -177,7 +177,7 @@ module WikiActions redirect_to wiki_path(wiki), status: :found else - @error = response + @error = response.message render 'shared/wikis/edit' end end diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb index dc2265e063a..adfad4de4dd 100644 --- a/app/controllers/concerns/with_performance_bar.rb +++ b/app/controllers/concerns/with_performance_bar.rb @@ -20,12 +20,11 @@ module WithPerformanceBar end def cookie_or_default_value - cookie_enabled = if cookies[:perf_bar_enabled].present? - cookies[:perf_bar_enabled] == 'true' - else - cookies[:perf_bar_enabled] = 'true' if Rails.env.development? - end + if cookies[:perf_bar_enabled].blank? && Rails.env.development? + cookies[:perf_bar_enabled] = 'true' + end + cookie_enabled = cookies[:perf_bar_enabled] == 'true' cookie_enabled && Gitlab::PerformanceBar.allowed_for_user?(current_user) end end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index e82500912fa..560369a8de4 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -13,7 +13,7 @@ class ConfirmationsController < Devise::ConfirmationsController protected def after_resending_confirmation_instructions_path_for(resource) - return users_almost_there_path unless Feature.enabled?(:soft_email_confirmation) + return users_almost_there_path(email: resource.email) unless Feature.enabled?(:soft_email_confirmation) stored_location_for(resource) || dashboard_projects_path end @@ -34,6 +34,10 @@ class ConfirmationsController < Devise::ConfirmationsController def after_sign_in(resource) after_sign_in_path_for(resource) end + + def context_user + resource + end end ConfirmationsController.prepend_mod_with('ConfirmationsController') diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 7cb39625371..01bb930a51b 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -36,7 +36,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def starred @projects = load_projects(params.merge(starred: true)) - .includes(:forked_from_project, :tags) + .includes(:forked_from_project, :topics) @groups = [] diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index e1f09d73739..3d8cdd766bf 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -8,7 +8,9 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false) + push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml) push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml) + push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) end feature_category :boards diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb index c1e3ce519cc..d4c7b31c4b8 100644 --- a/app/controllers/groups/email_campaigns_controller.rb +++ b/app/controllers/groups/email_campaigns_controller.rb @@ -16,7 +16,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController def track_click if Gitlab.com? - message = Gitlab::Email::Message::InProductMarketing.for(@track).new(group: group, series: @series) + message = Gitlab::Email::Message::InProductMarketing.for(@track).new(group: group, user: current_user, series: @series) data = { namespace_id: group.id, @@ -26,7 +26,7 @@ class Groups::EmailCampaignsController < Groups::ApplicationController } context = SnowplowTracker::SelfDescribingJson.new(EMAIL_CAMPAIGNS_SCHEMA_URL, data) - ::Gitlab::Tracking.event(self.class.name, 'click', context: [context]) + ::Gitlab::Tracking.event(self.class.name, 'click', context: [context], user: current_user, namespace: group) else ::Users::InProductMarketingEmail.save_cta_click(current_user, @track, @series) end @@ -58,8 +58,9 @@ class Groups::EmailCampaignsController < Groups::ApplicationController @series = params[:series]&.to_i track_valid = @track.in?(Namespaces::InProductMarketingEmailsService::TRACKS.keys) - series_valid = @series.in?(0..Namespaces::InProductMarketingEmailsService::INTERVAL_DAYS.size - 1) + return render_404 unless track_valid - render_404 unless track_valid && series_valid + series_valid = @series.in?(0..Namespaces::InProductMarketingEmailsService::TRACKS[@track][:interval_days].size - 1) + render_404 unless series_valid end end diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index 3b775af9722..0655d779a4e 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -11,8 +11,8 @@ class Groups::GroupLinksController < Groups::ApplicationController if shared_with_group result = Groups::GroupLinks::CreateService - .new(shared_with_group, current_user, group_link_create_params) - .execute(group) + .new(group, shared_with_group, current_user, group_link_create_params) + .execute return render_404 if result[:http_status] == 404 diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index c2ac56ccc63..8d9059d271f 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -25,7 +25,6 @@ class Groups::GroupMembersController < Groups::ApplicationController helper_method :can_manage_members? def index - preload_max_access @sort = params[:sort].presence || sort_value_name @members = GroupMembersFinder @@ -54,14 +53,6 @@ class Groups::GroupMembersController < Groups::ApplicationController private - def preload_max_access - return unless current_user - - # this allows the can? against admin type queries in this action to - # only perform the query once, even if it is cached - current_user.max_access_for_group[@group.id] = @group.max_member_access(current_user) - end - def can_manage_members? strong_memoize(:can_manage_members) do can?(current_user, :admin_group_member, @group) @@ -87,6 +78,22 @@ class Groups::GroupMembersController < Groups::ApplicationController def membershipable_members group.members end + + def plain_source_type + 'group' + end + + def source_type + _("group") + end + + def members_page_url + polymorphic_url([group, :members]) + end + + def root_params_key + :group_member + end end Groups::GroupMembersController.prepend_mod_with('Groups::GroupMembersController') diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index d914e0bffc6..3aaaf6ade6b 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -17,7 +17,7 @@ module Groups .execute .with_api_entity_associations - track_package_event(:list_repositories, :container) + track_package_event(:list_repositories, :container, user: current_user, namespace: group) serializer = ContainerRepositoriesSerializer .new(current_user: current_user) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index a755d242d4a..66816d4c587 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -33,6 +33,7 @@ class GroupsController < Groups::ApplicationController before_action do push_frontend_feature_flag(:vue_issuables_list, @group) + push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml) end before_action :export_rate_limit, only: [:export, :download_export] @@ -182,7 +183,12 @@ class GroupsController < Groups::ApplicationController def download_export if @group.export_file_exists? - send_upload(@group.export_file, attachment: @group.export_file.filename) + if @group.export_archive_exists? + send_upload(@group.export_file, attachment: @group.export_file.filename) + else + redirect_to edit_group_path(@group), + alert: _('The file containing the export is not available yet; it may still be transferring. Please try again later.') + end else redirect_to edit_group_path(@group), alert: _('Group export link has expired. Please generate a new export from your group settings.') @@ -264,7 +270,8 @@ class GroupsController < Groups::ApplicationController :default_branch_protection, :default_branch_name, :allow_mfa_for_subgroups, - :resource_access_token_creation_allowed + :resource_access_token_creation_allowed, + :prevent_sharing_groups_outside_hierarchy ] end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index c6a02250896..99b0b775217 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -15,6 +15,7 @@ class HealthController < ActionController::Base Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::QueuesCheck, Gitlab::HealthChecks::Redis::SharedStateCheck, + Gitlab::HealthChecks::Redis::TraceChunksCheck, Gitlab::HealthChecks::GitalyCheck ].freeze diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index 48635c933af..9d6c0a003c4 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -10,7 +10,7 @@ class Import::BulkImportsController < ApplicationController POLLING_INTERVAL = 3_000 - rescue_from BulkImports::Clients::Http::ConnectionError, with: :bulk_import_connection_error + rescue_from BulkImports::Clients::HTTP::ConnectionError, with: :bulk_import_connection_error def configure session[access_token_key] = configure_params[access_token_key]&.strip @@ -78,7 +78,7 @@ class Import::BulkImportsController < ApplicationController def query_params query_params = { top_level_only: true, - min_access_level: Gitlab::Access::MAINTAINER + min_access_level: Gitlab::Access::OWNER } query_params[:search] = sanitized_filter_param if sanitized_filter_param @@ -86,7 +86,7 @@ class Import::BulkImportsController < ApplicationController end def client - @client ||= BulkImports::Clients::Http.new( + @client ||= BulkImports::Clients::HTTP.new( uri: session[url_key], token: session[access_token_key], per_page: params[:per_page], diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb index fee8b43aa6b..0de42ad2452 100644 --- a/app/controllers/jira_connect/app_descriptor_controller.rb +++ b/app/controllers/jira_connect/app_descriptor_controller.rb @@ -40,7 +40,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController private HOME_URL = 'https://gitlab.com' - DOC_URL = 'https://docs.gitlab.com/ee/user/project/integrations/jira.html#gitlab-jira-integration' + DOC_URL = 'https://docs.gitlab.com/ee/integration/jira/' def modules modules = { diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 31f404a9974..9d7a1712698 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -287,6 +287,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def fail_admin_mode_invalid_credentials redirect_to new_admin_session_path, alert: _('Invalid login or password') end + + def context_user + current_user + end end OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController') diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 2c0ed825daa..c764f2d0459 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -67,6 +67,10 @@ class PasswordsController < Devise::PasswordsController redirect_to new_user_session_path, notice: I18n.t('devise.passwords.send_paranoid_instructions') end + + def context_user + resource + end end PasswordsController.prepend_mod_with('PasswordsController') diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index afebeafff7c..505608779ec 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -67,7 +67,7 @@ class ProfilesController < Profiles::ApplicationController .order("created_at DESC") .page(params[:page]) - Gitlab::Tracking.event(self.class.name, 'search_audit_event') + Gitlab::Tracking.event(self.class.name, 'search_audit_event', user: current_user) end # rubocop: enable CodeReuse/ActiveRecord @@ -127,6 +127,7 @@ class ProfilesController < Profiles::ApplicationController :include_private_contributions, :timezone, :job_title, + :pronouns, status: [:emoji, :message, :availability] ) end diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb index ebe867d915d..34f9f361e43 100644 --- a/app/controllers/projects/alert_management_controller.rb +++ b/app/controllers/projects/alert_management_controller.rb @@ -3,6 +3,10 @@ class Projects::AlertManagementController < Projects::ApplicationController before_action :authorize_read_alert_management_alert! + before_action(only: [:index]) do + push_frontend_feature_flag(:managed_alerts_deprecation, @project, default_enabled: :yaml) + end + feature_category :incident_management def index diff --git a/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb b/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb new file mode 100644 index 00000000000..c51a5ac7b88 --- /dev/null +++ b/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Projects::Analytics::CycleAnalytics::SummaryController < Projects::ApplicationController + include CycleAnalyticsParams + + respond_to :json + + feature_category :planning_analytics + + before_action :authorize_read_cycle_analytics! + + def show + render json: project_level.summary + end + + private + + def project_level + @project_level ||= Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(allowed_params)) + end + + def allowed_params + params.permit(:created_after, :created_before) + end +end + +Projects::Analytics::CycleAnalytics::SummaryController.prepend_mod_with('Projects::Analytics::CycleAnalytics::SummaryController') diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index dbe628cb43a..c6c9237292d 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -39,6 +39,7 @@ class Projects::BlobController < Projects::ApplicationController before_action do push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) end def new @@ -92,7 +93,7 @@ class Projects::BlobController < Projects::ApplicationController @blob.load_all_data! diffy = Diffy::Diff.new(@blob.data, @content, diff: '-U 3', include_diff_info: true) diff_lines = diffy.diff.scan(/.*\n/)[2..-1] - diff_lines = Gitlab::Diff::Parser.new.parse(diff_lines) + diff_lines = Gitlab::Diff::Parser.new.parse(diff_lines).to_a @diff_lines = Gitlab::Diff::Highlight.new(diff_lines, repository: @repository).highlight render layout: false diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 9a3e9437426..43c9046f850 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -9,6 +9,8 @@ class Projects::BoardsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml) push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml) + push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) + push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) end feature_category :boards diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 5006aa75ce5..3be10559e80 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -152,7 +152,7 @@ class Projects::BranchesController < Projects::ApplicationController ref_escaped = strip_tags(sanitize(params[:ref])) Addressable::URI.unescape(ref_escaped) else - @project.default_branch || 'master' + @project.default_branch_or_main end end @@ -185,18 +185,12 @@ class Projects::BranchesController < Projects::ApplicationController # Here we get one more branch to indicate if there are more data we're not showing limit = @overview_max_branches + 1 - if Feature.enabled?(:branch_list_keyset_pagination, project, default_enabled: :yaml) - @active_branches = - BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated }) - .execute(gitaly_pagination: true).select(&:active?) - @stale_branches = - BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated }) - .execute(gitaly_pagination: true).select(&:stale?) - else - @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?) - @active_branches = @active_branches.first(limit) - @stale_branches = @stale_branches.first(limit) - end + @active_branches = + BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated }) + .execute(gitaly_pagination: true).select(&:active?) + @stale_branches = + BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated }) + .execute(gitaly_pagination: true).select(&:stale?) @branches = @active_branches + @stale_branches end diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 6e31816bc99..ed1d5ca9594 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -6,6 +6,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_editor_branch_switcher, @project, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml) + push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml) end feature_category :pipeline_authoring diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 863715429ff..3d2398f7ee3 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -19,9 +19,6 @@ class Projects::CommitController < Projects::ApplicationController before_action :define_commit_box_vars, only: [:show, :pipelines] before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] - before_action do - push_frontend_feature_flag(:pick_into_project, @project, default_enabled: :yaml) - end BRANCH_SEARCH_LIMIT = 1000 COMMIT_DIFFS_PER_PAGE = 20 @@ -220,7 +217,6 @@ class Projects::CommitController < Projects::ApplicationController def find_cherry_pick_target_project return @project if params[:target_project_id].blank? - return @project unless Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml) MergeRequestTargetProjectFinder .new(current_user: current_user, source_project: @project, project_feature: :repository) diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 28a87f83451..edf45e7063a 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -79,11 +79,11 @@ class Projects::CompareController < Projects::ApplicationController private def validate_refs! - valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) } + invalid = [head_ref, start_ref].filter { |ref| !valid_ref?(ref) } - return if valid.all? + return if invalid.empty? - flash[:alert] = "Invalid branch name" + flash[:alert] = "Invalid branch name(s): #{invalid.join(', ')}" redirect_to project_compare_index_path(source_project) end diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb index 3a5dd23047c..a1da8d4e91f 100644 --- a/app/controllers/projects/cycle_analytics/events_controller.rb +++ b/app/controllers/projects/cycle_analytics/events_controller.rb @@ -53,7 +53,7 @@ module Projects end def cycle_analytics - @cycle_analytics ||= ::CycleAnalytics::ProjectLevel.new(project, options: options(cycle_analytics_project_params)) + @cycle_analytics ||= ::Analytics::CycleAnalytics::ProjectLevel.new(project: project, options: options(cycle_analytics_project_params)) end end end diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 5c15a5d246c..d1d27286c68 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -14,7 +14,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController feature_category :planning_analytics def show - @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params)) + @cycle_analytics = Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(cycle_analytics_project_params)) respond_to do |format| format.html do diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb index 6b6606c4f41..a59824b1085 100644 --- a/app/controllers/projects/feature_flags_controller.rb +++ b/app/controllers/projects/feature_flags_controller.rb @@ -11,6 +11,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController before_action :feature_flag, only: [:edit, :update, :destroy] before_action :ensure_flag_writable!, only: [:update] + before_action :exclude_legacy_flags_check, only: [:edit] before_action do push_frontend_feature_flag(:feature_flag_permissions) @@ -107,6 +108,12 @@ class Projects::FeatureFlagsController < Projects::ApplicationController end end + def exclude_legacy_flags_check + if feature_flag.legacy_flag? + not_found + end + end + def create_params params.require(:operations_feature_flag) .permit(:name, :description, :active, :version, diff --git a/app/controllers/projects/feature_flags_user_lists_controller.rb b/app/controllers/projects/feature_flags_user_lists_controller.rb index 7be3254e966..fd81321924a 100644 --- a/app/controllers/projects/feature_flags_user_lists_controller.rb +++ b/app/controllers/projects/feature_flags_user_lists_controller.rb @@ -6,6 +6,9 @@ class Projects::FeatureFlagsUserListsController < Projects::ApplicationControlle feature_category :feature_flags + def index + end + def new end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 8fa3635a737..9e42d218ceb 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -56,7 +56,13 @@ class Projects::ForksController < Projects::ApplicationController can_fork_to?(current_user.namespace) render json: { - namespaces: ForkNamespaceSerializer.new.represent(namespaces, project: project, current_user: current_user, memberships: memberships_hash) + namespaces: ForkNamespaceSerializer.new.represent( + namespaces, + project: project, + current_user: current_user, + memberships: memberships_hash, + forked_projects: forked_projects_by_namespace(namespaces) + ) } end end @@ -129,6 +135,10 @@ class Projects::ForksController < Projects::ApplicationController def memberships_hash current_user.members.where(source: load_namespaces_with_associations).index_by(&:source_id) end + + def forked_projects_by_namespace(namespaces) + project.forks.where(namespace: namespaces).includes(:namespace).index_by(&:namespace_id) + end end Projects::ForksController.prepend_mod_with('Projects::ForksController') diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 01a6de76ba5..295213bd38c 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -46,6 +46,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) push_frontend_feature_flag(:vue_issues_list, project) + push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) end before_action only: :show do @@ -55,6 +56,7 @@ class Projects::IssuesController < Projects::ApplicationController push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled) push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) + push_frontend_feature_flag(:labels_widget, @project, default_enabled: :yaml) experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance| experiment_instance.exclude! unless helpers.can_import_members? @@ -160,7 +162,7 @@ class Projects::IssuesController < Projects::ApplicationController new_project = Project.find(params[:move_to_project_id]) return render_404 unless issue.can_move?(current_user, new_project) - @issue = ::Issues::UpdateService.new(project: project, current_user: current_user, params: { target_project: new_project }).execute(issue) + @issue = ::Issues::MoveService.new(project: project, current_user: current_user).execute(issue, new_project) end respond_to do |format| diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index 011ac9a42f8..a8038878504 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -9,6 +9,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap respond_to do |format| format.html do @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_loading_conflict_ui_action(user: current_user) end format.json do @@ -42,6 +43,8 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap def resolve_conflicts return render_404 unless @conflicts_list.can_be_resolved_in_ui? + Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_resolve_conflict_action(user: current_user) + if @merge_request.can_be_merged? render status: :bad_request, json: { message: _('The merge conflicts for this merge request have already been resolved.') } return diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 613faa200d1..5958c7f66e5 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml) + push_frontend_feature_flag(:codequality_mr_diff_annotations, project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml) diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb index 3f10749602e..89c99a5fd5a 100644 --- a/app/controllers/projects/metrics_dashboard_controller.rb +++ b/app/controllers/projects/metrics_dashboard_controller.rb @@ -12,6 +12,7 @@ module Projects before_action do push_frontend_feature_flag(:prometheus_computed_alerts) push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) + push_frontend_feature_flag(:managed_alerts_deprecation, @project, default_enabled: :yaml) end feature_category :metrics diff --git a/app/controllers/projects/packages/infrastructure_registry_controller.rb b/app/controllers/projects/packages/infrastructure_registry_controller.rb index 22ae1d65013..ee04cbb0062 100644 --- a/app/controllers/projects/packages/infrastructure_registry_controller.rb +++ b/app/controllers/projects/packages/infrastructure_registry_controller.rb @@ -3,7 +3,19 @@ module Projects module Packages class InfrastructureRegistryController < Projects::ApplicationController + before_action :verify_feature_enabled! feature_category :infrastructure_as_code + + def show + @package = project.packages.find(params[:id]) + @package_files = @package.package_files.recent + end + + private + + def verify_feature_enabled! + render_404 unless Feature.enabled?(:infrastructure_registry_page, default_enabled: :yaml) + end end end end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 4af7508b935..006cb8a2201 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -10,6 +10,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] + before_action do + push_frontend_feature_flag(:ci_daily_limit_for_pipeline_schedules, @project, default_enabled: :yaml) + end + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 0de8dc597ae..7779f3c3b65 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -49,26 +49,9 @@ class Projects::PipelinesController < Projects::ApplicationController respond_to do |format| format.html do - experiment(:pipeline_empty_state_templates, actor: current_user) do |e| - e.exclude! unless current_user - e.exclude! if @pipelines_count.to_i > 0 - e.exclude! if helpers.has_gitlab_ci?(project) - - e.use {} - e.try {} - e.track(:view, value: project.namespace_id) - end - experiment(:code_quality_walkthrough, namespace: project.root_ancestor) do |e| - e.exclude! unless current_user - e.exclude! unless can?(current_user, :create_pipeline, project) - e.exclude! unless project.root_ancestor.recent? - e.exclude! if @pipelines_count.to_i > 0 - e.exclude! if helpers.has_gitlab_ci?(project) - - e.use {} - e.try {} - e.track(:view, property: project.root_ancestor.id.to_s) - end + enable_pipeline_empty_state_templates_experiment + enable_code_quality_walkthrough_experiment + enable_ci_runner_templates_experiment end format.json do Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) @@ -176,11 +159,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def retry - if Gitlab::Ci::Features.background_pipeline_retry_endpoint?(@project) - ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker - else - pipeline.retry_failed(current_user) - end + ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker respond_to do |format| format.html do @@ -321,6 +300,45 @@ class Projects::PipelinesController < Projects::ApplicationController def index_params params.permit(:scope, :username, :ref, :status) end + + def enable_pipeline_empty_state_templates_experiment + experiment(:pipeline_empty_state_templates, namespace: project.root_ancestor) do |e| + e.exclude! unless current_user + e.exclude! if @pipelines_count.to_i > 0 + e.exclude! if helpers.has_gitlab_ci?(project) + + e.control {} + e.candidate {} + e.record! + end + end + + def enable_code_quality_walkthrough_experiment + experiment(:code_quality_walkthrough, namespace: project.root_ancestor) do |e| + e.exclude! unless current_user + e.exclude! unless can?(current_user, :create_pipeline, project) + e.exclude! unless project.root_ancestor.recent? + e.exclude! if @pipelines_count.to_i > 0 + e.exclude! if helpers.has_gitlab_ci?(project) + + e.control {} + e.candidate {} + e.record! + end + end + + def enable_ci_runner_templates_experiment + experiment(:ci_runner_templates, namespace: project.root_ancestor) do |e| + e.exclude! unless current_user + e.exclude! unless can?(current_user, :create_pipeline, project) + e.exclude! if @pipelines_count.to_i > 0 + e.exclude! if helpers.has_gitlab_ci?(project) + + e.control {} + e.candidate {} + e.record! + end + end end Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index cc2157a7d51..370cd2b02a1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -62,6 +62,22 @@ class Projects::ProjectMembersController < Projects::ApplicationController def membershipable_members project.members end + + def plain_source_type + 'project' + end + + def source_type + _("project") + end + + def members_page_url + project_project_members_path(project) + end + + def root_params_key + :project_member + end end Projects::ProjectMembersController.prepend_mod_with('Projects::ProjectMembersController') diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 1bb50eabd1d..f01d10f4afa 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -10,6 +10,9 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :authorize_download_code!, except: [:index] before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new + before_action only: :index do + push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml) + end feature_category :release_orchestration diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb index 19de157357a..3a473bb67e0 100644 --- a/app/controllers/projects/security/configuration_controller.rb +++ b/app/controllers/projects/security/configuration_controller.rb @@ -7,6 +7,10 @@ module Projects feature_category :static_application_security_testing + before_action only: [:show] do + push_frontend_feature_flag(:security_configuration_redesign, project, default_enabled: :yaml) + end + def show render_403 unless can?(current_user, :read_security_configuration, project) end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 74145a70b95..cad13d7e708 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -85,14 +85,13 @@ class Projects::ServicesController < Projects::ApplicationController def integration @integration ||= @project.find_or_initialize_service(params[:id]) - @service ||= @integration # TODO: remove references to @service end alias_method :service, :integration def web_hook_logs - return unless @service.service_hook.present? + return unless integration.service_hook.present? - @web_hook_logs ||= @service.service_hook.web_hook_logs.recent.page(params[:page]) + @web_hook_logs ||= integration.service_hook.web_hook_logs.recent.page(params[:page]) end def ensure_service_enabled @@ -101,8 +100,8 @@ class Projects::ServicesController < Projects::ApplicationController def serialize_as_json integration - .as_json(only: @service.json_fields) - .merge(errors: @service.errors.as_json) + .as_json(only: integration.json_fields) + .merge(errors: integration.errors.as_json) end def redirect_deprecated_prometheus_service diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb index fba11ff1497..e54f4c511db 100644 --- a/app/controllers/projects/settings/integrations_controller.rb +++ b/app/controllers/projects/settings/integrations_controller.rb @@ -9,7 +9,7 @@ module Projects feature_category :integrations def show - @services = @project.find_or_initialize_services + @integrations = @project.find_or_initialize_services end end end diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index a357227c870..e32815b6239 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -65,7 +65,7 @@ module Projects return unless external_url_previous_change return unless external_url_previous_change[0].blank? && external_url_previous_change[1].present? - ::Gitlab::Tracking.event('project:operations:tracing', 'external_url_populated') + ::Gitlab::Tracking.event('project:operations:tracing', 'external_url_populated', user: current_user, project: project, namespace: project.namespace) end def alerting_params diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb index b4b8fb97049..df945a99c73 100644 --- a/app/controllers/projects/templates_controller.rb +++ b/app/controllers/projects/templates_controller.rb @@ -25,7 +25,7 @@ class Projects::TemplatesController < Projects::ApplicationController def names respond_to do |format| - format.json { render json: TemplateFinder.all_template_names_hash_or_array(project, params[:template_type].to_s) } + format.json { render json: TemplateFinder.all_template_names(project, params[:template_type].to_s.pluralize) } end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index a1493a25a1a..d1486f765e4 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -6,8 +6,4 @@ class Projects::WikisController < Projects::ApplicationController alias_method :container, :project feature_category :wiki - - before_action do - push_frontend_feature_flag(:wiki_content_editor, project, default_enabled: :yaml) - end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index fb977a5ee42..53d80b8be58 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -39,6 +39,11 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) end + before_action only: [:new] do + # Run experiment before render so it will be written to the `gon` for FE + helpers.new_repo_experiment_text + end + layout :determine_layout feature_category :projects, [ @@ -221,7 +226,14 @@ class ProjectsController < Projects::ApplicationController def download_export if @project.export_file_exists? - send_upload(@project.export_file, attachment: @project.export_file.filename) + if @project.export_archive_exists? + send_upload(@project.export_file, attachment: @project.export_file.filename) + else + redirect_to( + edit_project_path(@project, anchor: 'js-export-project'), + alert: _("The file containing the export is not available yet; it may still be transferring. Please try again later.") + ) + end else redirect_to( edit_project_path(@project, anchor: 'js-export-project'), @@ -330,11 +342,7 @@ class ProjectsController < Projects::ApplicationController experiment(:empty_repo_upload, project: @project).track(:view_project_show, property: property) end - if @project.empty_repo? - record_experiment_user(:invite_members_empty_project_version_a) - - render 'projects/empty' - end + render 'projects/empty' if @project.empty_repo? else if can?(current_user, :read_wiki, @project) @wiki = @project.wiki @@ -435,6 +443,7 @@ class ProjectsController < Projects::ApplicationController :request_access_enabled, :runners_token, :tag_list, + :topics, :visibility_level, :template_name, :template_project_id, diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 87465f8714d..34283cc8db7 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -52,7 +52,7 @@ module Registrations end def path_for_signed_in_user(user) - return users_almost_there_path if requires_confirmation?(user) + return users_almost_there_path(email: user.email) if requires_confirmation?(user) stored_location_for(user) || members_activity_path(user.members) end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0f29f6f608f..cacd3c2da64 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -101,7 +101,7 @@ class RegistrationsController < Devise::RegistrationsController Gitlab::AppLogger.info(user_created_message) return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval? - Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path + Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path(email: resource.email) end private @@ -202,6 +202,10 @@ class RegistrationsController < Devise::RegistrationsController experiment(:invite_signup_page_interaction, actor: member).track(:form_submission) experiment('members/invite_email', actor: member).track(:accepted) end + + def context_user + current_user + end end RegistrationsController.prepend_mod_with('RegistrationsController') diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 287ee2d5ab8..7282fc26121 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -20,7 +20,7 @@ class UsersController < ApplicationController skip_before_action :authenticate_user! prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } - before_action :user, except: [:exists, :suggests, :ssh_keys] + before_action :user, except: [:exists, :ssh_keys] before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following] @@ -153,14 +153,6 @@ class UsersController < ApplicationController render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) } end - def suggests - namespace_path = params[:username] - exists = !!Namespace.find_by_path_or_name(namespace_path) - suggestions = exists ? [Namespace.clean_path(namespace_path)] : [] - - render json: { exists: exists, suggests: suggestions } - end - def follow current_user.follow(user) diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index d7c4d2fcda3..53ea8ea2d3a 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -12,13 +12,18 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp def publish(_result = nil) return unless should_track? # don't track events for excluded contexts + record_experiment if @record # record the subject in the database if the context contains a namespace, group, project, actor or user + track(:assignment) # track that we've assigned a variant for this context - begin - Gon.push({ experiment: { name => signature } }, true) # push the experiment data to the client - rescue NoMethodError - # means we're not in the request cycle, and can't add to Gon. Log a warning maybe? - end + push_to_client + end + + # push the experiment data to the client + def push_to_client + Gon.push({ experiment: { name => signature } }, true) + rescue NoMethodError + # means we're not in the request cycle, and can't add to Gon. Log a warning maybe? end def track(action, **event_args) @@ -32,6 +37,10 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp )) end + def record! + @record = true + end + def exclude! @excluded = true end @@ -49,4 +58,13 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp def experiment_group? Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml) end + + def record_experiment + subject = context.value[:namespace] || context.value[:group] || context.value[:project] || context.value[:user] || context.value[:actor] + return unless ExperimentSubject.valid_subject?(subject) + + variant = :experimental if @variant_name != :control + + Experiment.add_subject(name, variant: variant || :control, subject: subject) + end end diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb index f780c6962df..893061e34f3 100644 --- a/app/experiments/members/invite_email_experiment.rb +++ b/app/experiments/members/invite_email_experiment.rb @@ -12,7 +12,7 @@ module Members end def resolve_variant_name - RoundRobin.new(feature_flag_name, %i[avatar permission_info control]).execute + RoundRobin.new(feature_flag_name, %i[activity control]).execute end end diff --git a/app/finders/ci/auth_job_finder.rb b/app/finders/ci/auth_job_finder.rb index aee7dd16341..d207a522aa8 100644 --- a/app/finders/ci/auth_job_finder.rb +++ b/app/finders/ci/auth_job_finder.rb @@ -15,6 +15,10 @@ module Ci next unless job validate_job!(job) + + if job.user && Feature.enabled?(:ci_scoped_job_token, job.project, default_enabled: :yaml) + job.user.set_ci_job_token_scope!(job) + end end end diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb index be65b1f6b3c..6c5038128f8 100644 --- a/app/finders/ci/pipelines_for_merge_request_finder.rb +++ b/app/finders/ci/pipelines_for_merge_request_finder.rb @@ -48,7 +48,7 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def pipelines_using_cte sha_relation = merge_request.all_commits.select(:sha) - sha_relation = sha_relation.distinct if Feature.enabled?(:use_distinct_in_shas_cte) + sha_relation = sha_relation.distinct if Feature.enabled?(:use_distinct_in_shas_cte, default_enabled: :yaml) cte = Gitlab::SQL::CTE.new(:shas, sha_relation) diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 60dd977ff94..7ad51361efd 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -4,6 +4,9 @@ module Ci class RunnersFinder < UnionFinder include Gitlab::Allowable + ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date].freeze + DEFAULT_SORT = 'created_at_desc' + def initialize(current_user:, group: nil, params:) @params = params @group = group @@ -24,11 +27,7 @@ module Ci end def sort_key - if @params[:sort] == 'contacted_asc' - 'contacted_asc' - else - 'created_date' - end + ALLOWED_SORTS.include?(@params[:sort]) ? @params[:sort] : DEFAULT_SORT end private diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb index f0ad998cadb..d2784a1d270 100644 --- a/app/finders/concerns/packages/finder_helper.rb +++ b/app/finders/concerns/packages/finder_helper.rb @@ -29,7 +29,7 @@ module Packages end def projects_visible_to_reporters(user, within_group:) - if user.is_a?(DeployToken) && Feature.enabled?(:packages_finder_helper_deploy_token, default_enabled: :yaml) + if user.is_a?(DeployToken) user.accessible_projects else within_group.all_projects diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index acce038dba6..bb9d204ab73 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -136,7 +136,7 @@ class DeploymentsFinder # Implicitly enforce the ordering when filtered by `updated_at` column for performance optimization. # See https://gitlab.com/gitlab-org/gitlab/-/issues/325627#note_552417509. # We remove this in https://gitlab.com/gitlab-org/gitlab/-/issues/328500. - if filter_by_updated_at? && implicitly_enforce_ordering_for_updated_at_filter? + if filter_by_updated_at? sort_params.replace('updated_at' => sort_direction) end @@ -170,15 +170,6 @@ class DeploymentsFinder params[:order_by].to_s == 'finished_at' end - def implicitly_enforce_ordering_for_updated_at_filter? - return false unless params[:project].present? - - ::Feature.enabled?( - :deployments_finder_implicitly_enforce_ordering_for_updated_at_filter, - params[:project], - default_enabled: :yaml) - end - # rubocop: disable CodeReuse/ActiveRecord def preload_associations(scope) scope.includes( diff --git a/app/finders/feature_flags_finder.rb b/app/finders/feature_flags_finder.rb index 7b38841970d..20b18c62f7a 100644 --- a/app/finders/feature_flags_finder.rb +++ b/app/finders/feature_flags_finder.rb @@ -24,7 +24,7 @@ class FeatureFlagsFinder private def feature_flags - project.operations_feature_flags + project.operations_feature_flags.new_version_only end def by_scope(items) diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index d1885b5ae08..086dadcf5b7 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -50,7 +50,7 @@ class IssuableFinder attr_reader :original_params attr_writer :parent - delegate(*%i[assignee milestones], to: :params) + delegate(*%i[milestones], to: :params) class << self def scalar_params @@ -148,7 +148,6 @@ class IssuableFinder # Negates all params found in `negatable_params` def filter_negated_items(items) - items = by_negated_assignee(items) items = by_negated_label(items) items = by_negated_milestone(items) items = by_negated_release(items) @@ -365,32 +364,21 @@ class IssuableFinder def by_author(items) Issuables::AuthorFilter.new( - items, params: original_params, or_filters_enabled: or_filters_enabled? - ).filter + ).filter(items) end def by_assignee(items) - if params.filter_by_no_assignee? - items.unassigned - elsif params.filter_by_any_assignee? - items.assigned - elsif params.assignee - items.assigned_to(params.assignee) - elsif params.assignee_id? || params.assignee_username? # assignee not found - items.none - else - items - end + assignee_filter.filter(items) end - def by_negated_assignee(items) - # We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB" - if not_params.assignees.present? - items.not_assigned_to(not_params.assignees) - else - items + def assignee_filter + strong_memoize(:assignee_filter) do + Issuables::AssigneeFilter.new( + params: original_params, + or_filters_enabled: or_filters_enabled? + ) end end diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index a62210ceac5..51e12dde51d 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -27,14 +27,6 @@ class IssuableFinder params.present? end - def filter_by_no_assignee? - params[:assignee_id].to_s.downcase == FILTER_NONE - end - - def filter_by_any_assignee? - params[:assignee_id].to_s.downcase == FILTER_ANY - end - def filter_by_no_label? downcased = label_names.map(&:downcase) @@ -156,24 +148,6 @@ class IssuableFinder end end - # rubocop: disable CodeReuse/ActiveRecord - def assignees - strong_memoize(:assignees) do - if assignee_id? - User.where(id: params[:assignee_id]) - elsif assignee_username? - User.where(username: params[:assignee_username]) - else - User.none - end - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def assignee - assignees.first - end - def label_names if labels? params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name] diff --git a/app/finders/issuables/assignee_filter.rb b/app/finders/issuables/assignee_filter.rb new file mode 100644 index 00000000000..2e58a6b34c9 --- /dev/null +++ b/app/finders/issuables/assignee_filter.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Issuables + class AssigneeFilter < BaseFilter + def filter(issuables) + filtered = by_assignee(issuables) + filtered = by_assignee_union(filtered) + by_negated_assignee(filtered) + end + + def includes_user?(user) + Array(params[:assignee_ids]).include?(user.id) || + Array(params[:assignee_id]).include?(user.id) || + Array(params[:assignee_username]).include?(user.username) + end + + private + + def by_assignee(issuables) + if filter_by_no_assignee? + issuables.unassigned + elsif filter_by_any_assignee? + issuables.assigned + elsif has_assignee_param?(params) + filter_by_assignees(issuables) + else + issuables + end + end + + def by_assignee_union(issuables) + return issuables unless or_filters_enabled? && has_assignee_param?(or_params) + + issuables.assigned_to(assignee_ids(or_params)) + end + + def by_negated_assignee(issuables) + return issuables unless has_assignee_param?(not_params) + + issuables.not_assigned_to(assignee_ids(not_params)) + end + + def filter_by_no_assignee? + params[:assignee_id].to_s.downcase == FILTER_NONE + end + + def filter_by_any_assignee? + params[:assignee_id].to_s.downcase == FILTER_ANY + end + + def filter_by_assignees(issuables) + assignee_ids = assignee_ids(params) + + return issuables.none if assignee_ids.blank? + + assignee_ids.each do |assignee_id| + issuables = issuables.assigned_to(assignee_id) + end + + issuables + end + + def has_assignee_param?(specific_params) + return if specific_params.nil? + + specific_params[:assignee_ids].present? || specific_params[:assignee_id].present? || specific_params[:assignee_username].present? + end + + def assignee_ids(specific_params) + if specific_params[:assignee_ids].present? + Array(specific_params[:assignee_ids]) + elsif specific_params[:assignee_id].present? + Array(specific_params[:assignee_id]) + elsif specific_params[:assignee_username].present? + User.by_username(specific_params[:assignee_username]).select(:id) + end + end + end +end diff --git a/app/finders/issuables/author_filter.rb b/app/finders/issuables/author_filter.rb index 522751a384e..f36daae553d 100644 --- a/app/finders/issuables/author_filter.rb +++ b/app/finders/issuables/author_filter.rb @@ -2,7 +2,7 @@ module Issuables class AuthorFilter < BaseFilter - def filter + def filter(issuables) filtered = by_author(issuables) filtered = by_author_union(filtered) by_negated_author(filtered) @@ -21,7 +21,7 @@ module Issuables end def by_author_union(issuables) - return issuables unless or_filters_enabled? && or_params&.fetch(:author_username).present? + return issuables unless or_filters_enabled? && or_params&.fetch(:author_username, false).present? issuables.authored(User.by_username(or_params[:author_username])) end diff --git a/app/finders/issuables/base_filter.rb b/app/finders/issuables/base_filter.rb index 6d1a3f96062..7c607e2d048 100644 --- a/app/finders/issuables/base_filter.rb +++ b/app/finders/issuables/base_filter.rb @@ -2,10 +2,12 @@ module Issuables class BaseFilter - attr_reader :issuables, :params + attr_reader :params - def initialize(issuables, params:, or_filters_enabled: false) - @issuables = issuables + FILTER_NONE = 'none' + FILTER_ANY = 'any' + + def initialize(params:, or_filters_enabled: false) @params = params @or_filters_enabled = or_filters_enabled end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index eb9099fe256..40d6730d232 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -52,7 +52,7 @@ class IssuesFinder < IssuableFinder # can always see confidential issues assigned to them. This is just an # optimization since a very common usecase of this Finder is to load the # count of issues assigned to the user for the header bar. - return Issue.all if current_user && params.assignees.include?(current_user) + return Issue.all if current_user && assignee_filter.includes_user?(current_user) return Issue.where('issues.confidential IS NOT TRUE') if params.user_cannot_see_confidential_issues? diff --git a/app/finders/packages/group_or_project_package_finder.rb b/app/finders/packages/group_or_project_package_finder.rb index fb8bcfc7d42..5b5f70bf459 100644 --- a/app/finders/packages/group_or_project_package_finder.rb +++ b/app/finders/packages/group_or_project_package_finder.rb @@ -26,9 +26,9 @@ module Packages def base if project? - packages_for_project(@project_or_group) + project_packages elsif group? - packages_visible_to_user(@current_user, within_group: @project_or_group) + group_packages else ::Packages::Package.none end @@ -41,5 +41,13 @@ module Packages def group? @project_or_group.is_a?(::Group) end + + def project_packages + packages_for_project(@project_or_group) + end + + def group_packages + packages_visible_to_user(@current_user, within_group: @project_or_group) + end end end diff --git a/app/finders/packages/helm/package_files_finder.rb b/app/finders/packages/helm/package_files_finder.rb new file mode 100644 index 00000000000..74f9eaaca82 --- /dev/null +++ b/app/finders/packages/helm/package_files_finder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Packages + module Helm + class PackageFilesFinder + def initialize(project, channel, params = {}) + @project = project + @channel = channel + @params = params + end + + def execute + package_files = Packages::PackageFile.for_helm_with_channel(@project, @channel).preload_helm_file_metadata + by_file_name(package_files) + end + + private + + def by_file_name(files) + return files unless @params[:file_name] + + files.with_file_name(@params[:file_name]) + end + end + end +end diff --git a/app/finders/packages/pypi/package_finder.rb b/app/finders/packages/pypi/package_finder.rb index 574e9770363..3a37e404b79 100644 --- a/app/finders/packages/pypi/package_finder.rb +++ b/app/finders/packages/pypi/package_finder.rb @@ -12,6 +12,16 @@ module Packages def packages base.pypi.has_version end + + def group_packages + # PyPI finds packages without checking permissions. + # The package download endpoint uses obfuscation to secure the file + # instead of authentication. This is behavior the PyPI package + # manager defines and is not something GitLab controls. + ::Packages::Package.for_projects( + @project_or_group.all_projects.select(:id) + ).installable + end end end end diff --git a/app/finders/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb index d79a2340379..509370b49a8 100644 --- a/app/finders/pending_todos_finder.rb +++ b/app/finders/pending_todos_finder.rb @@ -26,6 +26,7 @@ class PendingTodosFinder todos = by_project(todos) todos = by_target_id(todos) todos = by_target_type(todos) + todos = by_discussion(todos) by_commit_id(todos) end @@ -60,4 +61,12 @@ class PendingTodosFinder todos end end + + def by_discussion(todos) + if (discussion = params[:discussion]) + todos.for_note(discussion.notes) + else + todos + end + end end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index 13f84e0e3a5..f8ccea6b820 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -49,7 +49,7 @@ module Projects def has_prometheus?(environment_scope) finders_for_scope(environment_scope).any? do |finder| - finder.cluster.application_prometheus_available? + finder.cluster.integration_prometheus_available? end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 272747a124e..582075efc4e 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -14,7 +14,8 @@ # starred: boolean # sort: string # visibility_level: int -# tags: string[] +# tag: string[] - deprecated, use 'topic' instead +# topic: string[] # personal: boolean # search: string # search_namespaces: boolean @@ -37,6 +38,8 @@ class ProjectsFinder < UnionFinder @params = params @current_user = current_user @project_ids_relation = project_ids_relation + + @params[:topic] ||= @params.delete(:tag) if @params[:tag].present? end def execute @@ -76,7 +79,7 @@ class ProjectsFinder < UnionFinder collection = by_starred(collection) collection = by_trending(collection) collection = by_visibility_level(collection) - collection = by_tags(collection) + collection = by_topics(collection) collection = by_search(collection) collection = by_archived(collection) collection = by_custom_attributes(collection) @@ -176,8 +179,8 @@ class ProjectsFinder < UnionFinder end # rubocop: enable CodeReuse/ActiveRecord - def by_tags(items) - params[:tag].present? ? items.tagged_with(params[:tag]) : items + def by_topics(items) + params[:topic].present? ? items.tagged_with(params[:topic]) : items end def by_search(items) diff --git a/app/finders/security/jobs_finder.rb b/app/finders/security/jobs_finder.rb index e2efb2e18c9..b8649520c20 100644 --- a/app/finders/security/jobs_finder.rb +++ b/app/finders/security/jobs_finder.rb @@ -15,7 +15,7 @@ module Security attr_reader :pipeline def self.allowed_job_types - # Example return: [:sast, :dast, :dependency_scanning, :container_scanning, :license_management, :coverage_fuzzing] + # Example return: [:sast, :dast, :dependency_scanning, :container_scanning, :license_scanning, :coverage_fuzzing] raise NotImplementedError, 'allowed_job_types must be overwritten to return an array of job types' end diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb index 0f5622f2df0..b82b601541c 100644 --- a/app/finders/template_finder.rb +++ b/app/finders/template_finder.rb @@ -21,27 +21,11 @@ class TemplateFinder end end - # This is temporary and will be removed once we introduce group level inherited templates and - # remove the inherited_issuable_templates FF - def all_template_names_hash_or_array(project, issuable_type) - if project.inherited_issuable_templates_enabled? - all_template_names(project, issuable_type.pluralize) - else - all_template_names_array(project, issuable_type.pluralize) - end - end - def all_template_names(project, type) return {} if !VENDORED_TEMPLATES.key?(type.to_s) && type.to_s != 'licenses' build(type, project).template_names end - - # This is for issues and merge requests description templates only. - # This will be removed once we introduce group level inherited templates and remove the inherited_issuable_templates FF - def all_template_names_array(project, type) - all_template_names(project, type).values.flatten.select { |tmpl| tmpl[:project_id] == project.id }.compact.uniq - end end attr_reader :type, :project, :params diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 84941fcde02..8e95bd501ff 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -51,9 +51,8 @@ class GitlabSchema < GraphQL::Schema end def get_type(type_name) - # This is a backwards compatibility hack to work around an accidentally - # released argument typed as EEIterationID - type_name = type_name.gsub(/^EE/, '') if type_name.end_with?('ID') + type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name) + super(type_name) end @@ -162,10 +161,9 @@ class GitlabSchema < GraphQL::Schema end end - # This is a backwards compatibility hack to work around an accidentally - # released argument typed as EE{Type}ID def get_type(type_name) - type_name = type_name.gsub(/^EE/, '') if type_name.end_with?('ID') + type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name) + super(type_name) end end diff --git a/app/graphql/mutations/ci/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb index a484c2438a4..0973e9beae3 100644 --- a/app/graphql/mutations/ci/ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/ci_cd_settings_update.rb @@ -17,6 +17,10 @@ module Mutations required: false, description: 'Indicates if the latest artifact should be kept for this project.' + argument :job_token_scope_enabled, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Indicates CI job tokens generated in this project have restricted access to resources.' + field :ci_cd_settings, Types::Ci::CiCdSettingType, null: false, diff --git a/app/graphql/mutations/ci/runner/delete.rb b/app/graphql/mutations/ci/runner/delete.rb new file mode 100644 index 00000000000..8d9a5f15505 --- /dev/null +++ b/app/graphql/mutations/ci/runner/delete.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Runner + class Delete < BaseMutation + graphql_name 'RunnerDelete' + + authorize :delete_runner + + RunnerID = ::Types::GlobalIDType[::Ci::Runner] + + argument :id, RunnerID, + required: true, + description: 'ID of the runner to delete.' + + def resolve(id:, **runner_attrs) + runner = authorized_find!(id) + + error = authenticate_delete_runner!(runner) + return { errors: [error] } if error + + runner.destroy! + + { errors: runner.errors.full_messages } + end + + def authenticate_delete_runner!(runner) + return if current_user.can_admin_all_resources? + + "Runner #{runner.to_global_id} associated with more than one project" if runner.projects.count > 1 + end + + def find_object(id) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = RunnerID.coerce_isolated_input(id) + + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb new file mode 100644 index 00000000000..5b61b2ffc0d --- /dev/null +++ b/app/graphql/mutations/ci/runner/update.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Runner + class Update < BaseMutation + graphql_name 'RunnerUpdate' + + authorize :update_runner + + RunnerID = ::Types::GlobalIDType[::Ci::Runner] + + argument :id, RunnerID, + required: true, + description: 'ID of the runner to update.' + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: 'Description of the runner.' + + argument :maximum_timeout, GraphQL::INT_TYPE, + required: false, + description: 'Maximum timeout (in seconds) for jobs processed by the runner.' + + argument :access_level, ::Types::Ci::RunnerAccessLevelEnum, + required: false, + description: 'Access level of the runner.' + + argument :active, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Indicates the runner is allowed to receive jobs.' + + argument :locked, GraphQL::BOOLEAN_TYPE, required: false, + description: 'Indicates the runner is locked.' + + argument :run_untagged, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Indicates the runner is able to run untagged jobs.' + + argument :tag_list, [GraphQL::STRING_TYPE], required: false, + description: 'Tags associated with the runner.' + + field :runner, + Types::Ci::RunnerType, + null: true, + description: 'The runner after mutation.' + + def resolve(id:, **runner_attrs) + runner = authorized_find!(id) + + unless ::Ci::UpdateRunnerService.new(runner).update(runner_attrs) + return { runner: nil, errors: runner.errors.full_messages } + end + + { runner: runner, errors: [] } + end + + def find_object(id) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = RunnerID.coerce_isolated_input(id) + + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/runners_registration_token/reset.rb b/app/graphql/mutations/ci/runners_registration_token/reset.rb new file mode 100644 index 00000000000..e1cdd9a22a5 --- /dev/null +++ b/app/graphql/mutations/ci/runners_registration_token/reset.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module RunnersRegistrationToken + class Reset < BaseMutation + graphql_name 'RunnersRegistrationTokenReset' + + authorize :update_runners_registration_token + + ScopeID = ::GraphQL::ID_TYPE + + argument :type, ::Types::Ci::RunnerTypeEnum, + required: true, + description: 'Scope of the object to reset the token for.' + + argument :id, ScopeID, + required: false, + description: 'ID of the project or group to reset the token for. Omit if resetting instance runner token.' + + field :token, + GraphQL::STRING_TYPE, + null: true, + description: 'The runner token after mutation.' + + def resolve(**args) + { + token: reset_token(**args), + errors: [] + } + end + + private + + def find_object(type:, **args) + id = args[:id] + + case type + when 'group_type' + GitlabSchema.object_from_id(id, expected_type: ::Group) + when 'project_type' + GitlabSchema.object_from_id(id, expected_type: ::Project) + end + end + + def reset_token(type:, **args) + id = args[:id] + + case type + when 'instance_type' + raise Gitlab::Graphql::Errors::ArgumentError, "id must not be specified for '#{type}' scope" if id.present? + + authorize!(:global) + + ApplicationSetting.current.reset_runners_registration_token! + ApplicationSetting.current_without_cache.runners_registration_token + when 'group_type', 'project_type' + project_or_group = authorized_find!(type: type, id: id) + project_or_group.reset_runners_token! + project_or_group.runners_token + end + end + end + end + end +end diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb index 2e06e1ea0c4..f432f679909 100644 --- a/app/graphql/mutations/commits/create.rb +++ b/app/graphql/mutations/commits/create.rb @@ -44,6 +44,11 @@ module Mutations null: true, description: 'The commit after mutation.' + field :content, + [GraphQL::STRING_TYPE], + null: true, + description: 'Contents of the commit.' + authorize :push_code def resolve(project_path:, branch:, message:, actions:, **args) @@ -59,6 +64,7 @@ module Mutations result = ::Files::MultiService.new(project, current_user, attributes).execute { + content: actions.pluck(:content), # rubocop:disable CodeReuse/ActiveRecord because actions is an Array, not a Relation commit: (project.repository.commit(result[:result]) if result[:status] == :success), commit_pipeline_path: UrlHelpers.new.graphql_etag_pipeline_sha_path(result[:result]), errors: Array.wrap(result[:message]) diff --git a/app/graphql/mutations/concerns/mutations/resolves_subscription.rb b/app/graphql/mutations/concerns/mutations/resolves_subscription.rb index e26ae7d228c..ed9fb5fceb0 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_subscription.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_subscription.rb @@ -3,6 +3,7 @@ module Mutations module ResolvesSubscription extend ActiveSupport::Concern + included do argument :subscribed_state, GraphQL::BOOLEAN_TYPE, diff --git a/app/graphql/mutations/issues/set_subscription.rb b/app/graphql/mutations/issues/set_subscription.rb index a04c8f5ba2d..55c9049b7cf 100644 --- a/app/graphql/mutations/issues/set_subscription.rb +++ b/app/graphql/mutations/issues/set_subscription.rb @@ -2,10 +2,32 @@ module Mutations module Issues - class SetSubscription < Base + class SetSubscription < BaseMutation graphql_name 'IssueSetSubscription' include ResolvesSubscription + include Mutations::ResolvesIssuable + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: "The project the issue to mutate is in." + + argument :iid, GraphQL::STRING_TYPE, + required: true, + description: "The IID of the issue to mutate." + + field :issue, + Types::IssueType, + null: true, + description: "The issue after mutation." + + authorize :update_subscription + + private + + def find_object(project_path:, iid:) + resolve_issuable(type: :issue, parent_path: project_path, iid: iid) + end end end end diff --git a/app/graphql/mutations/labels/create.rb b/app/graphql/mutations/labels/create.rb index 4da628d53ea..683d0b44586 100644 --- a/app/graphql/mutations/labels/create.rb +++ b/app/graphql/mutations/labels/create.rb @@ -20,10 +20,6 @@ module Mutations required: false, description: 'Description of the label.' - argument :remove_on_close, GraphQL::BOOLEAN_TYPE, - required: false, - description: copy_field_description(Types::LabelType, :remove_on_close) - argument :color, GraphQL::STRING_TYPE, required: false, default_value: Label::DEFAULT_COLOR, diff --git a/app/graphql/mutations/merge_requests/set_subscription.rb b/app/graphql/mutations/merge_requests/set_subscription.rb index 7d3c40185c9..981daa81c28 100644 --- a/app/graphql/mutations/merge_requests/set_subscription.rb +++ b/app/graphql/mutations/merge_requests/set_subscription.rb @@ -2,10 +2,32 @@ module Mutations module MergeRequests - class SetSubscription < Base + class SetSubscription < BaseMutation graphql_name 'MergeRequestSetSubscription' include ResolvesSubscription + include Mutations::ResolvesIssuable + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: "The project the merge request to mutate is in." + + argument :iid, GraphQL::STRING_TYPE, + required: true, + description: "The IID of the merge request to mutate." + + field :merge_request, + Types::MergeRequestType, + null: true, + description: "The merge request after mutation." + + authorize :update_subscription + + private + + def find_object(project_path:, iid:) + resolve_issuable(type: :merge_request, parent_path: project_path, iid: iid) + end end end end diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index e9b45294659..d1ad0697acd 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -49,7 +49,7 @@ module Mutations process_args_for_params!(args) - service_response = ::Snippets::CreateService.new(project, current_user, args).execute + service_response = ::Snippets::CreateService.new(project: project, current_user: current_user, params: args).execute # Only when the user is not an api user and the operation was successful if !api_user? && service_response.success? diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index b9b9b13eebb..2e1382e1cb1 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -34,7 +34,7 @@ module Mutations process_args_for_params!(args) - service_response = ::Snippets::UpdateService.new(snippet.project, current_user, args).execute(snippet) + service_response = ::Snippets::UpdateService.new(project: snippet.project, current_user: current_user, params: args).execute(snippet) # TODO: DRY this up - From here down, this is all duplicated with Mutations::Snippets::Create#resolve, except for # `snippet.reset`, which is required in order to return the object in its non-dirty, unmodified, database state diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb index 22a5893d4ec..7dd06cc8293 100644 --- a/app/graphql/mutations/todos/mark_all_done.rb +++ b/app/graphql/mutations/todos/mark_all_done.rb @@ -7,12 +7,6 @@ module Mutations authorize :update_user - field :updated_ids, - [::Types::GlobalIDType[::Todo]], - null: false, - deprecated: { reason: 'Use to-do items', milestone: '13.2' }, - description: 'IDs of the updated to-do items.' - field :todos, [::Types::TodoType], null: false, description: 'Updated to-do items.' @@ -23,7 +17,6 @@ module Mutations updated_ids = mark_all_todos_done { - updated_ids: updated_ids, todos: Todo.id_in(updated_ids), errors: [] } diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb index 41ccbd77aa6..b09c59a3435 100644 --- a/app/graphql/mutations/todos/restore_many.rb +++ b/app/graphql/mutations/todos/restore_many.rb @@ -12,11 +12,6 @@ module Mutations required: true, description: 'The global IDs of the to-do items to restore (a maximum of 50 is supported at once).' - field :updated_ids, [::Types::GlobalIDType[Todo]], - null: false, - description: 'The IDs of the updated to-do items.', - deprecated: { reason: 'Use to-do items', milestone: '13.2' } - field :todos, [::Types::TodoType], null: false, description: 'Updated to-do items.' diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb index 29e66a59a15..dac93b91469 100644 --- a/app/graphql/resolvers/board_list_issues_resolver.rb +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -2,7 +2,7 @@ module Resolvers class BoardListIssuesResolver < BaseResolver - include BoardIssueFilterable + include BoardItemFilterable argument :filters, Types::Boards::BoardIssueInputType, required: false, @@ -13,7 +13,7 @@ module Resolvers alias_method :list, :object def resolve(**args) - filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id) + filter_params = item_filters(args[:filters]).merge(board_id: list.board.id, id: list.id) service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params) offset_pagination(service.execute) diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb index 0b699006626..4dae3b4a9d1 100644 --- a/app/graphql/resolvers/board_lists_resolver.rb +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -2,7 +2,7 @@ module Resolvers class BoardListsResolver < BaseResolver - include BoardIssueFilterable + include BoardItemFilterable include Gitlab::Graphql::Authorize::AuthorizeResource include LooksAhead @@ -22,7 +22,7 @@ module Resolvers def resolve_with_lookahead(id: nil, issue_filters: {}) lists = board_lists(id) - context.scoped_set!(:issue_filters, issue_filters(issue_filters)) + context.scoped_set!(:issue_filters, item_filters(issue_filters)) List.preload_preferences_for_user(lists, current_user) if load_preferences? diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 710706325cc..3ad1e2780dd 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -17,6 +17,10 @@ module Resolvers required: false, description: 'Filter by tags associated with the runner (comma-separated or array).' + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Filter by full token or partial text in description field.' + argument :sort, ::Types::Ci::RunnerSortEnum, required: false, description: 'Sort order of results.' diff --git a/app/graphql/resolvers/concerns/board_issue_filterable.rb b/app/graphql/resolvers/concerns/board_item_filterable.rb index 88de69a3844..1457a02e44f 100644 --- a/app/graphql/resolvers/concerns/board_issue_filterable.rb +++ b/app/graphql/resolvers/concerns/board_item_filterable.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -module BoardIssueFilterable +module BoardItemFilterable extend ActiveSupport::Concern private - def issue_filters(args) + def item_filters(args) filters = args.to_h set_filter_values(filters) @@ -32,4 +32,4 @@ module BoardIssueFilterable end end -::BoardIssueFilterable.prepend_mod_with('Resolvers::BoardIssueFilterable') +::BoardItemFilterable.prepend_mod_with('Resolvers::BoardItemFilterable') diff --git a/app/graphql/resolvers/concerns/time_frame_arguments.rb b/app/graphql/resolvers/concerns/time_frame_arguments.rb index 6cac46a71d2..0ec3c642f29 100644 --- a/app/graphql/resolvers/concerns/time_frame_arguments.rb +++ b/app/graphql/resolvers/concerns/time_frame_arguments.rb @@ -39,4 +39,12 @@ module TimeFrameArguments raise Gitlab::Graphql::Errors::ArgumentError, error_message end end + + def transform_timeframe_parameters(args) + if args[:timeframe] + args[:timeframe].transform_keys { |k| :"#{k}_date" } + else + args.slice(:start_date, :end_date) + end + end end diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb index c94e3d9e1d8..1241b41501d 100644 --- a/app/graphql/resolvers/milestones_resolver.rb +++ b/app/graphql/resolvers/milestones_resolver.rb @@ -44,15 +44,7 @@ module Resolvers title: args[:title], search_title: args[:search_title], containing_date: args[:containing_date] - }.merge!(timeframe_parameters(args)).merge!(parent_id_parameters(args)) - end - - def timeframe_parameters(args) - if args[:timeframe] - args[:timeframe].transform_keys { |k| :"#{k}_date" } - else - args.slice(:start_date, :end_date) - end + }.merge!(transform_timeframe_parameters(args)).merge!(parent_id_parameters(args)) end def parent diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb index db3037ec591..3674aa97e1f 100644 --- a/app/graphql/resolvers/projects/services_resolver.rb +++ b/app/graphql/resolvers/projects/services_resolver.rb @@ -12,19 +12,19 @@ module Resolvers argument :active, GraphQL::BOOLEAN_TYPE, required: false, - description: 'Indicates if the service is active.' + description: 'Indicates if the integration is active.' argument :type, Types::Projects::ServiceTypeEnum, required: false, - description: 'Class name of the service.' + description: 'Type of integration.' alias_method :project, :object def resolve(active: nil, type: nil) - servs = project.integrations - servs = servs.by_active_flag(active) unless active.nil? - servs = servs.by_type(type) unless type.blank? - servs + items = project.integrations + items = items.by_active_flag(active) unless active.nil? + items = items.by_type(type) unless type.blank? + items end end end diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 11d18a0a080..665ec796cd3 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -24,6 +24,10 @@ module Resolvers required: false, description: 'Sort order of results.' + argument :topics, type: [GraphQL::STRING_TYPE], + required: false, + description: 'Filters projects by topics.' + def resolve(**args) ProjectsFinder .new(current_user: current_user, params: project_finder_params(args), project_ids_relation: parse_gids(args[:ids])) @@ -38,7 +42,8 @@ module Resolvers non_public: params[:membership], search: params[:search], search_namespaces: params[:search_namespaces], - sort: params[:sort] + sort: params[:sort], + topic: params[:topics] }.compact end diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb index aebd04259ee..8ac30c4cb5d 100644 --- a/app/graphql/resolvers/timelog_resolver.rb +++ b/app/graphql/resolvers/timelog_resolver.rb @@ -7,106 +7,88 @@ module Resolvers type ::Types::TimelogType.connection_type, null: false argument :start_date, Types::TimeType, - required: false, - description: 'List time logs within a date range where the logged date is equal to or after startDate.' + required: false, + description: 'List time logs within a date range where the logged date is equal to or after startDate.' argument :end_date, Types::TimeType, - required: false, - description: 'List time logs within a date range where the logged date is equal to or before endDate.' + required: false, + description: 'List time logs within a date range where the logged date is equal to or before endDate.' argument :start_time, Types::TimeType, - required: false, - description: 'List time-logs within a time range where the logged time is equal to or after startTime.' + required: false, + description: 'List time-logs within a time range where the logged time is equal to or after startTime.' argument :end_time, Types::TimeType, - required: false, - description: 'List time-logs within a time range where the logged time is equal to or before endTime.' + required: false, + description: 'List time-logs within a time range where the logged time is equal to or before endTime.' def resolve_with_lookahead(**args) - return Timelog.none unless timelogs_available_for_user? + build_timelogs - validate_params_presence!(args) - transformed_args = transform_args(args) - validate_time_difference!(transformed_args) + if args.any? + validate_args!(args) + build_parsed_args(args) + validate_time_difference! + apply_time_filter + end - find_timelogs(transformed_args) + apply_lookahead(timelogs) end private + attr_reader :parsed_args, :timelogs + def preloads { note: [:note] } end - def find_timelogs(args) - apply_lookahead(group.timelogs(args[:start_time], args[:end_time])) + def validate_args!(args) + if args[:start_time] && args[:start_date] + raise_argument_error('Provide either a start date or time, but not both') + elsif args[:end_time] && args[:end_date] + raise_argument_error('Provide either an end date or time, but not both') + end end - def timelogs_available_for_user? - group&.user_can_access_group_timelogs?(context[:current_user]) - end + def build_parsed_args(args) + if times_provided?(args) + @parsed_args = args + else + @parsed_args = args.except(:start_date, :end_date) - def validate_params_presence!(args) - message = case time_params_count(args) - when 0 - 'Start and End arguments must be present' - when 1 - 'Both Start and End arguments must be present' - when 2 - validate_duplicated_args(args) - when 3 || 4 - 'Only Time or Date arguments must be present' - end - - raise_argument_error(message) if message + @parsed_args[:start_time] = args[:start_date].beginning_of_day if args[:start_date] + @parsed_args[:end_time] = args[:end_date].end_of_day if args[:end_date] + end end - def validate_time_difference!(args) - message = if args[:end_time] < args[:start_time] - 'Start argument must be before End argument' - elsif args[:end_time] - args[:start_time] > 60.days - 'The time range period cannot contain more than 60 days' - end - - raise_argument_error(message) if message + def times_provided?(args) + args[:start_time] && args[:end_time] end - def transform_args(args) - return args if args.keys == [:start_time, :end_time] + def validate_time_difference! + return unless end_time_before_start_time? - time_args = args.except(:start_date, :end_date) - - if time_args.empty? - time_args[:start_time] = args[:start_date].beginning_of_day - time_args[:end_time] = args[:end_date].end_of_day - elsif time_args.key?(:start_time) - time_args[:end_time] = args[:end_date].end_of_day - elsif time_args.key?(:end_time) - time_args[:start_time] = args[:start_date].beginning_of_day - end + raise_argument_error('Start argument must be before End argument') + end - time_args + def end_time_before_start_time? + times_provided?(parsed_args) && parsed_args[:end_time] < parsed_args[:start_time] end - def time_params_count(args) - [:start_time, :end_time, :start_date, :end_date].count { |param| args.key?(param) } + def build_timelogs + @timelogs = Timelog.in_group(object) end - def validate_duplicated_args(args) - if args.key?(:start_time) && args.key?(:start_date) || - args.key?(:end_time) && args.key?(:end_date) - 'Both Start and End arguments must be present' - end + def apply_time_filter + @timelogs = timelogs.at_or_after(parsed_args[:start_time]) if parsed_args[:start_time] + @timelogs = timelogs.at_or_before(parsed_args[:end_time]) if parsed_args[:end_time] end def raise_argument_error(message) raise Gitlab::Graphql::Errors::ArgumentError, message end - - def group - @group ||= object.respond_to?(:sync) ? object.sync : object - end end end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index 7ef1cbddbd9..d70236f16f9 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -2,7 +2,19 @@ module Types class BaseEnum < GraphQL::Schema::Enum - extend GitlabStyleDeprecations + class CustomValue < GraphQL::Schema::EnumValue + include ::GitlabStyleDeprecations + + attr_reader :deprecation + + def initialize(name, desc = nil, **kwargs) + @deprecation = gitlab_deprecation(kwargs) + + super(name, desc, **kwargs) + end + end + + enum_value_class(CustomValue) class << self # Registers enum definition by the given DeclarativeEnum module @@ -41,7 +53,6 @@ module Types def value(*args, **kwargs, &block) enum[args[0].downcase] = kwargs[:value] || args[0] - gitlab_deprecation(kwargs) super(*args, **kwargs, &block) end diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb index b34a91446a2..f90c75454ba 100644 --- a/app/graphql/types/ci/ci_cd_setting_type.rb +++ b/app/graphql/types/ci/ci_cd_setting_type.rb @@ -16,6 +16,9 @@ module Types field :keep_latest_artifact, GraphQL::BOOLEAN_TYPE, null: true, description: 'Whether to keep the latest builds artifacts.', method: :keep_latest_artifacts_available? + field :job_token_scope_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates CI job tokens generated in this project have restricted access to resources.', + method: :job_token_scope_enabled? field :project, Types::ProjectType, null: true, description: 'Project the CI/CD settings belong to.' end diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb index 550e870316a..95ec1867fea 100644 --- a/app/graphql/types/ci/runner_sort_enum.rb +++ b/app/graphql/types/ci/runner_sort_enum.rb @@ -7,7 +7,9 @@ module Types description 'Values for sorting runners' value 'CONTACTED_ASC', 'Ordered by contacted_at in ascending order.', value: :contacted_asc - value 'CREATED_DESC', 'Ordered by created_date in descending order.', value: :created_date + value 'CONTACTED_DESC', 'Ordered by contacted_at in descending order.', value: :contacted_desc + value 'CREATED_ASC', 'Ordered by created_at in ascending order.', value: :created_at_asc + value 'CREATED_DESC', 'Ordered by created_at in descending order.', value: :created_at_desc end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index 3abed7289d5..837d91ef765 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -40,3 +40,5 @@ module Types end end end + +Types::Ci::RunnerType.prepend_mod_with('Types::Ci::RunnerType') diff --git a/app/graphql/types/ci/runner_type_enum.rb b/app/graphql/types/ci/runner_type_enum.rb index f771635f4ed..12e87906179 100644 --- a/app/graphql/types/ci/runner_type_enum.rb +++ b/app/graphql/types/ci/runner_type_enum.rb @@ -5,10 +5,10 @@ module Types class RunnerTypeEnum < BaseEnum graphql_name 'CiRunnerType' - ::Ci::Runner.runner_types.keys.each do |type| - value type.upcase, - description: "A runner that is #{type.tr('_', ' ')}.", - value: type + ::Ci::Runner::AVAILABLE_TYPES.each do |runner_type| + value runner_type.upcase, + description: "A runner that is #{runner_type.tr('_', ' ')}.", + value: runner_type end end end diff --git a/app/graphql/types/deprecated_mutations.rb b/app/graphql/types/deprecated_mutations.rb index a4336fa3ef3..49bad56b6f9 100644 --- a/app/graphql/types/deprecated_mutations.rb +++ b/app/graphql/types/deprecated_mutations.rb @@ -5,15 +5,7 @@ module Types extend ActiveSupport::Concern prepended do - mount_aliased_mutation 'AddAwardEmoji', - Mutations::AwardEmojis::Add, - deprecated: { reason: 'Use awardEmojiAdd', milestone: '13.2' } - mount_aliased_mutation 'RemoveAwardEmoji', - Mutations::AwardEmojis::Remove, - deprecated: { reason: 'Use awardEmojiRemove', milestone: '13.2' } - mount_aliased_mutation 'ToggleAwardEmoji', - Mutations::AwardEmojis::Toggle, - deprecated: { reason: 'Use awardEmojiToggle', milestone: '13.2' } + # placeholder for any FOSS mutations to be deprecated end end end diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb index 79061df7282..c44c268b43f 100644 --- a/app/graphql/types/global_id_type.rb +++ b/app/graphql/types/global_id_type.rb @@ -52,11 +52,20 @@ module Types @id_types ||= {} @id_types[model_class] ||= Class.new(self) do - graphql_name "#{model_class.name.gsub(/::/, '')}ID" - description <<~MD + model_name = model_class.name + + graphql_name model_name_to_graphql_name(model_name) + description <<~MD.strip A `#{graphql_name}` is a global ID. It is encoded as a string. - An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_class.name, id: 1)}"`. + An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_name, id: 1)}"`. + #{ + if deprecation = Gitlab::GlobalId::Deprecations.deprecation_by(model_name) + 'The older format `"' + + ::Gitlab::GlobalId.build(model_name: deprecation.old_model_name, id: 1).to_s + + '"` was deprecated in ' + deprecation.milestone + '.' + end} + MD define_singleton_method(:to_s) do @@ -69,7 +78,7 @@ module Types define_singleton_method(:as) do |new_name| if @renamed && graphql_name != new_name - raise "Conflicting names for ID of #{model_class.name}: " \ + raise "Conflicting names for ID of #{model_name}: " \ "#{graphql_name} and #{new_name}" end @@ -79,11 +88,11 @@ module Types end define_singleton_method(:coerce_result) do |gid, ctx| - global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name) + global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_name) next global_id.to_s if suitable?(global_id) - raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}" + raise GraphQL::CoercionError, "Expected a #{model_name} ID, got #{global_id}" end define_singleton_method(:suitable?) do |gid| @@ -97,9 +106,13 @@ module Types gid = super(string, ctx) next gid if suitable?(gid) - raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}" + raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_name}" end end end + + def self.model_name_to_graphql_name(model_name) + "#{model_name.gsub(/::/, '')}ID" + end end end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index cb6b0312aa3..4e8718a80da 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -23,7 +23,5 @@ module Types description: 'When this label was created.' field :updated_at, Types::TimeType, null: false, description: 'When this label was last updated.' - field :remove_on_close, GraphQL::BOOLEAN_TYPE, null: false, - description: 'Whether the label should be removed from an issue when the issue is closed.' end end diff --git a/app/graphql/types/member_interface.rb b/app/graphql/types/member_interface.rb index 1c7257487d9..6a21e51fe28 100644 --- a/app/graphql/types/member_interface.rb +++ b/app/graphql/types/member_interface.rb @@ -22,7 +22,7 @@ module Types field :expires_at, Types::TimeType, null: true, description: 'Date and time the membership expires.' - field :user, Types::UserType, null: false, + field :user, Types::UserType, null: true, description: 'User that is associated with the member object.' definition_methods do diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 4eeeaa4f5d0..338b70bb0c6 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -82,7 +82,11 @@ module Types field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true, description: 'Indicates if the project settings will lead to source branch deletion after merge.' field :merge_status, GraphQL::STRING_TYPE, method: :public_merge_status, null: true, - description: 'Status of the merge request.' + description: 'Status of the merge request.', + deprecated: { reason: :renamed, replacement: 'MergeRequest.mergeStatusEnum', milestone: '14.0' } + field :merge_status_enum, ::Types::MergeRequests::MergeStatusEnum, + method: :public_merge_status, null: true, + description: 'Merge status of the merge request.' field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true, description: 'Commit SHA of the merge request if merge is in progress.' field :merge_error, GraphQL::STRING_TYPE, null: true, @@ -158,6 +162,10 @@ module Types description: 'Time estimate of the merge request.' field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the merge request.' + field :human_time_estimate, GraphQL::STRING_TYPE, null: true, + description: 'Human-readable time estimate of the merge request.' + field :human_total_time_spent, GraphQL::STRING_TYPE, null: true, + description: 'Human-readable total time reported as spent on the merge request.' field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, description: 'Internal reference of the merge request. Returned in shortened format by default.' do argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, diff --git a/app/graphql/types/merge_requests/merge_status_enum.rb b/app/graphql/types/merge_requests/merge_status_enum.rb new file mode 100644 index 00000000000..bb3e0f1a0c0 --- /dev/null +++ b/app/graphql/types/merge_requests/merge_status_enum.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class MergeStatusEnum < BaseEnum + graphql_name 'MergeStatus' + description 'Representation of whether a GitLab merge request can be merged.' + + value 'UNCHECKED', + value: 'unchecked', + description: 'Merge status has not been checked.' + value 'CHECKING', + value: 'checking', + description: 'Currently checking for mergeability.' + value 'CAN_BE_MERGED', + value: 'can_be_merged', + description: 'There are no conflicts between the source and target branches.' + value 'CANNOT_BE_MERGED', + value: 'cannot_be_merged', + description: 'There are conflicts between the source and target branches.' + value 'CANNOT_BE_MERGED_RECHECK', + value: 'cannot_be_merged_recheck', + description: 'Currently unchecked. The previous state was `CANNOT_BE_MERGED`.' + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 54a06ed5342..6b1146f8f09 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -99,6 +99,9 @@ module Types mount_mutation Mutations::Ci::CiCdSettingsUpdate mount_mutation Mutations::Ci::Job::Play mount_mutation Mutations::Ci::Job::Retry + mount_mutation Mutations::Ci::Runner::Update, feature_flag: :runner_graphql_query + mount_mutation Mutations::Ci::Runner::Delete, feature_flag: :runner_graphql_query + mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset, feature_flag: :runner_graphql_query mount_mutation Mutations::Namespace::PackageSettings::Update mount_mutation Mutations::UserCallouts::Create end diff --git a/app/graphql/types/packages/metadata_type.rb b/app/graphql/types/packages/metadata_type.rb index 94880cb9b22..3b2257547b7 100644 --- a/app/graphql/types/packages/metadata_type.rb +++ b/app/graphql/types/packages/metadata_type.rb @@ -6,7 +6,7 @@ module Types graphql_name 'PackageMetadata' description 'Represents metadata associated with a Package' - possible_types ::Types::Packages::Composer::MetadatumType, ::Types::Packages::Conan::MetadatumType, ::Types::Packages::Maven::MetadatumType, ::Types::Packages::Nuget::MetadatumType + possible_types ::Types::Packages::Composer::MetadatumType, ::Types::Packages::Conan::MetadatumType, ::Types::Packages::Maven::MetadatumType, ::Types::Packages::Nuget::MetadatumType, ::Types::Packages::Pypi::MetadatumType def self.resolve_type(object, context) case object @@ -18,6 +18,8 @@ module Types ::Types::Packages::Maven::MetadatumType when ::Packages::Nuget::Metadatum ::Types::Packages::Nuget::MetadatumType + when ::Packages::Pypi::Metadatum + ::Types::Packages::Pypi::MetadatumType else # NOTE: This method must be kept in sync with `PackageWithoutVersionsType#metadata`, # which must never produce data that this discriminator cannot handle. diff --git a/app/graphql/types/packages/package_group_sort_enum.rb b/app/graphql/types/packages/package_group_sort_enum.rb index 70fb27ec0db..28a1bf85911 100644 --- a/app/graphql/types/packages/package_group_sort_enum.rb +++ b/app/graphql/types/packages/package_group_sort_enum.rb @@ -6,10 +6,8 @@ module Types graphql_name 'PackageGroupSort' description 'Values for sorting group packages' - # The following enums are not available till we enable the new Arel node: - # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58657#note_552632305 - # value 'PROJECT_PATH_DESC', 'Project by descending order.', value: :project_path_desc - # value 'PROJECT_PATH_ASC', 'Project by ascending order.', value: :project_path_asc + value 'PROJECT_PATH_DESC', 'Ordered by project path in descending order.', value: :project_path_desc + value 'PROJECT_PATH_ASC', 'Ordered by project path in ascending order.', value: :project_path_asc end end end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index b349b655fa5..ee6785e3555 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -49,6 +49,8 @@ module Types object.maven_metadatum when 'nuget' object.nuget_metadatum + when 'pypi' + object.pypi_metadatum else nil end diff --git a/app/graphql/types/packages/pypi/metadatum_type.rb b/app/graphql/types/packages/pypi/metadatum_type.rb new file mode 100644 index 00000000000..031d3572197 --- /dev/null +++ b/app/graphql/types/packages/pypi/metadatum_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Packages + module Pypi + class MetadatumType < BaseObject + graphql_name 'PypiMetadata' + description 'Pypi metadata' + + authorize :read_package + + field :id, ::Types::GlobalIDType[::Packages::Pypi::Metadatum], null: false, description: 'ID of the metadatum.' + field :required_python, GraphQL::STRING_TYPE, null: true, description: 'Required Python version of the Pypi package.' + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index a2852588e89..55dc73d898d 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -387,6 +387,10 @@ module Types ::Security::CiConfiguration::SastParserService.new(object).configuration end + def tag_list + object.topic_list + end + private def project diff --git a/app/graphql/types/projects/service_type.rb b/app/graphql/types/projects/service_type.rb index 9ce325c4655..6f0dcd44cad 100644 --- a/app/graphql/types/projects/service_type.rb +++ b/app/graphql/types/projects/service_type.rb @@ -15,7 +15,7 @@ module Types definition_methods do def resolve_type(object, context) - if object.is_a?(::JiraService) + if object.is_a?(::Integrations::Jira) Types::Projects::Services::JiraServiceType else Types::Projects::Services::BaseServiceType diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb index 0a57cd48df4..9948fa8bb69 100644 --- a/app/graphql/types/projects/service_type_enum.rb +++ b/app/graphql/types/projects/service_type_enum.rb @@ -5,8 +5,8 @@ module Types class ServiceTypeEnum < BaseEnum graphql_name 'ServiceType' - ::Integration.available_services_types(include_dev: false).each do |service_type| - value service_type.underscore.upcase, value: service_type, description: "#{service_type} type" + ::Integration.available_services_types(include_dev: false).each do |type| + value type.underscore.upcase, value: type, description: "#{type} type" end end end diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 2f79ec48c04..34357ead048 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -61,12 +61,6 @@ module Types description: 'Raw URL of the snippet.', null: false - field :blob, type: Types::Snippets::BlobType, - description: 'Snippet blob.', - calls_gitaly: true, - null: false, - deprecated: { reason: 'Use `blobs`', milestone: '13.3' } - field :blobs, type: Types::Snippets::BlobType.connection_type, description: 'Snippet blobs.', calls_gitaly: true, diff --git a/app/graphql/types/snippets/blob_action_enum.rb b/app/graphql/types/snippets/blob_action_enum.rb index e3f89920f16..0defd521acb 100644 --- a/app/graphql/types/snippets/blob_action_enum.rb +++ b/app/graphql/types/snippets/blob_action_enum.rb @@ -6,10 +6,10 @@ module Types graphql_name 'SnippetBlobActionEnum' description 'Type of a snippet blob input action' - value 'create', value: :create - value 'update', value: :update - value 'delete', value: :delete - value 'move', value: :move + value 'create', description: 'Create a snippet blob.', value: :create + value 'update', description: 'Update a snippet blob.', value: :update + value 'delete', description: 'Delete a snippet blob.', value: :delete + value 'move', description: 'Move a snippet blob.', value: :move end end end diff --git a/app/graphql/types/snippets/visibility_scopes_enum.rb b/app/graphql/types/snippets/visibility_scopes_enum.rb index 5488e05b95d..ddcc005eaf2 100644 --- a/app/graphql/types/snippets/visibility_scopes_enum.rb +++ b/app/graphql/types/snippets/visibility_scopes_enum.rb @@ -3,9 +3,9 @@ module Types module Snippets class VisibilityScopesEnum < BaseEnum - value 'private', value: 'are_private' - value 'internal', value: 'are_internal' - value 'public', value: 'are_public' + value 'private', description: 'The snippet is visible only to the snippet creator.', value: 'are_private' + value 'internal', description: 'The snippet is visible for any logged in user except external users.', value: 'are_internal' + value 'public', description: 'The snippet can be accessed without any authentication.', value: 'are_public' end end end diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb index 99a619f1b1d..925a522629e 100644 --- a/app/graphql/types/timelog_type.rb +++ b/app/graphql/types/timelog_type.rb @@ -4,7 +4,7 @@ module Types class TimelogType < BaseObject graphql_name 'Timelog' - authorize :read_group_timelogs + authorize :read_issue field :spent_at, Types::TimeType, diff --git a/app/helpers/admin/background_migrations_helper.rb b/app/helpers/admin/background_migrations_helper.rb new file mode 100644 index 00000000000..698d81cc8a2 --- /dev/null +++ b/app/helpers/admin/background_migrations_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Admin + module BackgroundMigrationsHelper + def batched_migration_status_badge_class_name(migration) + class_names = { + 'active' => 'badge-info', + 'paused' => 'badge-warning', + 'failed' => 'badge-danger', + 'finished' => 'badge-success' + } + + class_names[migration.status] + end + + # The extra logic here is needed because total_tuple_count is just + # an estimate and completed_rows also does not account for last jobs + # whose batch size is likely larger than the actual number of rows processed + def batched_migration_progress(migration, completed_rows) + return 100 if migration.finished? + return 0 unless completed_rows.to_i > 0 + return unless migration.total_tuple_count.to_i > 0 + + [100 * completed_rows / migration.total_tuple_count, 99].min + end + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 0e3dff27da9..efdad22fa54 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -338,6 +338,8 @@ module ApplicationSettingsHelper :version_check_enabled, :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, + :diff_max_files, + :diff_max_lines, :commit_email_hostname, :protected_ci_variables, :local_markdown_version, @@ -368,7 +370,9 @@ module ApplicationSettingsHelper :container_registry_cleanup_tags_service_max_list_size, :keep_latest_artifact, :whats_new_variant - ] + ].tap do |settings| + settings << :deactivate_dormant_users unless Gitlab.com? + end end def external_authorization_service_attributes diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 439628f40c6..14783882f5e 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -44,7 +44,7 @@ module ClustersHelper base_domain: cluster.base_domain, application_ingress_external_ip: cluster.application_ingress_external_ip, auto_devops_help_path: help_page_path('topics/autodevops/index'), - external_endpoint_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint') + external_endpoint_help_path: help_page_path('user/project/clusters/index.md', anchor: 'base-domain') } end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 9b952ad127e..a7696ba4ea7 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -137,8 +137,6 @@ module CommitsHelper end def cherry_pick_projects_data(project) - return [] unless Feature.enabled?(:pick_into_project, project, default_enabled: :yaml) - [project, project.forked_from_project].compact.map do |project| { id: project.id.to_s, @@ -160,8 +158,8 @@ module CommitsHelper commit.author, ref, { - merge_request: merge_request, - pipeline_status: commit.status_for(ref), + merge_request: merge_request&.cache_key, + pipeline_status: commit.status_for(ref)&.cache_key, xhr: request.xhr?, controller: controller.controller_path, path: @path # referred to in #link_to_browse_code diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index c2f7fa2074c..0092743f96e 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -102,7 +102,7 @@ module DropdownsHelper def dropdown_filter(placeholder, search_id: nil) content_tag :div, class: "dropdown-input" do - filter_output = search_field_tag search_id, nil, data: { qa_selector: "dropdown_input_field" }, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' + filter_output = search_field_tag search_id, nil, data: { qa_selector: "dropdown_input_field" }, id: nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' filter_output << sprite_icon('search', css_class: 'dropdown-input-search') filter_output << sprite_icon('close', size: 16, css_class: 'dropdown-input-clear js-dropdown-input-clear') diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 594c6fedef1..5927c82abe9 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -36,7 +36,7 @@ module EnvironmentsHelper "environment_name": environment.name, "environments_path": api_v4_projects_environments_path(id: project.id), "environment_id": environment.id, - "cluster_applications_documentation_path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack'), + "cluster_applications_documentation_path" => help_page_path('user/clusters/integrations.md', anchor: 'elastic-stack-cluster-integration'), "clusters_path": project_clusters_path(project, format: :json) } end @@ -74,7 +74,6 @@ module EnvironmentsHelper 'metrics_dashboard_base_path' => metrics_dashboard_base_path(environment, project), 'current_environment_name' => environment.name, 'has_metrics' => "#{environment.has_metrics?}", - 'prometheus_status' => "#{environment.prometheus_status}", 'environment_state' => "#{environment.state}" } end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 03c3ee3363d..cd70c6f3962 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -157,7 +157,7 @@ module EventsHelper project_commit_url(event.project, id: event.commit_to) end - else + elsif event.ref_name project_commits_url(event.project, event.ref_name) end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index cf3e99eee49..9b4d0c0b9b3 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -2,19 +2,29 @@ module FormHelper def form_errors(model, type: 'form', truncate: []) - return unless model.errors.any? + errors = model.errors + + return unless errors.any? + + headline = n_( + 'The %{type} contains the following error:', + 'The %{type} contains the following errors:', + errors.count + ) % { type: type } - headline = n_('The %{type} contains the following error:', 'The %{type} contains the following errors:', model.errors.count) % { type: type } truncate = Array.wrap(truncate) - content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do - content_tag(:h4, headline) << - content_tag(:ul) do - messages = model.errors.map do |attribute, message| - message = html_escape_once(model.errors.full_message(attribute, message)).html_safe - message = content_tag(:span, message, class: 'str-truncated-100') if truncate.include?(attribute) + tag.div(class: 'alert alert-danger', id: 'error_explanation') do + tag.h4(headline) << + tag.ul do + messages = errors.map do |error| + attribute = error.attribute + message = error.message + + message = html_escape_once(errors.full_message(attribute, message)).html_safe + message = tag.span(message, class: 'str-truncated-100') if truncate.include?(attribute) - content_tag(:li, message) + tag.li(message) end messages.join.html_safe diff --git a/app/helpers/gitlab_script_tag_helper.rb b/app/helpers/gitlab_script_tag_helper.rb index 467f3f7305b..f784bb69dd8 100644 --- a/app/helpers/gitlab_script_tag_helper.rb +++ b/app/helpers/gitlab_script_tag_helper.rb @@ -21,4 +21,12 @@ module GitlabScriptTagHelper super end + + def preload_link_tag(source, options = {}) + # Chrome requires a nonce, see https://gitlab.com/gitlab-org/gitlab/-/issues/331810#note_584964908 + # It's likely to be a browser bug, but we need to work around it anyway + options[:nonce] = content_security_policy_nonce + + super + end end diff --git a/app/helpers/gitpod_helper.rb b/app/helpers/gitpod_helper.rb index 875a44c51bb..726c852fcdd 100644 --- a/app/helpers/gitpod_helper.rb +++ b/app/helpers/gitpod_helper.rb @@ -2,6 +2,6 @@ module GitpodHelper def gitpod_enable_description - s_('Enable %{linkStart}Gitpod%{linkEnd} integration to launch a development environment in your browser directly from GitLab.') + s_('Users can launch a development environment from a GitLab browser tab when the %{linkStart}Gitpod%{linkEnd} integration is enabled.') end end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 79191616c8f..c4d920dc317 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -13,12 +13,15 @@ module Groups::GroupMembersHelper render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level end - def group_members_list_data_json(group, members, pagination = {}) - group_members_list_data(group, members, pagination).to_json - end - - def group_group_links_list_data_json(group) - group_group_links_list_data(group).to_json + def group_members_app_data_json(group, members:, invited:, access_requests:) + { + user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }), + group: group_group_links_list_data(group), + invite: group_members_list_data(group, invited.nil? ? [] : invited, { param_name: :invited_members_page, params: { page: nil } }), + access_request: group_members_list_data(group, access_requests.nil? ? [] : access_requests), + source_id: group.id, + can_manage_members: can?(current_user, :admin_group_member, group) + }.to_json end private @@ -32,13 +35,11 @@ module Groups::GroupMembersHelper end # Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb` - def group_members_list_data(group, members, pagination) + def group_members_list_data(group, members, pagination = {}) { members: group_members_serialized(group, members), pagination: members_pagination_data(members, pagination), - member_path: group_group_member_path(group, ':id'), - source_id: group.id, - can_manage_members: can?(current_user, :admin_group_member, group) + member_path: group_group_member_path(group, ':id') } end @@ -48,8 +49,7 @@ module Groups::GroupMembersHelper { members: group_group_links_serialized(group_links), pagination: members_pagination_data(group_links), - member_path: group_group_link_path(group, ':id'), - source_id: group.id + member_path: group_group_link_path(group, ':id') } end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 8f647a49a64..26a5df321cd 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -3,14 +3,16 @@ module GroupsHelper def group_overview_nav_link_paths %w[ - groups#show - groups#details groups#activity groups#subgroups ].tap do |paths| - break paths if Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + extra_routes = if sidebar_refactor_disabled? + ['groups#show', 'groups#details'] + else + ['labels#index', 'group_members#index'] + end - paths.concat(['labels#index', 'group_members#index']) + paths.concat(extra_routes) end end @@ -43,7 +45,7 @@ module GroupsHelper end def group_information_title(group) - if Feature.enabled?(:sidebar_refactor, current_user) + if Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) group.subgroup? ? _('Subgroup information') : _('Group information') else group.subgroup? ? _('Subgroup overview') : _('Group overview') @@ -75,6 +77,10 @@ module GroupsHelper can?(current_user, :change_share_with_group_lock, group) end + def can_change_prevent_sharing_groups_outside_hierarchy?(group) + can?(current_user, :change_prevent_sharing_groups_outside_hierarchy, group) + end + def can_disable_group_emails?(group) can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled? end @@ -86,14 +92,6 @@ module GroupsHelper .count end - def group_open_merge_requests_count(group) - if Feature.enabled?(:cached_sidebar_merge_requests_count, group, default_enabled: :yaml) - cached_issuables_count(@group, type: :merge_requests) - else - number_with_delimiter(group_merge_requests_count(state: 'opened')) - end - end - def group_merge_requests_count(state:) MergeRequestsFinder .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true) @@ -194,6 +192,14 @@ module GroupsHelper end end + def link_to_group(group) + link_to(group.name, group_path(group)) + end + + def prevent_sharing_groups_outside_hierarchy_help_text(group) + s_("GroupSettings|This setting is only available on the top-level group and it applies to all subgroups. Groups that have already been shared with a group outside %{group} will still be shared, and this access will have to be revoked manually.").html_safe % { group: link_to_group(group) } + end + def parent_group_options(current_group) exclude_groups = current_group.self_and_descendants.pluck_primary_key exclude_groups << current_group.parent_id if current_group.parent_id diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index d1c84bd4141..b92e418006b 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -14,6 +14,7 @@ module IdeHelper 'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s, 'codesandbox-bundler-url': Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url, 'branch-name' => @branch, + 'default-branch' => @project && @project.default_branch, 'file-path' => @path, 'merge-request' => @merge_request, 'fork-info' => @fork_info&.to_json, diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 889c058cb21..3c290701a5f 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -8,7 +8,7 @@ module InviteMembersHelper end def can_invite_group_for_project?(project) - Feature.enabled?(:invite_members_group_modal, project.group) && project.allowed_to_share_with_group? + Feature.enabled?(:invite_members_group_modal, project.group) && can_manage_project_members?(project) && project.allowed_to_share_with_group? end def directly_invite_members? @@ -21,17 +21,6 @@ module InviteMembersHelper experiment_enabled?(:invite_members_empty_group_version_a) && Ability.allowed?(current_user, :admin_group_member, group) end - def dropdown_invite_members_link(form_model) - link_to invite_members_url(form_model), - data: { - 'track-event': 'click_link', - 'track-label': tracking_label, - 'track-property': experiment_tracking_category_and_group(:invite_members_new_dropdown) - } do - invite_member_link_content - end - end - def invite_accepted_notice(member) case member.source when Project @@ -43,22 +32,11 @@ module InviteMembersHelper end end - private - - def invite_members_url(form_model) - case form_model - when Project - project_project_members_path(form_model) - when Group - group_group_members_path(form_model) + def group_select_data(group) + if group.root_ancestor.namespace_settings.prevent_sharing_groups_outside_hierarchy + { groups_filter: 'descendant_groups', parent_id: group.root_ancestor.id } + else + {} end end - - def invite_member_link_content - text = s_('InviteMember|Invite members') - - return text unless experiment_enabled?(:invite_members_new_dropdown) - - "#{text} #{emoji_icon('shaking_hands', 'aria-hidden': true, class: 'gl-font-base gl-vertical-align-baseline')}".html_safe - end end diff --git a/app/helpers/issuables_description_templates_helper.rb b/app/helpers/issuables_description_templates_helper.rb index 5f69098de56..6cafde65c5c 100644 --- a/app/helpers/issuables_description_templates_helper.rb +++ b/app/helpers/issuables_description_templates_helper.rb @@ -29,17 +29,12 @@ module IssuablesDescriptionTemplatesHelper def issuable_templates(project, issuable_type) @template_types ||= {} @template_types[project.id] ||= {} - @template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names_hash_or_array(project, issuable_type) + @template_types[project.id][issuable_type] ||= TemplateFinder.all_template_names(project, issuable_type.pluralize) end def issuable_templates_names(issuable) all_templates = issuable_templates(ref_project, issuable.to_ability_name) - - if ref_project.inherited_issuable_templates_enabled? - all_templates.values.flatten.map { |tpl| tpl[:name] if tpl[:project_id] == ref_project.id }.compact.uniq - else - all_templates.map { |template| template[:name] } - end + all_templates.values.flatten.map { |tpl| tpl[:name] if tpl[:project_id] == ref_project.id }.compact.uniq end def selected_template(issuable) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index c662dabe453..c40feb42eea 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -220,6 +220,10 @@ module IssuablesHelper @show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project) end + def issuable_project_reference(issuable) + "#{issuable.project.full_name} #{issuable.to_reference}" + end + def issuable_initial_data(issuable) data = { endpoint: issuable_path(issuable), diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 1449725fb2b..91920277c50 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -192,15 +192,15 @@ module IssuesHelper empty_state_svg_path: image_path('illustrations/issues.svg'), endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), export_csv_path: export_csv_project_issues_path(project), - has_issues: project_issues(project).exists?.to_s, + has_project_issues: project_issues(project).exists?.to_s, import_csv_issues_path: import_csv_namespace_project_issues_path, initial_email: project.new_issuable_address(current_user, 'issue'), is_signed_in: current_user.present?.to_s, issues_path: project_issues_path(project), - jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'), + jira_integration_path: help_page_url('integration/jira/', anchor: 'view-jira-issues'), markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), - new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }), + new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }), project_import_jira_path: project_import_jira_path(project), project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json), project_milestones_path: project_milestones_path(project, format: :json), diff --git a/app/helpers/keyset_helper.rb b/app/helpers/keyset_helper.rb new file mode 100644 index 00000000000..e7f6f884091 --- /dev/null +++ b/app/helpers/keyset_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module KeysetHelper + def keyset_paginate(paginator, without_first_and_last_pages: false) + page_params = params.to_unsafe_h + + render('kaminari/gitlab/keyset_paginator', { + paginator: paginator, + without_first_and_last_pages: without_first_and_last_pages, + page_params: page_params + }) + end +end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 514f5fafd65..81059834136 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -80,12 +80,6 @@ module MergeRequestsHelper diffs_project_merge_request_path(project, merge_request, diff_id: merge_request_diff.id, start_sha: start_sha) end - def version_index(merge_request_diff) - return if @merge_request_diffs.empty? - - @merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff) - end - def merge_params(merge_request) { auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS, diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb new file mode 100644 index 00000000000..b952aeacb13 --- /dev/null +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +module Nav + module NewDropdownHelper + def new_dropdown_view_model(group:, project:) + return unless current_user + + menu_sections = [] + + if group&.persisted? + menu_sections.push(group_menu_section(group)) + elsif project&.persisted? + menu_sections.push(project_menu_section(project)) + end + + menu_sections.push(general_menu_section) + + { + title: _("New..."), + menu_sections: menu_sections.select { |x| x.fetch(:menu_items).any? } + } + end + + def new_repo_experiment_text + experiment(:new_repo, user: current_user) do |e| + e.use { _('New project') } + e.try { _('New project/repository') } + end.run + end + + private + + def group_menu_section(group) + menu_items = [] + + if can?(current_user, :create_projects, group) + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'new_project', + title: new_repo_experiment_text, + href: new_project_path(namespace_id: group.id), + data: { track_experiment: 'new_repo', track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } + ) + ) + end + + if can?(current_user, :create_subgroup, group) + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'new_subgroup', + title: _('New subgroup'), + href: new_group_path(parent_id: group.id), + data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } + ) + ) + end + + menu_items.push(create_epic_menu_item(group)) + + if Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can?(current_user, :admin_group_member, group) + menu_items.push( + invite_members_menu_item( + href: group_group_members_path(group) + ) + ) + end + + { + title: _('This group'), + menu_items: menu_items.compact + } + end + + def project_menu_section(project) + menu_items = [] + merge_project = merge_request_source_project_for_project(project) + + if show_new_issue_link?(project) + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'new_issue', + title: _('New issue'), + href: new_project_issue_path(project), + data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' } + ) + ) + end + + if merge_project + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'new_mr', + title: _('New merge request'), + href: project_new_merge_request_path(merge_project), + data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } + ) + ) + end + + if can?(current_user, :create_snippet, project) + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'new_snippet', + title: _('New snippet'), + href: new_project_snippet_path(project), + data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } + ) + ) + end + + if Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can_import_members? + menu_items.push( + invite_members_menu_item( + href: project_project_members_path(project) + ) + ) + end + + { + title: _('This project'), + menu_items: menu_items + } + end + + def general_menu_section + menu_items = [] + + if current_user.can_create_project? + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'general_new_project', + title: new_repo_experiment_text, + href: new_project_path, + data: { track_experiment: 'new_repo', track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' } + ) + ) + end + + if current_user.can_create_group? + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'general_new_group', + title: _('New group'), + href: new_group_path, + data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' } + ) + ) + end + + if current_user.can?(:create_snippet) + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'general_new_snippet', + title: _('New snippet'), + href: new_snippet_path, + data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' } + ) + ) + end + + { + title: _('GitLab'), + menu_items: menu_items + } + end + + def invite_members_menu_item(href:) + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'invite', + title: s_('InviteMember|Invite members'), + emoji: ('shaking_hands' if experiment_enabled?(:invite_members_new_dropdown)), + href: href, + data: { + track_event: 'click_link', + track_label: tracking_label, + track_property: experiment_tracking_category_and_group(:invite_members_new_dropdown) + } + ) + end + + # Overridden in EE + def create_epic_menu_item(group) + nil + end + end +end + +Nav::NewDropdownHelper.prepend_mod diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb index 159b7ca87f9..b8ddb932b73 100644 --- a/app/helpers/nav/top_nav_helper.rb +++ b/app/helpers/nav/top_nav_helper.rb @@ -4,52 +4,90 @@ module Nav module TopNavHelper PROJECTS_VIEW = :projects GROUPS_VIEW = :groups + NEW_VIEW = :new + SEARCH_VIEW = :search def top_nav_view_model(project:, group:) builder = ::Gitlab::Nav::TopNavViewModelBuilder.new - if current_user - build_view_model(builder: builder, project: project, group: group) - else - build_anonymous_view_model(builder: builder) + build_base_view_model(builder: builder, project: project, group: group) + + builder.build + end + + def top_nav_responsive_view_model(project:, group:) + builder = ::Gitlab::Nav::TopNavViewModelBuilder.new + + build_base_view_model(builder: builder, project: project, group: group) + + new_view_model = new_dropdown_view_model(project: project, group: group) + + if new_view_model + builder.add_view(NEW_VIEW, new_view_model) + end + + if top_nav_show_search + builder.add_view(SEARCH_VIEW, ::Gitlab::Nav::TopNavMenuItem.build(**top_nav_search_menu_item_attrs)) end builder.build end + def top_nav_show_search + header_link?(:search) + end + + def top_nav_search_menu_item_attrs + { + id: 'search', + title: _('Search'), + icon: 'search', + href: search_context.search_url + } + end + private + def build_base_view_model(builder:, project:, group:) + if current_user + build_view_model(builder: builder, project: project, group: group) + else + build_anonymous_view_model(builder: builder) + end + end + def build_anonymous_view_model(builder:) # These come from `app/views/layouts/nav/_explore.html.ham` if explore_nav_link?(:projects) - builder.add_primary_menu_item( - **projects_menu_item_attrs.merge( - { - active: active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]), - href: explore_root_path - }) + builder.add_primary_menu_item_with_shortcut( + href: explore_root_path, + active: nav == 'project' || active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]), + **projects_menu_item_attrs ) end if explore_nav_link?(:groups) - builder.add_primary_menu_item( - **groups_menu_item_attrs.merge( - { - active: active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']), - href: explore_groups_path - }) + builder.add_primary_menu_item_with_shortcut( + href: explore_groups_path, + active: nav == 'group' || active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']), + **groups_menu_item_attrs ) end if explore_nav_link?(:snippets) - builder.add_primary_menu_item( - **snippets_menu_item_attrs.merge( - { - active: active_nav_link?(controller: :snippets), - href: explore_snippets_path - }) + builder.add_primary_menu_item_with_shortcut( + active: active_nav_link?(controller: :snippets), + href: explore_snippets_path, + **snippets_menu_item_attrs ) end + + builder.add_secondary_menu_item( + id: 'help', + title: _('Help'), + icon: 'question-o', + href: help_path + ) end def build_view_model(builder:, project:, group:) @@ -57,13 +95,13 @@ module Nav if dashboard_nav_link?(:projects) current_item = project ? current_project(project: project) : {} - builder.add_primary_menu_item( - **projects_menu_item_attrs.merge({ - active: active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), - css_class: 'qa-projects-dropdown', - data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" }, - view: PROJECTS_VIEW - }) + builder.add_primary_menu_item_with_shortcut( + active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), + css_class: 'qa-projects-dropdown', + data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" }, + view: PROJECTS_VIEW, + shortcut_href: dashboard_projects_path, + **projects_menu_item_attrs ) builder.add_view(PROJECTS_VIEW, container_view_props(namespace: 'projects', current_item: current_item, submenu: projects_submenu)) end @@ -71,46 +109,47 @@ module Nav if dashboard_nav_link?(:groups) current_item = group ? current_group(group: group) : {} - builder.add_primary_menu_item( - **groups_menu_item_attrs.merge({ - active: active_nav_link?(path: %w[dashboard/groups explore/groups]), - css_class: 'qa-groups-dropdown', - data: { track_label: "groups_dropdown", track_event: "click_dropdown" }, - view: GROUPS_VIEW - }) + builder.add_primary_menu_item_with_shortcut( + active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]), + css_class: 'qa-groups-dropdown', + data: { track_label: "groups_dropdown", track_event: "click_dropdown" }, + view: GROUPS_VIEW, + shortcut_href: dashboard_groups_path, + **groups_menu_item_attrs ) builder.add_view(GROUPS_VIEW, container_view_props(namespace: 'groups', current_item: current_item, submenu: groups_submenu)) end if dashboard_nav_link?(:milestones) - builder.add_primary_menu_item( + builder.add_primary_menu_item_with_shortcut( id: 'milestones', title: 'Milestones', + href: dashboard_milestones_path, active: active_nav_link?(controller: 'dashboard/milestones'), icon: 'clock', data: { qa_selector: 'milestones_link' }, - href: dashboard_milestones_path + shortcut_class: 'dashboard-shortcuts-milestones' ) end if dashboard_nav_link?(:snippets) - builder.add_primary_menu_item( - **snippets_menu_item_attrs.merge({ - active: active_nav_link?(controller: 'dashboard/snippets'), - data: { qa_selector: 'snippets_link' }, - href: dashboard_snippets_path - }) + builder.add_primary_menu_item_with_shortcut( + active: active_nav_link?(controller: 'dashboard/snippets'), + data: { qa_selector: 'snippets_link' }, + href: dashboard_snippets_path, + **snippets_menu_item_attrs ) end if dashboard_nav_link?(:activity) - builder.add_primary_menu_item( + builder.add_primary_menu_item_with_shortcut( id: 'activity', title: 'Activity', + href: activity_dashboard_path, active: active_nav_link?(path: 'dashboard#activity'), icon: 'history', data: { qa_selector: 'activity_link' }, - href: activity_dashboard_path + shortcut_class: 'dashboard-shortcuts-activity' ) end @@ -137,7 +176,7 @@ module Nav active: active_nav_link?(controller: 'admin/sessions'), icon: 'lock-open', href: destroy_admin_session_path, - method: :post + data: { method: 'post' } ) elsif current_user.admin? builder.add_secondary_menu_item( @@ -165,7 +204,8 @@ module Nav { id: 'project', title: _('Projects'), - icon: 'project' + icon: 'project', + shortcut_class: 'dashboard-shortcuts-projects' } end @@ -173,7 +213,8 @@ module Nav { id: 'groups', title: 'Groups', - icon: 'group' + icon: 'group', + shortcut_class: 'dashboard-shortcuts-groups' } end @@ -181,7 +222,8 @@ module Nav { id: 'snippets', title: _('Snippets'), - icon: 'snippet' + icon: 'snippet', + shortcut_class: 'dashboard-shortcuts-snippets' } end @@ -234,7 +276,11 @@ module Nav builder = ::Gitlab::Nav::TopNavMenuBuilder.new builder.add_primary_menu_item(id: 'your', title: _('Your groups'), href: dashboard_groups_path) builder.add_primary_menu_item(id: 'explore', title: _('Explore groups'), href: explore_groups_path) - builder.add_secondary_menu_item(id: 'create', title: _('Create group'), href: new_group_path(anchor: 'create-group-pane')) + + if current_user.can_create_group? + builder.add_secondary_menu_item(id: 'create', title: _('Create group'), href: new_group_path) + end + builder.build end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index aab1a44bdfb..b5171dfbebd 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -12,7 +12,6 @@ module NavHelper def page_with_sidebar_class class_name = page_gutter_class class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar - class_name << 'sidebar-refactoring' if Feature.enabled?(:sidebar_refactor, current_user) class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @left_sidebar class_name -= ['right-sidebar-expanded'] if defined?(@right_sidebar) && !@right_sidebar @@ -61,7 +60,7 @@ module NavHelper end def admin_monitoring_nav_links - %w(system_info background_jobs health_check requests_profiles) + %w(system_info background_migrations background_jobs health_check requests_profiles) end def admin_analytics_nav_links diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index 38c98776fdf..c0ba93f4a30 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -9,29 +9,15 @@ module NotifyHelper link_to(entity.to_reference(full: full), issue_url(entity, *args)) end - def invited_role_description(role_name) - case role_name - when "Guest" - s_("InviteEmail|As a guest, you can view projects, leave comments, and create issues.") - when "Reporter" - s_("InviteEmail|As a reporter, you can view projects and reports, and leave comments on issues.") - when "Developer" - s_("InviteEmail|As a developer, you have full access to projects, so you can take an idea from concept to production.") - when "Maintainer" - s_("InviteEmail|As a maintainer, you have full access to projects. You can push commits to the default branch and deploy to production.") - when "Owner" - s_("InviteEmail|As an owner, you have full access to projects and can manage access to the group, including inviting new members.") - when "Minimal Access" - s_("InviteEmail|As a user with minimal access, you can view the high-level group from the UI and API.") - end - end - def invited_to_description(source) - case source - when "project" - s_('InviteEmail|Projects can be used to host your code, track issues, collaborate on code, and continuously build, test, and deploy your app with built-in GitLab CI/CD.') - when "group" - s_('InviteEmail|Groups assemble related projects together and grant members access to several projects at once.') - end + default_description = + case source + when Project + s_('InviteEmail|Projects are used to host and collaborate on code, track issues, and continuously build, test, and deploy your app with built-in GitLab CI/CD.') + when Group + s_('InviteEmail|Groups assemble related projects together and grant members access to several projects at once.') + end + + (source.description || default_description).truncate(200, separator: ' ') end end diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index df07baa2d03..fb410c46128 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -10,6 +10,9 @@ module OperationsHelper end def alerts_settings_data(disabled: false) + setting = project_incident_management_setting + templates = setting.available_issue_templates.map { |t| { key: t.key, name: t.name } } + { 'prometheus_activated' => prometheus_service.manual_configuration?.to_s, 'prometheus_form_path' => scoped_integration_path(prometheus_service), @@ -21,21 +24,22 @@ module OperationsHelper 'alerts_usage_url' => project_alert_management_index_path(@project), 'disabled' => disabled.to_s, 'project_path' => @project.full_path, - 'multi_integrations' => 'false' + 'multi_integrations' => 'false', + 'templates' => templates.to_json, + 'create_issue' => setting.create_issue.to_s, + 'issue_template_key' => setting.issue_template_key.to_s, + 'send_email' => setting.send_email.to_s, + 'auto_close_incident' => setting.auto_close_incident.to_s, + 'pagerduty_reset_key_path' => reset_pagerduty_token_project_settings_operations_path(@project), + 'operations_settings_endpoint' => project_settings_operations_path(@project) } end def operations_settings_data setting = project_incident_management_setting - templates = setting.available_issue_templates.map { |t| { key: t.key, name: t.name } } { operations_settings_endpoint: project_settings_operations_path(@project), - templates: templates.to_json, - create_issue: setting.create_issue.to_s, - issue_template_key: setting.issue_template_key.to_s, - send_email: setting.send_email.to_s, - auto_close_incident: setting.auto_close_incident.to_s, pagerduty_active: setting.pagerduty_active.to_s, pagerduty_token: setting.pagerduty_token.to_s, pagerduty_webhook_url: project_incidents_integrations_pagerduty_url(@project, token: setting.pagerduty_token), diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 04465f7798c..fe41c041b4f 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -53,4 +53,14 @@ module PackagesHelper category = args.delete(:category) || self.class.name ::Gitlab::Tracking.event(category, event_name.to_s, **args) end + + def show_cleanup_policy_on_alert(project) + Gitlab.com? && + Gitlab.config.registry.enabled && + project.container_registry_enabled && + !Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries && + Feature.enabled?(:container_expiration_policies_historic_entry, project) && + project.container_expiration_policy.nil? && + project.container_repositories.exists? + end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index d851ed3db8f..76f2ee0981b 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -66,6 +66,10 @@ module PreferencesHelper @user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename end + def user_theme_primary_color + Gitlab::Themes.for_user(current_user).primary_color + end + def user_color_scheme Gitlab::ColorSchemes.for_user(current_user).css_class end @@ -83,13 +87,17 @@ module PreferencesHelper def integration_views [].tap do |views| - views << { name: 'gitpod', message: gitpod_enable_description, message_url: 'https://gitpod.io/', help_link: help_page_path('integration/gitpod.md') } if Gitlab::CurrentSettings.gitpod_enabled + views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod.md') } if Gitlab::CurrentSettings.gitpod_enabled views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences.md', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled end end private + def gitpod_url_placeholder + Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/' + end + # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too def validate_dashboard_choices!(user_dashboards) if user_dashboards.size != localized_dashboard_choices.size diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb index fa68bbad135..0871d5638b8 100644 --- a/app/helpers/projects/project_members_helper.rb +++ b/app/helpers/projects/project_members_helper.rb @@ -27,12 +27,15 @@ module Projects::ProjectMembersHelper project.group.has_owner?(current_user) end - def project_members_list_data_json(project, members, pagination = {}) - project_members_list_data(project, members, pagination).to_json - end - - def project_group_links_list_data_json(project, group_links) - project_group_links_list_data(project, group_links).to_json + def project_members_app_data_json(project, members:, group_links:, invited:, access_requests:) + { + user: project_members_list_data(project, members, { param_name: :page, params: { search_groups: nil } }), + group: project_group_links_list_data(project, group_links), + invite: project_members_list_data(project, invited.nil? ? [] : invited), + access_request: project_members_list_data(project, access_requests.nil? ? [] : access_requests), + source_id: project.id, + can_manage_members: can_manage_project_members?(project) + }.to_json end private @@ -45,13 +48,11 @@ module Projects::ProjectMembersHelper GroupLink::ProjectGroupLinkSerializer.new.represent(group_links, { current_user: current_user }) end - def project_members_list_data(project, members, pagination) + def project_members_list_data(project, members, pagination = {}) { members: project_members_serialized(project, members), pagination: members_pagination_data(members, pagination), - member_path: project_project_member_path(project, ':id'), - source_id: project.id, - can_manage_members: can_manage_project_members?(project) + member_path: project_project_member_path(project, ':id') } end @@ -59,9 +60,7 @@ module Projects::ProjectMembersHelper { members: project_group_links_serialized(group_links), pagination: members_pagination_data(group_links), - member_path: project_group_link_path(project, ':id'), - source_id: project.id, - can_manage_members: can_manage_project_members?(project) + member_path: project_group_link_path(project, ':id') } end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index f2a50ce1325..8800bd0643c 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -612,12 +612,12 @@ module ProjectsHelper end def settings_container_registry_expiration_policy_available?(project) - Feature.disabled?(:sidebar_refactor, current_user) && + Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) && can_destroy_container_registry_image?(current_user, project) end def settings_packages_and_registries_enabled?(project) - Feature.enabled?(:sidebar_refactor, current_user) && + Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) && can_destroy_container_registry_image?(current_user, project) end diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb index 79f0a66f995..24131e32c6c 100644 --- a/app/helpers/registrations_helper.rb +++ b/app/helpers/registrations_helper.rb @@ -7,6 +7,16 @@ module RegistrationsHelper devise_mapping.omniauthable? && button_based_providers_enabled? end + + def signup_username_data_attributes + { + min_length: User::MIN_USERNAME_LENGTH, + min_length_message: s_('SignUp|Username is too short (minimum is %{min_length} characters).') % { min_length: User::MIN_USERNAME_LENGTH }, + max_length: User::MAX_USERNAME_LENGTH, + max_length_message: s_('SignUp|Username is too long (maximum is %{max_length} characters).') % { max_length: User::MAX_USERNAME_LENGTH }, + qa_selector: 'new_user_username_field' + } + end end RegistrationsHelper.prepend_mod_with('RegistrationsHelper') diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1f4c98d6f28..e07ee22339a 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -9,7 +9,8 @@ module SearchHelper :repository_ref, :snippets, :sort, - :force_search_results + :force_search_results, + :project_ids ].freeze def search_autocomplete_opts(term) diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 3d3ab3a6972..83000189ab3 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -107,7 +107,7 @@ module ServicesHelper reset_path: scoped_reset_integration_path(integration, group: group) } - if integration.is_a?(JiraService) + if integration.is_a?(Integrations::Jira) form_data[:jira_issue_transition_automatic] = integration.jira_issue_transition_automatic form_data[:jira_issue_transition_id] = integration.jira_issue_transition_id end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 0fc306a3f2e..39ad8ed8a0f 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -20,6 +20,14 @@ module SidebarsHelper Sidebars::Projects::Context.new(**context_data) end + def sidebar_refactor_enabled? + Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + end + + def sidebar_refactor_disabled? + !sidebar_refactor_enabled? + end + private def sidebar_project_tracking_attrs diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 1d3242ca44a..e64e1c935dd 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -123,7 +123,21 @@ module TabHelper def route_matches_pages?(pages) Array(pages).compact.any? do |single_page| - current_page?(single_page) + # We need to distinguish between Hash argument and other types of + # arguments (for example String) in order to fix deprecation kwargs + # warning. + # + # This can be refactored to + # + # current_page?(single_page) + # + # When we migrate to Ruby 3 or the Rails version contains the following: + # https://github.com/rails/rails/commit/81d90d81d0ee1fc1a649ab705119a71f2d04c8a2 + if single_page.is_a?(Hash) + current_page?(**single_page) + else + current_page?(single_page) + end end end diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index bfc8803f514..86289ec8ed2 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -15,16 +15,6 @@ module TagsHelper project_tags_path(@project, @id, options) end - def tag_list(project) - html = [] - - project.tag_list.each do |tag| - html << link_to(tag, tag_path(tag)) - end - - html.join.html_safe - end - def protected_tag?(project, tag) ProtectedTag.protected?(project, tag.name) end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 23db3be631c..c44da915105 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -1,22 +1,16 @@ # frozen_string_literal: true module UserCalloutsHelper - ADMIN_INTEGRATIONS_MOVED = 'admin_integrations_moved' GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' GCP_SIGNUP_OFFER = 'gcp_signup_offer' SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' SERVICE_TEMPLATES_DEPRECATED_CALLOUT = 'service_templates_deprecated_callout' TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' - WEBHOOKS_MOVED = 'webhooks_moved' CUSTOMIZE_HOMEPAGE = 'customize_homepage' FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout' UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' - def show_admin_integrations_moved? - !user_dismissed?(ADMIN_INTEGRATIONS_MOVED) - end - def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && can?(current_user, :create_cluster, project) && @@ -48,10 +42,6 @@ module UserCalloutsHelper !user_dismissed?(SERVICE_TEMPLATES_DEPRECATED_CALLOUT) end - def show_webhooks_moved_alert? - !user_dismissed?(WEBHOOKS_MOVED) - end - def show_customize_homepage_banner? !user_dismissed?(CUSTOMIZE_HOMEPAGE) end diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index 6f94c241914..f8d7264d4cc 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -19,7 +19,7 @@ module VersionCheckHelper end def source_host_url - Gitlab::COM_URL + Gitlab::Saas.com_url end def source_code_group diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb index 97243660512..e745cd51a55 100644 --- a/app/mailers/emails/in_product_marketing.rb +++ b/app/mailers/emails/in_product_marketing.rb @@ -14,8 +14,9 @@ module Emails def in_product_marketing_email(recipient_id, group_id, track, series) group = Group.find(group_id) - email = User.find(recipient_id).notification_email_for(group) - @message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, series: series) + user = User.find(recipient_id) + email = user.notification_email_for(group) + @message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, user: user, series: series) mail_to(to: email, subject: @message.subject_line) end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 674a9bfc4eb..d1870065845 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -53,18 +53,10 @@ module Emails return unless member_exists? - subject_line = - if member.created_by - subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name }) - else - subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular }) - end - - member_email_with_layout( - to: member.invite_email, - subject: subject_line, - layout: 'unknown_user_mailer' - ) + mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format| + format.html { render layout: 'unknown_user_mailer' } + format.text { render layout: 'unknown_user_mailer' } + end end def member_invited_reminder_email(member_source_type, member_id, token, reminder_index) @@ -149,6 +141,25 @@ module Emails private + def invite_email_subject + if member.created_by + subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name }) + else + subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular }) + end + end + + def invite_email_headers + if Gitlab.dev_env_or_com? + { + 'X-Mailgun-Tag' => 'invite_email', + 'X-Mailgun-Variables' => { 'invite_token' => @token }.to_json + } + else + {} + end + end + def member_exists? Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank? member.present? diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index 66eb2c646a9..e8034ef9b57 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -20,7 +20,9 @@ module Emails options = service_desk_options(email_sender, 'thank_you', @issue.external_author) .merge(subject: "Re: #{subject_base}") - mail_new_thread(@issue, options) + mail_new_thread(@issue, options).tap do + Gitlab::Metrics::BackgroundTransaction.current&.add_event(:service_desk_thank_you_email) + end end def service_desk_new_note_email(issue_id, note_id, recipient) @@ -31,7 +33,9 @@ module Emails options = service_desk_options(email_sender, 'new_note', recipient) .merge(subject: subject_base) - mail_answer_thread(@issue, options) + mail_answer_thread(@issue, options).tap do + Gitlab::Metrics::BackgroundTransaction.current&.add_event(:service_desk_new_note_email) + end end private diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index df0d1774d6b..ceeb178e9c2 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -146,7 +146,7 @@ class NotifyPreview < ActionMailer::Preview end def member_invited_email - Notify.member_invited_email('project', user.id, '1234').message + Notify.member_invited_email('project', member.id, '1234').message end def pages_domain_enabled_email diff --git a/app/models/analytics/cycle_analytics/project_level.rb b/app/models/analytics/cycle_analytics/project_level.rb new file mode 100644 index 00000000000..7a73bc75ed6 --- /dev/null +++ b/app/models/analytics/cycle_analytics/project_level.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ProjectLevel + attr_reader :project, :options + + def initialize(project:, options:) + @project = project + @options = options.merge(project: project) + end + + def summary + @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(project, + options: options, + current_user: options[:current_user]).data + end + + def permissions(user:) + Gitlab::CycleAnalytics::Permissions.get(user: user, project: project) + end + + def stats + @stats ||= default_stage_names.map do |stage_name| + self[stage_name].as_json + end + end + + def [](stage_name) + ::CycleAnalytics::ProjectLevelStageAdapter.new(build_stage(stage_name), options) + end + + private + + def build_stage(stage_name) + stage_params = stage_params_by_name(stage_name).merge(project: project) + Analytics::CycleAnalytics::ProjectStage.new(stage_params) + end + + def stage_params_by_name(name) + Gitlab::Analytics::CycleAnalytics::DefaultStages.find_by_name!(name) + end + + def default_stage_names + Gitlab::Analytics::CycleAnalytics::DefaultStages.symbolized_stage_names + end + end + end +end diff --git a/app/models/analytics/usage_trends/measurement.rb b/app/models/analytics/usage_trends/measurement.rb index 46c5d56d210..02e239ca0ef 100644 --- a/app/models/analytics/usage_trends/measurement.rb +++ b/app/models/analytics/usage_trends/measurement.rb @@ -3,7 +3,7 @@ module Analytics module UsageTrends class Measurement < ApplicationRecord - self.table_name = 'analytics_instance_statistics_measurements' + self.table_name = 'analytics_usage_trends_measurements' enum identifier: { projects: 1, diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 5e5bc00458e..a93348a3b27 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -53,10 +53,12 @@ class ApplicationRecord < ActiveRecord::Base # Start a new transaction with a shorter-than-usual statement timeout. This is # currently one third of the default 15-second timeout def self.with_fast_read_statement_timeout(timeout_ms = 5000) - transaction(requires_new: true) do - connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}") + ::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do + transaction(requires_new: true) do + connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}") - yield + yield + end end end @@ -85,5 +87,3 @@ class ApplicationRecord < ActiveRecord::Base enum(enum_mod.key => values) end end - -ApplicationRecord.prepend_mod_with('ApplicationRecordHelpers') diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 65800e40d6c..f8047ed9b78 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -273,6 +273,18 @@ class ApplicationSetting < ApplicationRecord greater_than_or_equal_to: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, less_than_or_equal_to: Gitlab::Git::Diff::MAX_PATCH_BYTES_UPPER_BOUND } + validates :diff_max_files, + presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_FILES_SETTING, + less_than_or_equal_to: Commit::MAX_DIFF_FILES_SETTING_UPPER_BOUND } + + validates :diff_max_lines, + presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: Commit::DEFAULT_MAX_DIFF_LINES_SETTING, + less_than_or_equal_to: Commit::MAX_DIFF_LINES_SETTING_UPPER_BOUND } + validates :user_default_internal_regex, js_regex: true, allow_nil: true validates :personal_access_token_prefix, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index bf9df3b9efc..b613e698471 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -60,6 +60,8 @@ module ApplicationSettingImplementation default_projects_limit: Settings.gitlab['default_projects_limit'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, + diff_max_files: Commit::DEFAULT_MAX_DIFF_FILES_SETTING, + diff_max_lines: Commit::DEFAULT_MAX_DIFF_LINES_SETTING, disable_feed_token: false, disabled_oauth_sign_in_sources: [], dns_rebinding_protection_enabled: true, diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb index 59ca4dbfec6..371b58dea03 100644 --- a/app/models/bulk_imports/export.rb +++ b/app/models/bulk_imports/export.rb @@ -4,6 +4,10 @@ module BulkImports class Export < ApplicationRecord include Gitlab::Utils::StrongMemoize + STARTED = 0 + FINISHED = 1 + FAILED = -1 + self.table_name = 'bulk_import_exports' belongs_to :project, optional: true @@ -18,9 +22,9 @@ module BulkImports validate :portable_relation? state_machine :status, initial: :started do - state :started, value: 0 - state :finished, value: 1 - state :failed, value: -1 + state :started, value: STARTED + state :finished, value: FINISHED + state :failed, value: FAILED event :start do transition any => :started diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb new file mode 100644 index 00000000000..98804d18f27 --- /dev/null +++ b/app/models/bulk_imports/export_status.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module BulkImports + class ExportStatus + include Gitlab::Utils::StrongMemoize + + def initialize(pipeline_tracker, relation) + @pipeline_tracker = pipeline_tracker + @relation = relation + @entity = @pipeline_tracker.entity + @configuration = @entity.bulk_import.configuration + @client = Clients::HTTP.new(uri: @configuration.url, token: @configuration.access_token) + end + + def started? + export_status['status'] == Export::STARTED + end + + def failed? + export_status['status'] == Export::FAILED + end + + def error + export_status['error'] + end + + private + + attr_reader :client, :entity, :relation + + def export_status + strong_memoize(:export_status) do + fetch_export_status.find { |item| item['relation'] == relation } + end + rescue StandardError => e + { 'status' => Export::FAILED, 'error' => e.message } + end + + def fetch_export_status + client.get(status_endpoint).parsed_response + end + + def status_endpoint + "/groups/#{entity.encoded_source_full_path}/export_relations/status" + end + end +end diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb index bb04e84ad72..7396f9d3655 100644 --- a/app/models/bulk_imports/file_transfer/base_config.rb +++ b/app/models/bulk_imports/file_transfer/base_config.rb @@ -13,6 +13,14 @@ module BulkImports attributes_finder.find_root(portable_class_sym) end + def top_relation_tree(relation) + portable_relations_tree[relation.to_s] + end + + def relation_excluded_keys(relation) + attributes_finder.find_excluded_keys(relation) + end + def export_path strong_memoize(:export_path) do relative_path = File.join(base_export_path, SecureRandom.hex) @@ -47,6 +55,10 @@ module BulkImports @portable_class_sym ||= portable_class.to_s.demodulize.underscore.to_sym end + def portable_relations_tree + @portable_relations_tree ||= attributes_finder.find_relations_tree(portable_class_sym).deep_stringify_keys + end + def import_export_yaml raise NotImplementedError end diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb index 6e39d7e2204..ee786ae6cb7 100644 --- a/app/models/chat_team.rb +++ b/app/models/chat_team.rb @@ -7,8 +7,8 @@ class ChatTeam < ApplicationRecord belongs_to :namespace def remove_mattermost_team(current_user) - Mattermost::Team.new(current_user).destroy(team_id: team_id) - rescue Mattermost::ClientError => e + ::Mattermost::Team.new(current_user).destroy(team_id: team_id) + rescue ::Mattermost::ClientError => e # Either the group is not found, or the user doesn't have the proper # access on the mattermost instance. In the first case, we're done either way # in the latter case, we can't recover by retrying, so we just log what happened diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 352229c64da..577bca282ef 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -163,7 +163,7 @@ module Ci def expanded_environment_name end - def instantized_environment + def persisted_environment end def execute_hooks diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 46fc87a6ea8..fdfffd9b0cd 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -11,6 +11,7 @@ module Ci include Importable include Ci::HasRef include IgnorableColumns + include TaggableQueries BuildArchivedError = Class.new(StandardError) @@ -37,6 +38,8 @@ module Ci has_one :deployment, as: :deployable, class_name: 'Deployment' has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build + has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id + has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build has_many :report_results, class_name: 'Ci::BuildReportResult', inverse_of: :build @@ -88,16 +91,6 @@ module Ci end end - # Initializing an object instead of fetching `persisted_environment` for avoiding unnecessary queries. - # We're planning to introduce a direct relationship between build and environment - # in https://gitlab.com/gitlab-org/gitlab/-/issues/326445 to let us to preload - # in batch. - def instantized_environment - return unless has_environment? - - ::Environment.new(project: self.project, name: self.expanded_environment_name) - end - serialize :options # rubocop:disable Cop/ActiveRecordSerialize serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize @@ -212,6 +205,8 @@ module Ci end scope :with_coverage, -> { where.not(coverage: nil) } + scope :without_coverage, -> { where(coverage: nil) } + scope :with_coverage_regex, -> { where.not(coverage_regex: nil) } scope :for_project, -> (project_id) { where(project_id: project_id) } @@ -222,6 +217,8 @@ module Ci before_save :ensure_token before_destroy { unscoped_project } + after_save :stick_build_if_status_changed + after_create unless: :importing? do |build| run_after_commit { BuildHooksWorker.perform_async(build.id) } end @@ -304,12 +301,35 @@ module Ci end end - after_transition any => [:pending] do |build| + # rubocop:disable CodeReuse/ServiceClass + after_transition any => [:pending] do |build, transition| + Ci::UpdateBuildQueueService.new.push(build, transition) + build.run_after_commit do BuildQueueWorker.perform_async(id) end end + after_transition pending: any do |build, transition| + Ci::UpdateBuildQueueService.new.pop(build, transition) + end + + after_transition any => [:running] do |build, transition| + Ci::UpdateBuildQueueService.new.track(build, transition) + end + + after_transition running: any do |build, transition| + Ci::UpdateBuildQueueService.new.untrack(build, transition) + + Ci::BuildRunnerSession.where(build: build).delete_all + end + + # rubocop:enable CodeReuse/ServiceClass + # + after_transition pending: :running do |build| + build.ensure_metadata.update_timeout_state + end + after_transition pending: :running do |build| build.deployment&.run @@ -362,14 +382,6 @@ module Ci end end - after_transition pending: :running do |build| - build.ensure_metadata.update_timeout_state - end - - after_transition running: any do |build| - Ci::BuildRunnerSession.where(build: build).delete_all - end - after_transition any => [:skipped, :canceled] do |build, transition| if transition.to_name == :skipped build.deployment&.skip @@ -379,6 +391,33 @@ module Ci end end + def self.build_matchers(project) + unique_params = [ + :protected, + Arel.sql("(#{arel_tag_names_array.to_sql})") + ] + + group(*unique_params).pluck('array_agg(id)', *unique_params).map do |values| + Gitlab::Ci::Matching::BuildMatcher.new({ + build_ids: values[0], + protected: values[1], + tag_list: values[2], + project: project + }) + end + end + + def build_matcher + strong_memoize(:build_matcher) do + Gitlab::Ci::Matching::BuildMatcher.new({ + protected: protected?, + tag_list: tag_list, + build_ids: [id], + project: project + }) + end + end + def auto_retry_allowed? auto_retry.allowed? end @@ -442,7 +481,13 @@ module Ci end def retryable? - !archived? && (success? || failed? || canceled?) + if Feature.enabled?(:prevent_retry_of_retried_jobs, project, default_enabled: :yaml) + return false if retried? || archived? + + success? || failed? || canceled? + else + !archived? && (success? || failed? || canceled?) + end end def retries_count @@ -560,6 +605,8 @@ module Ci variables.concat(persisted_environment.predefined_variables) + variables.append(key: 'CI_ENVIRONMENT_ACTION', value: environment_action) + # Here we're passing unexpanded environment_url for runner to expand, # and we need to make sure that CI_ENVIRONMENT_NAME and # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded. @@ -716,22 +763,14 @@ module Ci end def any_runners_online? - if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml) - cache_for_online_runners do - project.any_online_runners? { |runner| runner.match_build_if_online?(self) } - end - else - project.any_active_runners? { |runner| runner.match_build_if_online?(self) } + cache_for_online_runners do + project.any_online_runners? { |runner| runner.match_build_if_online?(self) } end end def any_runners_available? - if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml) - cache_for_available_runners do - project.active_runners.exists? - end - else - project.any_active_runners? + cache_for_available_runners do + project.active_runners.exists? end end @@ -1039,6 +1078,28 @@ module Ci options.dig(:allow_failure_criteria, :exit_codes).present? end + def create_queuing_entry! + ::Ci::PendingBuild.upsert_from_build!(self) + end + + ## + # We can have only one queuing entry or running build tracking entry, + # because there is a unique index on `build_id` in each table, but we need + # a relation to remove these entries more efficiently in a single statement + # without actually loading data. + # + def all_queuing_entries + ::Ci::PendingBuild.where(build_id: self.id) + end + + def all_runtime_metadata + ::Ci::RunningBuild.where(build_id: self.id) + end + + def shared_runner_build? + runner&.instance_type? + end + protected def run_status_commit_hooks! @@ -1049,6 +1110,13 @@ module Ci private + def stick_build_if_status_changed + return unless saved_change_to_status? + return unless running? + + ::Gitlab::Database::LoadBalancing::Sticking.stick(:build, id) + end + def status_commit_hooks @status_commit_hooks ||= [] end diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index 716d919487d..d39e0411a79 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -143,8 +143,6 @@ module Ci def specified_cross_pipeline_dependencies strong_memoize(:specified_cross_pipeline_dependencies) do - next [] unless Feature.enabled?(:ci_cross_pipeline_artifacts_download, processable.project, default_enabled: true) - specified_cross_dependencies.select { |dep| dep[:pipeline] && dep[:artifacts] } end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 4094bdb26dc..bb2dac5cd43 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -10,6 +10,7 @@ module Ci include Presentable include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize + include IgnorableColumns self.table_name = 'ci_builds_metadata' @@ -21,8 +22,8 @@ module Ci validates :build, presence: true validates :secrets, json_schema: { filename: 'build_metadata_secrets' } - serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize - serialize :config_variables, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize + serialize :config_options, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize + serialize :config_variables, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize chronic_duration_attr_reader :timeout_human_readable, :timeout @@ -37,6 +38,8 @@ module Ci job_timeout_source: 4 } + ignore_column :build_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' + def update_timeout_state timeout = timeout_with_highest_precedence diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 719511bbb8a..25f4a06088d 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -14,7 +14,13 @@ module Ci belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id - default_value_for :data_store, :redis + default_value_for :data_store do + if Feature.enabled?(:dedicated_redis_trace_chunks, type: :ops) + :redis_trace_chunks + else + :redis + end + end after_create { metrics.increment_trace_operation(operation: :chunked) } @@ -25,22 +31,22 @@ module Ci FailedToPersistDataError = Class.new(StandardError) - # Note: The ordering of this hash is related to the precedence of persist store. - # The bottom item takes the highest precedence, and the top item takes the lowest precedence. DATA_STORES = { redis: 1, database: 2, - fog: 3 + fog: 3, + redis_trace_chunks: 4 }.freeze STORE_TYPES = DATA_STORES.keys.to_h do |store| - [store, "Ci::BuildTraceChunks::#{store.capitalize}".constantize] + [store, "Ci::BuildTraceChunks::#{store.to_s.camelize}".constantize] end.freeze + LIVE_STORES = %i[redis redis_trace_chunks].freeze enum data_store: DATA_STORES - scope :live, -> { redis } - scope :persisted, -> { not_redis.order(:chunk_index) } + scope :live, -> { where(data_store: LIVE_STORES) } + scope :persisted, -> { where.not(data_store: LIVE_STORES).order(:chunk_index) } class << self def all_stores @@ -48,8 +54,7 @@ module Ci end def persistable_store - # get first available store from the back of the list - all_stores.reverse.find { |store| get_store_class(store).available? } + STORE_TYPES[:fog].available? ? :fog : :database end def get_store_class(store) @@ -85,16 +90,10 @@ module Ci # change the behavior in CE. # def with_read_consistency(build, &block) - return yield unless consistent_reads_enabled?(build) - ::Gitlab::Database::Consistency .with_read_consistency(&block) end - def consistent_reads_enabled?(build) - Feature.enabled?(:gitlab_ci_trace_read_consistency, build.project, type: :development, default_enabled: true) - end - ## # Sometimes we do not want to read raw data. This method makes it easier # to find attributes that are just metadata excluding raw data. @@ -201,7 +200,7 @@ module Ci end def flushed? - !redis? + !live? end def migrated? @@ -209,7 +208,7 @@ module Ci end def live? - redis? + LIVE_STORES.include?(data_store.to_sym) end def <=>(other) diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb index 7448afba4c2..895028778a9 100644 --- a/app/models/ci/build_trace_chunks/database.rb +++ b/app/models/ci/build_trace_chunks/database.rb @@ -3,10 +3,6 @@ module Ci module BuildTraceChunks class Database - def available? - true - end - def keys(relation) [] end diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb index cbf0c0a1696..fab85fae33d 100644 --- a/app/models/ci/build_trace_chunks/fog.rb +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -3,10 +3,18 @@ module Ci module BuildTraceChunks class Fog - def available? + def self.available? object_store.enabled end + def self.object_store + Gitlab.config.artifacts.object_store + end + + def available? + self.class.available? + end + def data(model) files.get(key(model))&.body rescue Excon::Error::NotFound @@ -85,7 +93,7 @@ module Ci end def object_store - Gitlab.config.artifacts.object_store + self.class.object_store end def object_store_raw_config diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb index 003ec107895..46f275636e1 100644 --- a/app/models/ci/build_trace_chunks/redis.rb +++ b/app/models/ci/build_trace_chunks/redis.rb @@ -2,92 +2,11 @@ module Ci module BuildTraceChunks - class Redis - CHUNK_REDIS_TTL = 1.week - LUA_APPEND_CHUNK = <<~EOS - local key, new_data, offset = KEYS[1], ARGV[1], ARGV[2] - local length = new_data:len() - local expire = #{CHUNK_REDIS_TTL.seconds} - local current_size = redis.call("strlen", key) - offset = tonumber(offset) - - if offset == 0 then - -- overwrite everything - redis.call("set", key, new_data, "ex", expire) - return redis.call("strlen", key) - elseif offset > current_size then - -- offset range violation - return -1 - elseif offset + length >= current_size then - -- efficiently append or overwrite and append - redis.call("expire", key, expire) - return redis.call("setrange", key, offset, new_data) - else - -- append and truncate - local current_data = redis.call("get", key) - new_data = current_data:sub(1, offset) .. new_data - redis.call("set", key, new_data, "ex", expire) - return redis.call("strlen", key) - end - EOS - - def available? - true - end - - def data(model) - Gitlab::Redis::SharedState.with do |redis| - redis.get(key(model)) - end - end - - def set_data(model, new_data) - Gitlab::Redis::SharedState.with do |redis| - redis.set(key(model), new_data, ex: CHUNK_REDIS_TTL) - end - end - - def append_data(model, new_data, offset) - Gitlab::Redis::SharedState.with do |redis| - redis.eval(LUA_APPEND_CHUNK, keys: [key(model)], argv: [new_data, offset]) - end - end - - def size(model) - Gitlab::Redis::SharedState.with do |redis| - redis.strlen(key(model)) - end - end - - def delete_data(model) - delete_keys([[model.build_id, model.chunk_index]]) - end - - def keys(relation) - relation.pluck(:build_id, :chunk_index) - end - - def delete_keys(keys) - return if keys.empty? - - keys = keys.map { |key| key_raw(*key) } - - Gitlab::Redis::SharedState.with do |redis| - # https://gitlab.com/gitlab-org/gitlab/-/issues/224171 - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.del(keys) - end - end - end - + class Redis < RedisBase private - def key(model) - key_raw(model.build_id, model.chunk_index) - end - - def key_raw(build_id, chunk_index) - "gitlab:ci:trace:#{build_id.to_i}:chunks:#{chunk_index.to_i}" + def with_redis + Gitlab::Redis::SharedState.with { |redis| yield(redis) } end end end diff --git a/app/models/ci/build_trace_chunks/redis_base.rb b/app/models/ci/build_trace_chunks/redis_base.rb new file mode 100644 index 00000000000..3b7a844d122 --- /dev/null +++ b/app/models/ci/build_trace_chunks/redis_base.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Ci + module BuildTraceChunks + class RedisBase + CHUNK_REDIS_TTL = 1.week + LUA_APPEND_CHUNK = <<~EOS + local key, new_data, offset = KEYS[1], ARGV[1], ARGV[2] + local length = new_data:len() + local expire = #{CHUNK_REDIS_TTL.seconds} + local current_size = redis.call("strlen", key) + offset = tonumber(offset) + + if offset == 0 then + -- overwrite everything + redis.call("set", key, new_data, "ex", expire) + return redis.call("strlen", key) + elseif offset > current_size then + -- offset range violation + return -1 + elseif offset + length >= current_size then + -- efficiently append or overwrite and append + redis.call("expire", key, expire) + return redis.call("setrange", key, offset, new_data) + else + -- append and truncate + local current_data = redis.call("get", key) + new_data = current_data:sub(1, offset) .. new_data + redis.call("set", key, new_data, "ex", expire) + return redis.call("strlen", key) + end + EOS + + def data(model) + with_redis do |redis| + redis.get(key(model)) + end + end + + def set_data(model, new_data) + with_redis do |redis| + redis.set(key(model), new_data, ex: CHUNK_REDIS_TTL) + end + end + + def append_data(model, new_data, offset) + with_redis do |redis| + redis.eval(LUA_APPEND_CHUNK, keys: [key(model)], argv: [new_data, offset]) + end + end + + def size(model) + with_redis do |redis| + redis.strlen(key(model)) + end + end + + def delete_data(model) + delete_keys([[model.build_id, model.chunk_index]]) + end + + def keys(relation) + relation.pluck(:build_id, :chunk_index) + end + + def delete_keys(keys) + return if keys.empty? + + keys = keys.map { |key| key_raw(*key) } + + with_redis do |redis| + # https://gitlab.com/gitlab-org/gitlab/-/issues/224171 + Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do + redis.del(keys) + end + end + end + + private + + def key(model) + key_raw(model.build_id, model.chunk_index) + end + + def key_raw(build_id, chunk_index) + "gitlab:ci:trace:#{build_id.to_i}:chunks:#{chunk_index.to_i}" + end + end + end +end diff --git a/app/models/ci/build_trace_chunks/redis_trace_chunks.rb b/app/models/ci/build_trace_chunks/redis_trace_chunks.rb new file mode 100644 index 00000000000..06e315b0aaf --- /dev/null +++ b/app/models/ci/build_trace_chunks/redis_trace_chunks.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + module BuildTraceChunks + class RedisTraceChunks < RedisBase + private + + def with_redis + Gitlab::Redis::TraceChunks.with { |redis| yield(redis) } + end + end + end +end diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb index 5091e3ff04a..036f611a61c 100644 --- a/app/models/ci/build_trace_section.rb +++ b/app/models/ci/build_trace_section.rb @@ -4,11 +4,14 @@ module Ci class BuildTraceSection < ApplicationRecord extend SuppressCompositePrimaryKeyWarning extend Gitlab::Ci::Model + include IgnorableColumns belongs_to :build, class_name: 'Ci::Build' belongs_to :project belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName' validates :section_name, :build, :project, presence: true, allow_blank: false + + ignore_column :build_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 5248a80f710..6a7a2b3f6bd 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -18,7 +18,6 @@ module Ci ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze - UNSUPPORTED_FILE_TYPES = %i[license_management].freeze SAST_REPORT_TYPES = %w[sast].freeze SECRET_DETECTION_REPORT_TYPES = %w[secret_detection].freeze DEFAULT_FILE_NAMES = { @@ -35,7 +34,6 @@ module Ci dependency_scanning: 'gl-dependency-scanning-report.json', container_scanning: 'gl-container-scanning-report.json', dast: 'gl-dast-report.json', - license_management: 'gl-license-management-report.json', license_scanning: 'gl-license-scanning-report.json', performance: 'performance.json', browser_performance: 'browser-performance.json', @@ -45,7 +43,7 @@ module Ci dotenv: '.env', cobertura: 'cobertura-coverage.xml', terraform: 'tfplan.json', - cluster_applications: 'gl-cluster-applications.json', + cluster_applications: 'gl-cluster-applications.json', # DEPRECATED: https://gitlab.com/gitlab-org/gitlab/-/issues/333441 requirements: 'requirements.json', coverage_fuzzing: 'gl-coverage-fuzzing.json', api_fuzzing: 'gl-api-fuzzing-report.json' @@ -74,7 +72,6 @@ module Ci dependency_scanning: :raw, container_scanning: :raw, dast: :raw, - license_management: :raw, license_scanning: :raw, # All these file formats use `raw` as we need to store them uncompressed @@ -102,7 +99,6 @@ module Ci dependency_scanning dotenv junit - license_management license_scanning lsif metrics @@ -124,7 +120,6 @@ module Ci mount_file_store_uploader JobArtifactUploader validates :file_format, presence: true, unless: :trace?, on: :create - validate :validate_supported_file_format!, on: :create validate :validate_file_format!, unless: :trace?, on: :create before_save :set_size, if: :file_changed? @@ -199,8 +194,7 @@ module Ci container_scanning: 7, ## EE-specific dast: 8, ## EE-specific codequality: 9, ## EE-specific - license_management: 10, ## EE-specific - license_scanning: 101, ## EE-specific till 13.0 + license_scanning: 101, ## EE-specific performance: 11, ## EE-specific till 13.2 metrics: 12, ## EE-specific metrics_referee: 13, ## runner referees @@ -233,14 +227,6 @@ module Ci hashed_path: 2 } - def validate_supported_file_format! - return if Feature.disabled?(:drop_license_management_artifact, project, default_enabled: true) - - if UNSUPPORTED_FILE_TYPES.include?(self.file_type&.to_sym) - errors.add(:base, _("File format is no longer supported")) - end - end - def validate_file_format! unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym errors.add(:base, _('Invalid file format with specified file type')) diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb new file mode 100644 index 00000000000..283ad4a190d --- /dev/null +++ b/app/models/ci/job_token/project_scope_link.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# The connection between a source project (which defines the job token scope) +# and a target project which is the one allowed to be accessed by the job token. + +module Ci + module JobToken + class ProjectScopeLink < ApplicationRecord + self.table_name = 'ci_job_token_project_scope_links' + + belongs_to :source_project, class_name: 'Project' + belongs_to :target_project, class_name: 'Project' + belongs_to :added_by, class_name: 'User' + + scope :from_project, ->(project) { where(source_project: project) } + scope :to_project, ->(project) { where(target_project: project) } + + validates :source_project, presence: true + validates :target_project, presence: true + validate :not_self_referential_link + + private + + def not_self_referential_link + return unless source_project && target_project + + if source_project == target_project + self.errors.add(:target_project, _("can't be the same as the source project")) + end + end + end + end +end diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb new file mode 100644 index 00000000000..42cfdc21d66 --- /dev/null +++ b/app/models/ci/job_token/scope.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# This model represents the surface where a CI_JOB_TOKEN can be used. +# A Scope is initialized with the project that the job token belongs to, +# and indicates what are all the other projects that the token could access. +# +# By default a job token can only access its own project, which is the same +# project that defines the scope. +# By adding ScopeLinks to the scope we can allow other projects to be accessed +# by the job token. This works as an allowlist of projects for a job token. +# +# If a project is not included in the scope we should not allow the job user +# to access it since operations using CI_JOB_TOKEN should be considered untrusted. + +module Ci + module JobToken + class Scope + attr_reader :source_project + + def initialize(project) + @source_project = project + end + + def includes?(target_project) + # if the setting is disabled any project is considered to be in scope. + return true unless source_project.ci_job_token_scope_enabled? + + target_project.id == source_project.id || + Ci::JobToken::ProjectScopeLink.from_project(source_project).to_project(target_project).exists? + end + + def all_projects + Project.from_union([ + Project.id_in(source_project), + Project.where_exists( + Ci::JobToken::ProjectScopeLink + .from_project(source_project) + .where('projects.id = ci_job_token_project_scope_links.target_project_id')) + ], remove_duplicates: false) + end + end + end +end diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb new file mode 100644 index 00000000000..b9a8a44bd6b --- /dev/null +++ b/app/models/ci/pending_build.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + class PendingBuild < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :project + belongs_to :build, class_name: 'Ci::Build' + + def self.upsert_from_build!(build) + entry = self.new(build: build, project: build.project, protected: build.protected?) + + entry.validate! + + self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id) + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f0a2c074584..ae06bea5a02 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -644,6 +644,10 @@ module Ci end end + def update_builds_coverage + builds.with_coverage_regex.without_coverage.each(&:update_coverage) + end + def batch_lookup_report_artifact_for_file_type(file_type) latest_report_artifacts .values_at(*::Ci::JobArtifact.associated_file_types_for(file_type.to_s)) @@ -660,15 +664,9 @@ module Ci # Return a hash of file type => array of 1 job artifact def latest_report_artifacts ::Gitlab::SafeRequestStore.fetch("pipeline:#{self.id}:latest_report_artifacts") do - # Note we use read_attribute(:project_id) to read the project - # ID instead of self.project_id. The latter appears to load - # the Project model. This extra filter doesn't appear to - # affect query plan but included to ensure we don't leak the - # wrong informaiton. ::Ci::JobArtifact.where( id: job_artifacts.with_reports .select('max(ci_job_artifacts.id) as id') - .where(project_id: self.read_attribute(:project_id)) .group(:file_type) ) .preload(:job) @@ -928,6 +926,12 @@ module Ci Ci::Build.latest.where(pipeline: self_and_descendants) end + def environments_in_self_and_descendants + environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id') + + Environment.where(id: environment_ids) + end + # Without using `unscoped`, caller scope is also included into the query. # Using `unscoped` here will be redundant after Rails 6.1 def self_and_descendants @@ -1252,6 +1256,10 @@ module Ci end end + def build_matchers + self.builds.build_matchers(project) + end + private def add_message(severity, content) diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 9e5d517c1fe..effe2d95a99 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -3,6 +3,7 @@ module Ci class PipelineSchedule < ApplicationRecord extend Gitlab::Ci::Model + extend ::Gitlab::Utils::Override include Importable include StripAttribute include CronSchedulable @@ -55,6 +56,17 @@ module Ci variables&.map(&:to_runner_variable) || [] end + override :set_next_run_at + def set_next_run_at + self.next_run_at = ::Ci::PipelineSchedules::CalculateNextRunService # rubocop: disable CodeReuse/ServiceClass + .new(project) + .execute(self, fallback_method: method(:calculate_next_run_at)) + end + + def daily_limit + project.actual_limits.limit_for(:ci_daily_pipeline_schedule_triggers) + end + private def worker_cron_expression diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 15c57550159..e2f257eab25 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -120,7 +120,7 @@ module Ci raise NotImplementedError end - def instantized_environment + def persisted_environment raise NotImplementedError end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 8c877c2b818..71110ef0696 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -10,6 +10,8 @@ module Ci include TokenAuthenticatable include IgnorableColumns include FeatureGate + include Gitlab::Utils::StrongMemoize + include TaggableQueries add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required } @@ -58,6 +60,7 @@ module Ci scope :active, -> { where(active: true) } scope :paused, -> { where(active: false) } scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) } + scope :recent, -> { where('ci_runners.created_at > :date OR ci_runners.contacted_at > :date', date: 3.months.ago) } # The following query using negation is cheaper than using `contacted_at <= ?` # because there are less runners online than have been created. The # resulting query is quickly finding online ones and then uses the regular @@ -131,6 +134,8 @@ module Ci end scope :order_contacted_at_asc, -> { order(contacted_at: :asc) } + scope :order_contacted_at_desc, -> { order(contacted_at: :desc) } + scope :order_created_at_asc, -> { order(created_at: :asc) } scope :order_created_at_desc, -> { order(created_at: :desc) } scope :with_tags, -> { preload(:tags) } @@ -161,20 +166,17 @@ module Ci numericality: { greater_than_or_equal_to: 0.0, message: 'needs to be non-negative' } + validates :config, json_schema: { filename: 'ci_runner_config' } + # Searches for runners matching the given query. # - # This method uses ILIKE on PostgreSQL. - # - # This method performs a *partial* match on tokens, thus a query for "a" - # will match any runner where the token contains the letter "a". As a result - # you should *not* use this method for non-admin purposes as otherwise users - # might be able to query a list of all runners. + # This method uses ILIKE on PostgreSQL for the description field and performs a full match on tokens. # # query - The search query as a String. # # Returns an ActiveRecord::Relation. def self.search(query) - fuzzy_search(query, [:token, :description]) + where(token: query).or(fuzzy_search(query, [:description])) end def self.online_contact_time_deadline @@ -190,13 +192,54 @@ module Ci end def self.order_by(order) - if order == 'contacted_asc' + case order + when 'contacted_asc' order_contacted_at_asc + when 'contacted_desc' + order_contacted_at_desc + when 'created_at_asc' + order_created_at_asc else order_created_at_desc end end + def self.runner_matchers + unique_params = [ + :runner_type, + :public_projects_minutes_cost_factor, + :private_projects_minutes_cost_factor, + :run_untagged, + :access_level, + Arel.sql("(#{arel_tag_names_array.to_sql})") + ] + + # we use distinct to de-duplicate data + distinct.pluck(*unique_params).map do |values| + Gitlab::Ci::Matching::RunnerMatcher.new({ + runner_type: values[0], + public_projects_minutes_cost_factor: values[1], + private_projects_minutes_cost_factor: values[2], + run_untagged: values[3], + access_level: values[4], + tag_list: values[5] + }) + end + end + + def runner_matcher + strong_memoize(:runner_matcher) do + Gitlab::Ci::Matching::RunnerMatcher.new({ + runner_type: runner_type, + public_projects_minutes_cost_factor: public_projects_minutes_cost_factor, + private_projects_minutes_cost_factor: private_projects_minutes_cost_factor, + run_untagged: run_untagged, + access_level: access_level, + tag_list: tag_list + }) + end + end + def assign_to(project, current_user = nil) if instance_type? self.runner_type = :project_type @@ -298,6 +341,14 @@ module Ci end def tick_runner_queue + ## + # We only stick a runner to primary database to be able to detect the + # replication lag in `EE::Ci::RegisterJobService#execute`. The + # intention here is not to execute `Ci::RegisterJobService#execute` on + # the primary database. + # + ::Gitlab::Database::LoadBalancing::Sticking.stick(:runner, id) + SecureRandom.hex.tap do |new_update| ::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_update, expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: true) @@ -315,21 +366,24 @@ module Ci end def heartbeat(values) - values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {} - values[:contacted_at] = Time.current + ## + # We can safely ignore writes performed by a runner heartbeat. We do + # not want to upgrade database connection proxy to use the primary + # database after heartbeat write happens. + # + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do + values = values&.slice(:version, :revision, :platform, :architecture, :ip_address, :config) || {} + values[:contacted_at] = Time.current - cache_attributes(values) + cache_attributes(values) - # We save data without validation, it will always change due to `contacted_at` - self.update_columns(values) if persist_cached_data? + # We save data without validation, it will always change due to `contacted_at` + self.update_columns(values) if persist_cached_data? + end end def pick_build!(build) - if Feature.enabled?(:ci_reduce_queries_when_ticking_runner_queue, self, default_enabled: :yaml) - tick_runner_queue if matches_build?(build) - else - tick_runner_queue if can_pick?(build) - end + tick_runner_queue if matches_build?(build) end def uncached_contacted_at @@ -395,13 +449,7 @@ module Ci end def matches_build?(build) - return false if self.ref_protected? && !build.protected? - - accepting_tags?(build) - end - - def accepting_tags?(build) - (run_untagged? || build.has_tags?) && (build.tag_list - tag_list).empty? + runner_matcher.matches?(build.build_matcher) end end end diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index f819dda207d..41a4c9012ff 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -7,6 +7,7 @@ module Ci self.limit_name = 'ci_registered_group_runners' self.limit_scope = :group + self.limit_relation = :recent_runners self.limit_feature_flag = :ci_runner_limits belongs_to :runner, inverse_of: :runner_namespaces @@ -16,6 +17,10 @@ module Ci validates :runner_id, uniqueness: { scope: :namespace_id } validate :group_runner_type + def recent_runners + ::Ci::Runner.belonging_to_group(namespace_id).recent + end + private def group_runner_type diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index c26b8183b52..af2595ce4af 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -7,11 +7,16 @@ module Ci self.limit_name = 'ci_registered_project_runners' self.limit_scope = :project + self.limit_relation = :recent_runners self.limit_feature_flag = :ci_runner_limits belongs_to :runner, inverse_of: :runner_projects belongs_to :project, inverse_of: :runner_projects + def recent_runners + ::Ci::Runner.belonging_to_project(project_id).recent + end + validates :runner_id, uniqueness: { scope: :project_id } end end diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb new file mode 100644 index 00000000000..9446cfa05da --- /dev/null +++ b/app/models/ci/running_build.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ci + class RunningBuild < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :project + belongs_to :build, class_name: 'Ci::Build' + belongs_to :runner, class_name: 'Ci::Runner' + + enum runner_type: ::Ci::Runner.runner_types + + def self.upsert_shared_runner_build!(build) + unless build.shared_runner_build? + raise ArgumentError, 'build has not been picked by a shared runner' + end + + entry = self.new(build: build, + project: build.project, + runner: build.runner, + runner_type: build.runner.runner_type) + + entry.validate! + + self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id) + end + end +end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index ef920b2d589..d00066b778d 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -7,6 +7,9 @@ module Ci include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable + include IgnorableColumns + + ignore_column :id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' enum status: Ci::HasStatus::STATUSES_ENUM diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb deleted file mode 100644 index 91aa422b859..00000000000 --- a/app/models/clusters/applications/fluentd.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class Fluentd < ApplicationRecord - VERSION = '2.4.0' - CILIUM_CONTAINER_NAME = 'cilium-monitor' - - self.table_name = 'clusters_applications_fluentd' - - include ::Clusters::Concerns::ApplicationCore - include ::Clusters::Concerns::ApplicationStatus - include ::Clusters::Concerns::ApplicationVersion - include ::Clusters::Concerns::ApplicationData - - default_value_for :version, VERSION - default_value_for :port, 514 - default_value_for :protocol, :tcp - - enum protocol: { tcp: 0, udp: 1 } - - validate :has_at_least_one_log_enabled? - - def chart - 'fluentd/fluentd' - end - - def repository - 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive' - end - - def install_command - helm_command_module::InstallCommand.new( - name: 'fluentd', - repository: repository, - version: VERSION, - rbac: cluster.platform_kubernetes_rbac?, - chart: chart, - files: files - ) - end - - def values - content_values.to_yaml - end - - private - - def has_at_least_one_log_enabled? - if !waf_log_enabled && !cilium_log_enabled - errors.add(:base, _("At least one logging option is required to be enabled")) - end - end - - def content_values - YAML.load_file(chart_values_file).deep_merge!(specification) - end - - def specification - { - "configMaps" => { - "output.conf" => output_configuration_content, - "general.conf" => general_configuration_content - } - } - end - - def output_configuration_content - <<~EOF - <match kubernetes.**> - @type remote_syslog - @id out_kube_remote_syslog - host #{host} - port #{port} - program fluentd - hostname ${kubernetes_host} - protocol #{protocol} - packet_size 131072 - <buffer kubernetes_host> - </buffer> - <format> - @type ltsv - </format> - </match> - EOF - end - - def general_configuration_content - <<~EOF - <match fluent.**> - @type null - </match> - <source> - @type http - port 9880 - bind 0.0.0.0 - </source> - <source> - @type tail - @id in_tail_container_logs - path #{path_to_logs} - pos_file /var/log/fluentd-containers.log.pos - tag kubernetes.* - read_from_head true - <parse> - @type json - time_format %Y-%m-%dT%H:%M:%S.%NZ - </parse> - </source> - EOF - end - - def path_to_logs - path = [] - path << "/var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log" if waf_log_enabled - path << "/var/log/containers/*#{CILIUM_CONTAINER_NAME}*.log" if cilium_log_enabled - path.join(',') - end - end - end -end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index e7d4d737b8e..3a8c314efe4 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -7,10 +7,6 @@ module Clusters class Ingress < ApplicationRecord VERSION = '1.40.2' INGRESS_CONTAINER_NAME = 'nginx-ingress-controller' - MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log' - MODSECURITY_MODE_LOGGING = "DetectionOnly" - MODSECURITY_MODE_BLOCKING = "On" - MODSECURITY_OWASP_RULES_FILE = "/etc/nginx/owasp-modsecurity-crs/nginx-modsecurity.conf" self.table_name = 'clusters_applications_ingress' @@ -20,22 +16,18 @@ module Clusters include ::Clusters::Concerns::ApplicationData include AfterCommitQueue include UsageStatistics + include IgnorableColumns default_value_for :ingress_type, :nginx - default_value_for :modsecurity_enabled, true default_value_for :version, VERSION - default_value_for :modsecurity_mode, :logging + + ignore_column :modsecurity_enabled, remove_with: '14.2', remove_after: '2021-07-22' + ignore_column :modsecurity_mode, remove_with: '14.2', remove_after: '2021-07-22' enum ingress_type: { nginx: 1 } - enum modsecurity_mode: { logging: 0, blocking: 1 } - - scope :modsecurity_not_installed, -> { where(modsecurity_enabled: nil) } - scope :modsecurity_enabled, -> { where(modsecurity_enabled: true) } - scope :modsecurity_disabled, -> { where(modsecurity_enabled: false) } - FETCH_IP_ADDRESS_DELAY = 30.seconds state_machine :status do @@ -92,96 +84,13 @@ module Clusters private - def specification - return {} unless modsecurity_enabled - - { - "controller" => { - "config" => { - "enable-modsecurity" => "true", - "enable-owasp-modsecurity-crs" => "false", - "modsecurity-snippet" => modsecurity_snippet_content, - "modsecurity.conf" => modsecurity_config_content - }, - "extraContainers" => [ - { - "name" => MODSECURITY_LOG_CONTAINER_NAME, - "image" => "busybox", - "args" => [ - "/bin/sh", - "-c", - "tail -F /var/log/modsec/audit.log" - ], - "volumeMounts" => [ - { - "name" => "modsecurity-log-volume", - "mountPath" => "/var/log/modsec", - "readOnly" => true - } - ], - "livenessProbe" => { - "exec" => { - "command" => [ - "ls", - "/var/log/modsec/audit.log" - ] - } - } - } - ], - "extraVolumeMounts" => [ - { - "name" => "modsecurity-template-volume", - "mountPath" => "/etc/nginx/modsecurity/modsecurity.conf", - "subPath" => "modsecurity.conf" - }, - { - "name" => "modsecurity-log-volume", - "mountPath" => "/var/log/modsec" - } - ], - "extraVolumes" => [ - { - "name" => "modsecurity-template-volume", - "configMap" => { - "name" => "ingress-#{INGRESS_CONTAINER_NAME}", - "items" => [ - { - "key" => "modsecurity.conf", - "path" => "modsecurity.conf" - } - ] - } - }, - { - "name" => "modsecurity-log-volume", - "emptyDir" => {} - } - ] - } - } - end - - def modsecurity_config_content - File.read(modsecurity_config_file_path) - end - - def modsecurity_config_file_path - Rails.root.join('vendor', 'ingress', 'modsecurity.conf') - end - def content_values - YAML.load_file(chart_values_file).deep_merge!(specification) + YAML.load_file(chart_values_file) end def application_jupyter_installed? cluster.application_jupyter&.installed? end - - def modsecurity_snippet_content - sec_rule_engine = logging? ? MODSECURITY_MODE_LOGGING : MODSECURITY_MODE_BLOCKING - "SecRuleEngine #{sec_rule_engine}\nInclude #{MODSECURITY_OWASP_RULES_FILE}" - end end end end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 6867d7b6934..0e7cbb35e47 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -141,13 +141,13 @@ module Clusters end def install_knative_metrics - return [] unless cluster.application_prometheus_available? + return [] unless cluster.application_prometheus&.available? [Gitlab::Kubernetes::KubectlCmd.apply_file(METRICS_CONFIG)] end def delete_knative_istio_metrics - return [] unless cluster.application_prometheus_available? + return [] unless cluster.application_prometheus&.available? [Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)] end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index e8d56072b89..49840e3a2e7 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.28.0' + VERSION = '0.29.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 4877ced795c..2fff0a69a26 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -21,7 +21,6 @@ module Clusters Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter, Clusters::Applications::Knative.application_name => Clusters::Applications::Knative, Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack, - Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd, Clusters::Applications::Cilium.application_name => Clusters::Applications::Cilium }.freeze DEFAULT_ENVIRONMENT = '*' @@ -68,7 +67,6 @@ module Clusters has_one_cluster_application :jupyter has_one_cluster_application :knative has_one_cluster_application :elastic_stack - has_one_cluster_application :fluentd has_one_cluster_application :cilium has_many :kubernetes_namespaces @@ -104,8 +102,8 @@ module Clusters delegate :available?, to: :application_helm, prefix: true, allow_nil: true delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true - delegate :available?, to: :application_elastic_stack, prefix: true, allow_nil: true delegate :available?, to: :integration_elastic_stack, prefix: true, allow_nil: true + delegate :available?, to: :integration_prometheus, prefix: true, allow_nil: true delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true @@ -138,11 +136,10 @@ module Clusters scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) } scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) } - scope :with_enabled_modsecurity, -> { joins(:application_ingress).merge(::Clusters::Applications::Ingress.modsecurity_enabled) } scope :with_available_elasticstack, -> { joins(:application_elastic_stack).merge(::Clusters::Applications::ElasticStack.available) } scope :with_available_cilium, -> { joins(:application_cilium).merge(::Clusters::Applications::Cilium.available) } scope :distinct_with_deployed_environments, -> { joins(:environments).merge(::Deployment.success).distinct } - scope :preload_elasticstack, -> { preload(:application_elastic_stack) } + scope :preload_elasticstack, -> { preload(:integration_elastic_stack) } scope :preload_environments, -> { preload(:environments) } scope :managed, -> { where(managed: true) } @@ -171,18 +168,16 @@ module Clusters state_machine :cleanup_status, initial: :cleanup_not_started do state :cleanup_not_started, value: 1 - state :cleanup_uninstalling_applications, value: 2 state :cleanup_removing_project_namespaces, value: 3 state :cleanup_removing_service_account, value: 4 state :cleanup_errored, value: 5 event :start_cleanup do |cluster| - transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications + transition [:cleanup_not_started, :cleanup_errored] => :cleanup_removing_project_namespaces end event :continue_cleanup do transition( - cleanup_uninstalling_applications: :cleanup_removing_project_namespaces, cleanup_removing_project_namespaces: :cleanup_removing_service_account) end @@ -195,13 +190,7 @@ module Clusters cluster.cleanup_status_reason = status_reason if status_reason end - after_transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications do |cluster| - cluster.run_after_commit do - Clusters::Cleanup::AppWorker.perform_async(cluster.id) - end - end - - after_transition cleanup_uninstalling_applications: :cleanup_removing_project_namespaces do |cluster| + after_transition [:cleanup_not_started, :cleanup_errored] => :cleanup_removing_project_namespaces do |cluster| cluster.run_after_commit do Clusters::Cleanup::ProjectNamespaceWorker.perform_async(cluster.id) end @@ -325,7 +314,7 @@ module Clusters end def elastic_stack_adapter - application_elastic_stack || integration_elastic_stack + integration_elastic_stack end def elasticsearch_client @@ -333,11 +322,7 @@ module Clusters end def elastic_stack_available? - if application_elastic_stack_available? || integration_elastic_stack_available? - true - else - false - end + !!integration_elastic_stack_available? end def kubernetes_namespace_for(environment, deployable: environment.last_deployable) @@ -391,12 +376,8 @@ module Clusters end end - def application_prometheus_available? - integration_prometheus&.available? || application_prometheus&.available? - end - def prometheus_adapter - integration_prometheus || application_prometheus + integration_prometheus end private diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb index 125783e6ee1..162a1a3290d 100644 --- a/app/models/clusters/clusters_hierarchy.rb +++ b/app/models/clusters/clusters_hierarchy.rb @@ -4,9 +4,8 @@ module Clusters class ClustersHierarchy DEPTH_COLUMN = :depth - def initialize(clusterable, include_management_project: true) + def initialize(clusterable) @clusterable = clusterable - @include_management_project = include_management_project end # Returns clusters in order from deepest to highest group @@ -25,7 +24,7 @@ module Clusters private - attr_reader :clusterable, :include_management_project + attr_reader :clusterable def recursive_cte cte = Gitlab::SQL::RecursiveCTE.new(:clusters_cte) @@ -39,7 +38,7 @@ module Clusters raise ArgumentError, "unknown type for #{clusterable}" end - if clusterable.is_a?(::Project) && include_management_project + if clusterable.is_a?(::Project) cte << same_namespace_management_clusters_query end @@ -71,7 +70,7 @@ module Clusters # Only applicable if the clusterable is a project (most especially when # requesting project.deployment_platform). def depth_order_clause - return { DEPTH_COLUMN => :asc } unless clusterable.is_a?(::Project) && include_management_project + return { DEPTH_COLUMN => :asc } unless clusterable.is_a?(::Project) order = <<~SQL (CASE clusters.management_project_id diff --git a/app/models/commit.rb b/app/models/commit.rb index 09e43bb8f20..a1ed5eb9ab9 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -15,8 +15,6 @@ class Commit include ActsAsPaginatedDiff include CacheMarkdownField - attr_mentionable :safe_message, pipeline: :single_line - participant :author participant :committer participant :notes_with_associations @@ -35,10 +33,20 @@ class Commit # Used by GFM to match and present link extensions on node texts and hrefs. LINK_EXTENSION_PATTERN = /(patch)/.freeze + DEFAULT_MAX_DIFF_LINES_SETTING = 50_000 + DEFAULT_MAX_DIFF_FILES_SETTING = 1_000 + MAX_DIFF_LINES_SETTING_UPPER_BOUND = 100_000 + MAX_DIFF_FILES_SETTING_UPPER_BOUND = 3_000 + DIFF_SAFE_LIMIT_FACTOR = 10 + cache_markdown_field :title, pipeline: :single_line cache_markdown_field :full_title, pipeline: :single_line, limit: 1.kilobyte cache_markdown_field :description, pipeline: :commit_description, limit: 1.megabyte + # Share the cache used by the markdown fields + attr_mentionable :title, pipeline: :single_line + attr_mentionable :description, pipeline: :commit_description, limit: 1.megabyte + class << self def decorate(commits, container) commits.map do |commit| @@ -76,20 +84,24 @@ class Commit end def diff_safe_lines(project: nil) - Gitlab::Git::DiffCollection.default_limits(project: project)[:max_lines] + diff_safe_max_lines(project: project) end - def diff_hard_limit_files(project: nil) + def diff_max_files(project: nil) if Feature.enabled?(:increased_diff_limits, project) 3000 + elsif Feature.enabled?(:configurable_diff_limits, project) + Gitlab::CurrentSettings.diff_max_files else 1000 end end - def diff_hard_limit_lines(project: nil) + def diff_max_lines(project: nil) if Feature.enabled?(:increased_diff_limits, project) 100000 + elsif Feature.enabled?(:configurable_diff_limits, project) + Gitlab::CurrentSettings.diff_max_lines else 50000 end @@ -97,11 +109,19 @@ class Commit def max_diff_options(project: nil) { - max_files: diff_hard_limit_files(project: project), - max_lines: diff_hard_limit_lines(project: project) + max_files: diff_max_files(project: project), + max_lines: diff_max_lines(project: project) } end + def diff_safe_max_files(project: nil) + diff_max_files(project: project) / DIFF_SAFE_LIMIT_FACTOR + end + + def diff_safe_max_lines(project: nil) + diff_max_lines(project: project) / DIFF_SAFE_LIMIT_FACTOR + end + def from_hash(hash, container) raw_commit = Gitlab::Git::Commit.new(container.repository.raw, hash) new(raw_commit, container) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index c5ba19438cd..2db606898b9 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -57,6 +57,9 @@ class CommitStatus < ApplicationRecord scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } scope :eager_load_pipeline, -> { eager_load(:pipeline, project: { namespace: :route }) } scope :with_pipeline, -> { joins(:pipeline) } + scope :updated_before, ->(lookback:, timeout:) { + where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout) + } scope :for_project_paths, -> (paths) do where(project: Project.where_full_path_in(Array(paths))) @@ -174,8 +177,11 @@ class CommitStatus < ApplicationRecord next if commit_status.processed? next unless commit_status.project + last_arg = transition.args.last + transition_options = last_arg.is_a?(Hash) && last_arg.extractable_options? ? last_arg : {} + commit_status.run_after_commit do - PipelineProcessWorker.perform_async(pipeline_id) + PipelineProcessWorker.perform_async(pipeline_id) unless transition_options[:skip_pipeline_processing] ExpireJobCacheWorker.perform_async(id) end end diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb index 3748e77e933..908f0b6a7e2 100644 --- a/app/models/concerns/bulk_insert_safe.rb +++ b/app/models/concerns/bulk_insert_safe.rb @@ -141,6 +141,12 @@ module BulkInsertSafe raise ArgumentError, "returns needs to be :ids or nil" end + # Handle insertions for tables with a composite primary key + primary_keys = connection.schema_cache.primary_keys(table_name) + if unique_by.blank? && primary_key != primary_keys + unique_by = primary_keys + end + transaction do items.each_slice(batch_size).flat_map do |item_batch| attributes = _bulk_insert_item_attributes( diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index a5cf947ba07..101bff32dfe 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -27,7 +27,7 @@ module CacheMarkdownField # Returns the default Banzai render context for the cached markdown field. def banzai_render_context(field) raise ArgumentError, "Unknown field: #{field.inspect}" unless - cached_markdown_fields.markdown_fields.include?(field) + cached_markdown_fields.key?(field) # Always include a project key, or Banzai complains project = self.project if self.respond_to?(:project) @@ -100,7 +100,7 @@ module CacheMarkdownField def cached_html_for(markdown_field) raise ArgumentError, "Unknown field: #{markdown_field}" unless - cached_markdown_fields.markdown_fields.include?(markdown_field) + cached_markdown_fields.key?(markdown_field) __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend end @@ -108,7 +108,7 @@ module CacheMarkdownField # Updates the markdown cache if necessary, then returns the field # Unlike `cached_html_for` it returns `nil` if the field does not exist def updated_cached_html_for(markdown_field) - return unless cached_markdown_fields.markdown_fields.include?(markdown_field) + return unless cached_markdown_fields.key?(markdown_field) if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) # Invalidated due to Markdown content change @@ -157,6 +157,9 @@ module CacheMarkdownField end def store_mentions! + # We can only store mentions if the mentionable is a database object + return unless self.is_a?(ApplicationRecord) + refs = all_references(self.author) references = {} diff --git a/app/models/concerns/cron_schedulable.rb b/app/models/concerns/cron_schedulable.rb index beb3a09c119..48605ecc3d7 100644 --- a/app/models/concerns/cron_schedulable.rb +++ b/app/models/concerns/cron_schedulable.rb @@ -4,23 +4,28 @@ module CronSchedulable extend ActiveSupport::Concern include Schedulable + def set_next_run_at + self.next_run_at = calculate_next_run_at + end + + private + ## # The `next_run_at` column is set to the actual execution date of worker that # triggers the schedule. This way, a schedule like `*/1 * * * *` won't be triggered # in a short interval when the worker runs irregularly by Sidekiq Memory Killer. - def set_next_run_at + def calculate_next_run_at now = Time.zone.now + ideal_next_run = ideal_next_run_from(now) - self.next_run_at = if ideal_next_run == cron_worker_next_run_from(now) - ideal_next_run - else - cron_worker_next_run_from(ideal_next_run) - end + if ideal_next_run == cron_worker_next_run_from(now) + ideal_next_run + else + cron_worker_next_run_from(ideal_next_run) + end end - private - def ideal_next_run_from(start_time) next_time_from(start_time, cron, cron_timezone) end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 02f7711e927..b6245e29746 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -10,10 +10,6 @@ module DeploymentPlatform private - def cluster_management_project_enabled? - Feature.enabled?(:cluster_management_project, self, default_enabled: true) - end - def find_deployment_platform(environment) find_platform_kubernetes_with_cte(environment) || find_instance_cluster_platform_kubernetes(environment: environment) @@ -21,13 +17,13 @@ module DeploymentPlatform def find_platform_kubernetes_with_cte(environment) if environment - ::Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?) + ::Clusters::ClustersHierarchy.new(self) .base_and_ancestors .enabled .on_environment(environment, relevant_only: true) .first&.platform_kubernetes else - Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors + Clusters::ClustersHierarchy.new(self).base_and_ancestors .enabled.default_environment .first&.platform_kubernetes end diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb index 6d0a21cf070..c66942025d7 100644 --- a/app/models/concerns/enum_with_nil.rb +++ b/app/models/concerns/enum_with_nil.rb @@ -11,14 +11,6 @@ module EnumWithNil # override auto-defined methods only for the # key which uses nil value definitions.each do |name, values| - next unless key_with_nil = values.key(nil) - - # E.g. for enum_with_nil failure_reason: { unknown_failure: nil } - # this overrides auto-generated method `unknown_failure?` - define_method("#{key_with_nil}?") do - self[name].nil? - end - # E.g. for enum_with_nil failure_reason: { unknown_failure: nil } # this overrides auto-generated method `failure_reason` define_method(name) do diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 2e368b12cb7..72788d15c0a 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -24,6 +24,7 @@ module Enums project_deleted: 15, ci_quota_exceeded: 16, pipeline_loop_detected: 17, + no_matching_runner: 18, # not used anymore, but cannot be deleted because of old data insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index 55360eb92e6..749d1ad65cd 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -29,6 +29,14 @@ module Enums critical: 7 }.with_indifferent_access.freeze + DETECTION_METHODS = { + gitlab_security_report: 0, + external_security_report: 1, + bug_bounty: 2, + code_review: 3, + security_audit: 4 + }.with_indifferent_access.freeze + def self.confidence_levels CONFIDENCE_LEVELS end @@ -40,6 +48,10 @@ module Enums def self.severity_levels SEVERITY_LEVELS end + + def self.detection_methods + DETECTION_METHODS + end end end diff --git a/app/models/concerns/has_timelogs_report.rb b/app/models/concerns/has_timelogs_report.rb deleted file mode 100644 index 3af063438bf..00000000000 --- a/app/models/concerns/has_timelogs_report.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module HasTimelogsReport - extend ActiveSupport::Concern - include Gitlab::Utils::StrongMemoize - - def timelogs(start_time, end_time) - strong_memoize(:timelogs) { timelogs_for(start_time, end_time) } - end - - def user_can_access_group_timelogs?(current_user) - Ability.allowed?(current_user, :read_group_timelogs, self) - end - - private - - def timelogs_for(start_time, end_time) - Timelog.between_times(start_time, end_time).in_group(self) - end -end diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index 468387115e5..4b4f9c0df84 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -12,10 +12,11 @@ module HasUserType ghost: 5, project_bot: 6, migration_bot: 7, - security_bot: 8 + security_bot: 8, + automation_bot: 9 }.with_indifferent_access.freeze - BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot].freeze + BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot automation_bot].freeze NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze diff --git a/app/models/concerns/services/data_fields.rb b/app/models/concerns/integrations/base_data_fields.rb index fd56af449bc..3cedb90756f 100644 --- a/app/models/concerns/services/data_fields.rb +++ b/app/models/concerns/integrations/base_data_fields.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true -module Services - module DataFields +module Integrations + module BaseDataFields extend ActiveSupport::Concern included do - belongs_to :integration, inverse_of: self.name.underscore.to_sym, foreign_key: :service_id + # TODO: Once we rename the tables we can't rely on `table_name` anymore. + # https://gitlab.com/gitlab-org/gitlab/-/issues/331953 + belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: :service_id delegate :activated?, to: :integration, allow_nil: true diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb new file mode 100644 index 00000000000..e9aaaac8226 --- /dev/null +++ b/app/models/concerns/integrations/has_data_fields.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Integrations + module HasDataFields + extend ActiveSupport::Concern + + class_methods do + # Provide convenient accessor methods for data fields. + # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 + def data_field(*args) + args.each do |arg| + self.class_eval <<-RUBY, __FILE__, __LINE__ + 1 + unless method_defined?(arg) + def #{arg} + data_fields.send('#{arg}') || (properties && properties['#{arg}']) + end + end + + def #{arg}=(value) + @old_data_fields ||= {} + @old_data_fields['#{arg}'] ||= #{arg} # set only on the first assignment, IOW we remember the original value only + data_fields.send('#{arg}=', value) + end + + def #{arg}_touched? + @old_data_fields ||= {} + @old_data_fields.has_key?('#{arg}') + end + + def #{arg}_changed? + #{arg}_touched? && @old_data_fields['#{arg}'] != #{arg} + end + + def #{arg}_was + return unless #{arg}_touched? + return if data_fields.persisted? # arg_was does not work for attr_encrypted + + legacy_properties_data['#{arg}'] + end + RUBY + end + end + end + + included do + has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData' + has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData' + has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::OpenProjectTrackerData' + + def data_fields + raise NotImplementedError + end + + def data_fields_present? + data_fields.present? + rescue NotImplementedError + false + end + end + end +end diff --git a/app/models/project_services/slack_mattermost/notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb index 1a78cea5933..a919fc840fd 100644 --- a/app/models/project_services/slack_mattermost/notifier.rb +++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -module SlackMattermost - module Notifier +module Integrations + module SlackMattermostNotifier private def notify(message, opts) # See https://gitlab.com/gitlab-org/slack-notifier/#custom-http-client - notifier = Slack::Messenger.new(webhook, opts.merge(http_client: HTTPClient)) + notifier = ::Slack::Messenger.new(webhook, opts.merge(http_client: HTTPClient)) notifier.ping( message.pretext, attachments: message.attachments, diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index f5c70f10dc5..2d06247a486 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -101,20 +101,19 @@ module Issuable scope :unassigned, -> do where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)") end - scope :assigned_to, ->(u) do - assignees_table = Arel::Table.new("#{to_ability_name}_assignees") - sql = assignees_table.project('true').where(assignees_table[:user_id].in(u.id)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) - where("EXISTS (#{sql.to_sql})") - end - # rubocop:enable GitlabSecurity/SqlInjection + scope :assigned_to, ->(users) do + assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass + condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id")) + where(condition.arel.exists) + end scope :not_assigned_to, ->(users) do - assignees_table = Arel::Table.new("#{to_ability_name}_assignees") - sql = assignees_table.project('true') - .where(assignees_table[:user_id].in(users)) - .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) - where(sql.exists.not) + assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass + + condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id")) + where(condition.arel.exists.not) end + # rubocop:enable GitlabSecurity/SqlInjection scope :without_particular_labels, ->(label_names) do labels_table = Label.arel_table @@ -469,9 +468,11 @@ module Issuable if self.respond_to?(:total_time_spent) old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent) + old_time_change = old_associations.fetch(:time_change, time_change) if old_total_time_spent != total_time_spent changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + changes[:time_change] = [old_time_change, time_change] end end end diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index 28d12a033a6..933e8b5f687 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -11,7 +11,8 @@ module IssueAvailableFeatures def available_features_for_issue_types { assignee: %w(issue incident), - confidentiality: %(issue incident) + confidentiality: %w(issue incident), + time_tracking: %w(issue incident) }.with_indifferent_access end end diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb index 672bcdbbb1b..41efea65c5a 100644 --- a/app/models/concerns/limitable.rb +++ b/app/models/concerns/limitable.rb @@ -6,6 +6,7 @@ module Limitable included do class_attribute :limit_scope + class_attribute :limit_relation class_attribute :limit_name class_attribute :limit_feature_flag self.limit_name = self.name.demodulize.tableize @@ -28,7 +29,7 @@ module Limitable return unless scope_relation return if limit_feature_flag && ::Feature.disabled?(limit_feature_flag, scope_relation, default_enabled: :yaml) - relation = self.class.where(limit_scope => scope_relation) + relation = limit_relation ? self.public_send(limit_relation) : self.class.where(limit_scope => scope_relation) # rubocop:disable GitlabSecurity/PublicSend limits = scope_relation.actual_limits check_plan_limit_not_exceeded(limits, relation) diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index e33b6db0103..b05beb6c764 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -29,7 +29,7 @@ module Mentionable def self.external_pattern strong_memoize(:external_pattern) do - issue_pattern = IssueTrackerService.reference_pattern + issue_pattern = Integrations::BaseIssueTracker.reference_pattern link_patterns = URI::DEFAULT_PARSER.make_regexp(%w(http https)) reference_pattern(link_patterns, issue_pattern) end diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb index 2354335469a..18ec996c3df 100644 --- a/app/models/concerns/notification_branch_selection.rb +++ b/app/models/concerns/notification_branch_selection.rb @@ -2,7 +2,7 @@ # Concern handling functionality around deciding whether to send notification # for activities on a specified branch or not. Will be included in -# ChatNotificationService and PipelinesEmailService classes. +# Integrations::BaseChatNotification and PipelinesEmailService classes. module NotificationBranchSelection extend ActiveSupport::Concern diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb index c41635a0d16..9cf66c756a0 100644 --- a/app/models/concerns/packages/debian/component_file.rb +++ b/app/models/concerns/packages/debian/component_file.rb @@ -50,6 +50,8 @@ module Packages scope :with_file_type, ->(file_type) { where(file_type: file_type) } + scope :with_architecture, ->(architecture) { where(architecture: architecture) } + scope :with_architecture_name, ->(architecture_name) do left_outer_joins(:architecture) .where("packages_debian_#{container_type}_architectures" => { name: architecture_name }) @@ -60,7 +62,7 @@ module Packages scope :preload_distribution, -> { includes(component: :distribution) } - scope :created_before, ->(reference) { where("#{table_name}.created_at < ?", reference) } + scope :updated_before, ->(reference) { where("#{table_name}.updated_at < ?", reference) } mount_file_store_uploader Packages::Debian::ComponentFileUploader diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 267c7a4d201..159f0044c82 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -18,6 +18,10 @@ module Packages belongs_to container_type belongs_to :creator, class_name: 'User' + has_one :key, + class_name: "Packages::Debian::#{container_type.capitalize}DistributionKey", + foreign_key: :distribution_id, + inverse_of: :distribution # component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :components, class_name: "Packages::Debian::#{container_type.capitalize}Component", @@ -91,6 +95,14 @@ module Packages mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader + def component_names + components.pluck(:name).sort + end + + def architecture_names + architectures.pluck(:name).sort + end + def needs_update? !file.exists? || time_duration_expired? end diff --git a/app/models/concerns/packages/debian/distribution_key.rb b/app/models/concerns/packages/debian/distribution_key.rb new file mode 100644 index 00000000000..7023e2dcd37 --- /dev/null +++ b/app/models/concerns/packages/debian/distribution_key.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Packages + module Debian + module DistributionKey + extend ActiveSupport::Concern + + included do + belongs_to :distribution, class_name: "Packages::Debian::#{container_type.capitalize}Distribution", inverse_of: :key + validates :distribution, + presence: true + + validates :private_key, presence: true, length: { maximum: 512.kilobytes } + validates :passphrase, presence: true, length: { maximum: 255 } + validates :public_key, presence: true, length: { maximum: 512.kilobytes } + validates :fingerprint, presence: true, length: { maximum: 255 } + + validate :private_key_armored, :public_key_armored + + attr_encrypted :private_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm' + attr_encrypted :passphrase, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm' + + private + + def private_key_armored + if private_key.present? && !private_key.start_with?('-----BEGIN PGP PRIVATE KEY BLOCK-----') + errors.add(:private_key, 'must be ASCII armored') + end + end + + def public_key_armored + if public_key.present? && !public_key.start_with?('-----BEGIN PGP PUBLIC KEY BLOCK-----') + errors.add(:public_key, 'must be ASCII armored') + end + end + end + end + end +end diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index afebc426762..86280097d19 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -38,7 +38,7 @@ module PrometheusAdapter # This is a heavy-weight check if a prometheus is properly configured and accessible from GitLab. # This actually sends a request to an external service and often it could take a long time, - # Please consider using `configured?` instead if the process is running on unicorn/puma threads. + # Please consider using `configured?` instead if the process is running on Puma threads. def can_query? prometheus_client.present? end diff --git a/app/models/concerns/service_push_data_validations.rb b/app/models/concerns/service_push_data_validations.rb index defc5794142..451804a2c56 100644 --- a/app/models/concerns/service_push_data_validations.rb +++ b/app/models/concerns/service_push_data_validations.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -# This concern is used by registerd services such as TeamCityService and -# DroneCiService and add methods to perform validations on the received +# This concern is used by registered integrations such as Integrations::TeamCity and +# Integrations::DroneCi and adds methods to perform validations on the received # data. module ServicePushDataValidations diff --git a/app/models/concerns/taggable_queries.rb b/app/models/concerns/taggable_queries.rb new file mode 100644 index 00000000000..2897e5e6420 --- /dev/null +++ b/app/models/concerns/taggable_queries.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module TaggableQueries + extend ActiveSupport::Concern + + class_methods do + # context is a name `acts_as_taggable context` + def arel_tag_names_array(context = :tags) + ActsAsTaggableOn::Tagging + .joins(:tag) + .where("taggings.taggable_id=#{quoted_table_name}.id") # rubocop:disable GitlabSecurity/SqlInjection + .where(taggings: { context: context, taggable_type: polymorphic_name }) + .select('COALESCE(array_agg(tags.name ORDER BY name), ARRAY[]::text[])') + end + end +end diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index a1e7d06b1c1..89b42eec727 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -33,11 +33,11 @@ module TimeTrackable return if @time_spent == 0 - if @time_spent == :reset - reset_spent_time - else - add_or_subtract_spent_time - end + @timelog = if @time_spent == :reset + reset_spent_time + else + add_or_subtract_spent_time + end end alias_method :spend_time=, :spend_time # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -50,6 +50,14 @@ module TimeTrackable Gitlab::TimeTrackingFormatter.output(total_time_spent) end + def time_change + @timelog&.time_spent.to_i # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + def human_time_change + Gitlab::TimeTrackingFormatter.output(time_change) + end + def human_time_estimate Gitlab::TimeTrackingFormatter.output(time_estimate) end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index fb9a8cd312d..8dc58f8dca1 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -44,7 +44,6 @@ module Timebox validates :project, presence: true, unless: :group validates :title, presence: true - validate :uniqueness_of_title, if: :title_changed? validate :timebox_type_check validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } validate :dates_within_4_digits @@ -243,18 +242,6 @@ module Timebox end end - # Timebox titles must be unique across project and group timeboxes - def uniqueness_of_title - if project - relation = self.class.for_projects_and_groups([project_id], [project.group&.id]) - elsif group - relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id]) - end - - title_exists = relation.find_by_title(title) - errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists - end - # Timebox should be either a project timebox or a group timebox def timebox_type_check if group_id && project_id diff --git a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb index 25c050820d6..3be82ed72d3 100644 --- a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb +++ b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb @@ -5,10 +5,6 @@ module TokenAuthenticatableStrategies DYNAMIC_NONCE_IDENTIFIER = "|" NONCE_SIZE = 12 - def self.encrypt_token(plaintext_token) - Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token) - end - def self.decrypt_token(token) return unless token @@ -22,5 +18,13 @@ module TokenAuthenticatableStrategies Gitlab::CryptoHelper.aes256_gcm_decrypt(token) end end + + def self.encrypt_token(plaintext_token) + return Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token) unless Feature.enabled?(:dynamic_nonce, type: :ops) + + iv = ::Digest::SHA256.hexdigest(plaintext_token).bytes.take(NONCE_SIZE).pack('c*') + token = Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token, nonce: iv) + "#{DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv}" + end end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 6e0d0e347c9..2d28a81f462 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -24,8 +24,15 @@ class ContainerRepository < ApplicationRecord scope :for_group_and_its_subgroups, ->(group) do project_scope = Project .for_group_and_its_subgroups(group) - .with_container_registry - .select(:id) + + project_scope = + if Feature.enabled?(:read_container_registry_access_level, group, default_enabled: :yaml) + project_scope.with_feature_enabled(:container_registry) + else + project_scope.with_container_registry + end + + project_scope = project_scope.select(:id) joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id") end @@ -33,6 +40,7 @@ class ContainerRepository < ApplicationRecord scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } scope :waiting_for_cleanup, -> { where(expiration_policy_cleanup_status: WAITING_CLEANUP_STATUSES) } scope :expiration_policy_started_at_nil_or_before, ->(timestamp) { where('expiration_policy_started_at < ? OR expiration_policy_started_at IS NULL', timestamp) } + scope :with_stale_ongoing_cleanup, ->(threshold) { cleanup_ongoing.where('expiration_policy_started_at < ?', threshold) } def self.exists_by_path?(path) where( @@ -42,16 +50,15 @@ class ContainerRepository < ApplicationRecord end def self.with_enabled_policy - joins("INNER JOIN container_expiration_policies ON container_repositories.project_id = container_expiration_policies.project_id") + joins('INNER JOIN container_expiration_policies ON container_repositories.project_id = container_expiration_policies.project_id') .where(container_expiration_policies: { enabled: true }) end def self.requiring_cleanup - where( - container_repositories: { expiration_policy_cleanup_status: REQUIRING_CLEANUP_STATUSES }, - project_id: ::ContainerExpirationPolicy.runnable_schedules - .select(:project_id) - ) + with_enabled_policy + .where(container_repositories: { expiration_policy_cleanup_status: REQUIRING_CLEANUP_STATUSES }) + .where('container_repositories.expiration_policy_started_at IS NULL OR container_repositories.expiration_policy_started_at < container_expiration_policies.next_run_at') + .where('container_expiration_policies.next_run_at < ?', Time.zone.now) end def self.with_unfinished_cleanup diff --git a/app/models/cycle_analytics/project_level.rb b/app/models/cycle_analytics/project_level.rb deleted file mode 100644 index 5bd07b3f6c3..00000000000 --- a/app/models/cycle_analytics/project_level.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module CycleAnalytics - class ProjectLevel - attr_reader :project, :options - - def initialize(project, options:) - @project = project - @options = options.merge(project: project) - end - - def summary - @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(project, - from: options[:from], - to: options[:to], - current_user: options[:current_user]).data - end - - def permissions(user:) - Gitlab::CycleAnalytics::Permissions.get(user: user, project: project) - end - - def stats - @stats ||= default_stage_names.map do |stage_name| - self[stage_name].as_json - end - end - - def [](stage_name) - CycleAnalytics::ProjectLevelStageAdapter.new(build_stage(stage_name), options) - end - - private - - def build_stage(stage_name) - stage_params = stage_params_by_name(stage_name).merge(project: project) - Analytics::CycleAnalytics::ProjectStage.new(stage_params) - end - - def stage_params_by_name(name) - Gitlab::Analytics::CycleAnalytics::DefaultStages.find_by_name!(name) - end - - def default_stage_names - Gitlab::Analytics::CycleAnalytics::DefaultStages.symbolized_stage_names - end - end -end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index e2b25690323..7f5849bffc6 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -8,6 +8,9 @@ class Deployment < ApplicationRecord include Importable include Gitlab::Utils::StrongMemoize include FastDestroyAll + include IgnorableColumns + + ignore_column :deployable_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' belongs_to :project, required: true belongs_to :environment, required: true diff --git a/app/models/environment.rb b/app/models/environment.rb index 2e677a3d177..558963c98c4 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -223,6 +223,7 @@ class Environment < ApplicationRecord Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) .append(key: 'CI_ENVIRONMENT_SLUG', value: slug) + .append(key: 'CI_ENVIRONMENT_TIER', value: tier) end def recently_updated_on_branch?(ref) @@ -335,10 +336,6 @@ class Environment < ApplicationRecord prometheus_adapter.query(:environment, self) if has_metrics_and_can_query? end - def prometheus_status - deployment_platform&.cluster&.application_prometheus&.status_name - end - def additional_metrics(*args) return unless has_metrics_and_can_query? diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb index 55ea4e2fe18..07c0983f239 100644 --- a/app/models/environment_status.rb +++ b/app/models/environment_status.rb @@ -100,7 +100,7 @@ class EnvironmentStatus def self.build_environments_status(mr, user, pipeline) return [] unless pipeline - pipeline.environments.includes(:project).available.map do |environment| + pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment| next unless Ability.allowed?(user, :read_environment, environment) EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha) diff --git a/app/models/experiment.rb b/app/models/experiment.rb index 7ffb321f2b7..cd0814c476a 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -11,7 +11,11 @@ class Experiment < ApplicationRecord end def self.add_group(name, variant:, group:) - find_or_create_by!(name: name).record_group_and_variant!(group, variant) + add_subject(name, variant: variant, subject: group) + end + + def self.add_subject(name, variant:, subject:) + find_or_create_by!(name: name).record_subject_and_variant!(subject, variant) end def self.record_conversion_event(name, user, context = {}) @@ -37,8 +41,11 @@ class Experiment < ApplicationRecord experiment_user.update!(converted_at: Time.current, context: merged_context(experiment_user, context)) end - def record_group_and_variant!(group, variant) - experiment_subject = experiment_subjects.find_or_initialize_by(group: group) + def record_subject_and_variant!(subject, variant) + raise 'Incompatible subject provided!' unless ExperimentSubject.valid_subject?(subject) + + attr_name = subject.class.table_name.singularize.to_sym + experiment_subject = experiment_subjects.find_or_initialize_by(attr_name => subject) experiment_subject.assign_attributes(variant: variant) # We only call save when necessary because this causes the request to stick to the primary DB # even when the save is a no-op diff --git a/app/models/experiment_subject.rb b/app/models/experiment_subject.rb index 51ffc0b304e..2a7b9017a51 100644 --- a/app/models/experiment_subject.rb +++ b/app/models/experiment_subject.rb @@ -5,7 +5,7 @@ class ExperimentSubject < ApplicationRecord belongs_to :experiment, inverse_of: :experiment_subjects belongs_to :user - belongs_to :group + belongs_to :namespace belongs_to :project validates :experiment, presence: true @@ -14,15 +14,19 @@ class ExperimentSubject < ApplicationRecord enum variant: { GROUP_CONTROL => 0, GROUP_EXPERIMENTAL => 1 } + def self.valid_subject?(subject) + subject.is_a?(Namespace) || subject.is_a?(User) || subject.is_a?(Project) + end + private def must_have_one_subject_present if non_nil_subjects.length != 1 - errors.add(:base, s_("ExperimentSubject|Must have exactly one of User, Group, or Project.")) + errors.add(:base, s_("ExperimentSubject|Must have exactly one of User, Namespace, or Project.")) end end def non_nil_subjects - @non_nil_subjects ||= [user, group, project].reject(&:blank?) + @non_nil_subjects ||= [user, namespace, project].reject(&:blank?) end end diff --git a/app/models/group.rb b/app/models/group.rb index da795651c63..e4127b2b2d4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -16,9 +16,7 @@ class Group < Namespace include Gitlab::Utils::StrongMemoize include GroupAPICompatibility include EachBatch - include HasTimelogsReport - - ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 + include BulkMemberAccessLoad has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent @@ -82,6 +80,8 @@ class Group < Namespace # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + delegate :prevent_sharing_groups_outside_hierarchy, to: :namespace_settings + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects @@ -444,6 +444,12 @@ class Group < Namespace end end + def self_and_descendants_ids + strong_memoize(:self_and_descendants_ids) do + self_and_descendants.pluck(:id) + end + end + def direct_members GroupMember.active_without_invites_and_requests .non_minimal_access @@ -569,24 +575,8 @@ class Group < Namespace def max_member_access_for_user(user, only_concrete_membership: false) return GroupMember::NO_ACCESS unless user return GroupMember::OWNER if user.can_admin_all_resources? && !only_concrete_membership - # Use the preloaded value that exists instead of performing the db query again(cached or not). - # Groups::GroupMembersController#preload_max_access makes use of this by - # calling Group#max_member_access. This helps when we have a process - # that may query this multiple times from the outside through a policy query - # like the GroupPolicy#lookup_access_level! does as a condition for any role - return user.max_access_for_group[id] if user.max_access_for_group[id] - max_member_access(user) - end - - def max_member_access(user) - max_member_access = members_with_parents - .where(user_id: user) - .reorder(access_level: :desc) - .first - &.access_level - - max_member_access || GroupMember::NO_ACCESS + max_member_access([user.id])[user.id] end def mattermost_team_params @@ -649,7 +639,7 @@ class Group < Namespace end def access_request_approvers_to_be_notified - members.owners.connected_to_user.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + members.owners.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def supports_events? @@ -657,13 +647,17 @@ class Group < Namespace end def export_file_exists? - export_file&.file + import_export_upload&.export_file_exists? end def export_file import_export_upload&.export_file end + def export_archive_exists? + import_export_upload&.export_archive_exists? + end + def adjourned_deletion? false end @@ -728,8 +722,26 @@ class Group < Namespace Gitlab::Routing.url_helpers.activity_group_path(self) end + # rubocop: disable CodeReuse/ServiceClass + def open_issues_count(current_user = nil) + Groups::OpenIssuesCountService.new(self, current_user).count + end + # rubocop: enable CodeReuse/ServiceClass + + # rubocop: disable CodeReuse/ServiceClass + def open_merge_requests_count(current_user = nil) + Groups::MergeRequestsCountService.new(self, current_user).count + end + # rubocop: enable CodeReuse/ServiceClass + private + def max_member_access(user_ids) + max_member_access_for_resource_ids(User, user_ids) do |user_ids| + members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level) + end + end + def update_two_factor_requirement return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period? diff --git a/app/models/group_deploy_token.rb b/app/models/group_deploy_token.rb index d4ad29ddabb..084a8672460 100644 --- a/app/models/group_deploy_token.rb +++ b/app/models/group_deploy_token.rb @@ -9,8 +9,6 @@ class GroupDeployToken < ApplicationRecord validates :deploy_token_id, uniqueness: { scope: [:group_id] } def has_access_to?(requested_project) - return false unless Feature.enabled?(:allow_group_deploy_token, default_enabled: true) - requested_project_group = requested_project&.group return false unless requested_project_group return true if requested_project_group.id == group_id diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index a28b97e63e5..d1584a62bfb 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -39,6 +39,11 @@ class ProjectHook < WebHook def rate_limit project.actual_limits.limit_for(:web_hook_calls) end + + override :application_context + def application_context + super.merge(project: project) + end end ProjectHook.prepend_mod_with('ProjectHook') diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 02b4feb4ccc..5f8fa4bca0a 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -3,6 +3,7 @@ class WebHook < ApplicationRecord include Sortable + MAX_FAILURES = 100 FAILURE_THRESHOLD = 3 # three strikes INITIAL_BACKOFF = 10.minutes MAX_BACKOFF = 1.day @@ -72,14 +73,29 @@ class WebHook < ApplicationRecord end def enable! + return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0 + update!(recent_failures: 0, disabled_until: nil, backoff_count: 0) end + def backoff! + update!(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES)) + end + + def failed! + update!(recent_failures: recent_failures + 1) if recent_failures < MAX_FAILURES + end + # Overridden in ProjectHook and GroupHook, other webhooks are not rate-limited. def rate_limit nil end + # Custom attributes to be included in the worker context. + def application_context + { related_class: type } + end + private def web_hooks_disable_failed? diff --git a/app/models/hooks/web_hook_log_archived.rb b/app/models/hooks/web_hook_log_archived.rb deleted file mode 100644 index a1c8a44f5ba..00000000000 --- a/app/models/hooks/web_hook_log_archived.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -# This model is not intended to be used. -# It is a temporary reference to the old non-partitioned -# web_hook_logs table. -# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5558 -# for details. -# rubocop:disable Gitlab/NamespacedClass: This is a temporary class with no relevant namespace -# WebHook, WebHookLog and all hooks are defined outside of a namespace -class WebHookLogArchived < ApplicationRecord - self.table_name = 'web_hook_logs_archived' -end diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb index 7d73fd281f1..bc363cce8dd 100644 --- a/app/models/import_export_upload.rb +++ b/app/models/import_export_upload.rb @@ -11,7 +11,42 @@ class ImportExportUpload < ApplicationRecord mount_uploader :import_file, ImportExportUploader mount_uploader :export_file, ImportExportUploader + # This causes CarrierWave v1 and v3 (but not v2) to upload the file to + # object storage *after* the database entry has been committed to the + # database. This avoids idling in a transaction. + if Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_STORE_EXPORT_FILE_AFTER_COMMIT', true)) + skip_callback :save, :after, :store_export_file! + set_callback :commit, :after, :store_export_file! + end + + scope :updated_before, ->(date) { where('updated_at < ?', date) } + scope :with_export_file, -> { where.not(export_file: nil) } + def retrieve_upload(_identifier, paths) Upload.find_by(model: self, path: paths) end + + def export_file_exists? + !!carrierwave_export_file + end + + # This checks if the export archive is actually stored on disk. It + # requires a HEAD request if object storage is used. + def export_archive_exists? + !!carrierwave_export_file&.exists? + # Handle any HTTP unexpected error + # https://github.com/excon/excon/blob/bbb5bd791d0bb2251593b80e3bce98dbec6e8f24/lib/excon/error.rb#L129-L169 + rescue Excon::Error => e + # The HEAD request will fail with a 403 Forbidden if the file does not + # exist, and the user does not have permission to list the object + # storage bucket. + Gitlab::ErrorTracking.track_exception(e) + false + end + + private + + def carrierwave_export_file + export_file&.file + end end diff --git a/app/models/integration.rb b/app/models/integration.rb index 13203cd4e95..238ecbbf209 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -6,7 +6,7 @@ class Integration < ApplicationRecord include Sortable include Importable include ProjectServicesLoggable - include DataFields + include Integrations::HasDataFields include FromUnion include EachBatch @@ -29,6 +29,27 @@ class Integration < ApplicationRecord mock_ci mock_monitoring ].freeze + # Base classes which aren't actual integrations. + BASE_CLASSES = %w[ + Integrations::BaseChatNotification + Integrations::BaseCi + Integrations::BaseIssueTracker + Integrations::BaseMonitoring + Integrations::BaseSlashCommands + ].freeze + + # used as part of the renaming effort (https://gitlab.com/groups/gitlab-org/-/epics/2504) + RENAMED_TO_INTEGRATION = %w[ + asana assembla + bamboo bugzilla buildkite + campfire confluence custom_issue_tracker + datadog discord drone_ci + ].to_set.freeze + + def self.renamed?(name) + RENAMED_TO_INTEGRATION.include?(name) + end + serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize attribute :type, Gitlab::Integrations::StiType.new @@ -59,7 +80,7 @@ class Integration < ApplicationRecord validates :project_id, presence: true, unless: -> { template? || instance_level? || group_level? } validates :group_id, presence: true, unless: -> { template? || instance_level? || project_level? } validates :project_id, :group_id, absence: true, if: -> { template? || instance_level? } - validates :type, presence: true + validates :type, presence: true, exclusion: BASE_CLASSES validates :type, uniqueness: { scope: :template }, if: :template? validates :type, uniqueness: { scope: :instance }, if: :instance_level? validates :type, uniqueness: { scope: :project_id }, if: :project_level? @@ -185,7 +206,7 @@ class Integration < ApplicationRecord def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil) return unless name.in?(available_services_names(include_project_specific: false)) - service_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id) + integration_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id) end def self.find_or_initialize_all_non_project_specific(scope) @@ -194,7 +215,7 @@ class Integration < ApplicationRecord def self.build_nonexistent_services_for(scope) nonexistent_services_types_for(scope).map do |service_type| - service_type_to_model(service_type).new + integration_type_to_model(service_type).new end end private_class_method :build_nonexistent_services_for @@ -210,6 +231,7 @@ class Integration < ApplicationRecord # Returns a list of available service names. # Example: ["asana", ...] + # @deprecated def self.available_services_names(include_project_specific: true, include_dev: true) service_names = services_names service_names += project_specific_services_names if include_project_specific @@ -218,10 +240,14 @@ class Integration < ApplicationRecord service_names.sort_by(&:downcase) end - def self.services_names + def self.integration_names INTEGRATION_NAMES end + def self.services_names + integration_names + end + def self.dev_services_names return [] unless Rails.env.development? @@ -236,29 +262,29 @@ class Integration < ApplicationRecord # Example: ["AsanaService", ...] def self.available_services_types(include_project_specific: true, include_dev: true) available_services_names(include_project_specific: include_project_specific, include_dev: include_dev).map do |service_name| - service_name_to_type(service_name) + integration_name_to_type(service_name) end end # Returns the model for the given service name. # Example: "asana" => Integrations::Asana - def self.service_name_to_model(name) - type = service_name_to_type(name) - service_type_to_model(type) + def self.integration_name_to_model(name) + type = integration_name_to_type(name) + integration_type_to_model(type) end # Returns the STI type for the given service name. # Example: "asana" => "AsanaService" - def self.service_name_to_type(name) + def self.integration_name_to_type(name) "#{name}_service".camelize end # Returns the model for the given STI type. # Example: "AsanaService" => Integrations::Asana - def self.service_type_to_model(type) + def self.integration_type_to_model(type) Gitlab::Integrations::StiType.new.cast(type).constantize end - private_class_method :service_type_to_model + private_class_method :integration_type_to_model def self.build_from_integration(integration, project_id: nil, group_id: nil) new_integration = integration.dup @@ -480,10 +506,6 @@ class Integration < ApplicationRecord ProjectServiceWorker.perform_async(id, data) end - def external_wiki? - type == 'ExternalWikiService' && active? - end - # override if needed def supports_data_fields? false diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 82111c7322e..dbd7aedf4fe 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Integrations - class Bamboo < CiService + class Bamboo < BaseCi include ActionView::Helpers::UrlHelper include ReactiveService diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb new file mode 100644 index 00000000000..5eae8bce92a --- /dev/null +++ b/app/models/integrations/base_chat_notification.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +# Base class for Chat notifications services +# This class is not meant to be used directly, but only to inherit from. + +module Integrations + class BaseChatNotification < Integration + include ChatMessage + include NotificationBranchSelection + + SUPPORTED_EVENTS = %w[ + push issue confidential_issue merge_request note confidential_note + tag_push pipeline wiki_page deployment + ].freeze + + SUPPORTED_EVENTS_FOR_LABEL_FILTER = %w[issue confidential_issue merge_request note confidential_note].freeze + + EVENT_CHANNEL = proc { |event| "#{event}_channel" } + + LABEL_NOTIFICATION_BEHAVIOURS = [ + MATCH_ANY_LABEL = 'match_any', + MATCH_ALL_LABELS = 'match_all' + ].freeze + + default_value_for :category, 'chat' + + prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior + + # Custom serialized properties initialization + prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) + + boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch + + validates :webhook, presence: true, public_url: true, if: :activated? + validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true + + def initialize_properties + if properties.nil? + self.properties = {} + self.notify_only_broken_pipelines = true + self.branches_to_be_notified = "default" + self.labels_to_be_notified_behavior = MATCH_ANY_LABEL + elsif !self.notify_only_default_branch.nil? + # In older versions, there was only a boolean property named + # `notify_only_default_branch`. Now we have a string property named + # `branches_to_be_notified`. Instead of doing a background migration, we + # opted to set a value for the new property based on the old one, if + # users haven't specified one already. When users edit the service and + # select a value for this new property, it will override everything. + + self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" + end + end + + def confidential_issue_channel + properties['confidential_issue_channel'].presence || properties['issue_channel'] + end + + def confidential_note_channel + properties['confidential_note_channel'].presence || properties['note_channel'] + end + + def self.supported_events + SUPPORTED_EVENTS + end + + def fields + default_fields + build_event_channels + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}", required: true }.freeze, + { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, + { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, + { + type: 'text', + name: 'labels_to_be_notified', + placeholder: '~backend,~frontend', + help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' + }.freeze, + { + type: 'select', + name: 'labels_to_be_notified_behavior', + choices: [ + ['Match any of the labels', MATCH_ANY_LABEL], + ['Match all of the labels', MATCH_ALL_LABELS] + ] + }.freeze + ].freeze + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + return unless webhook.present? + + object_kind = data[:object_kind] + + data = custom_data(data) + + return unless notify_label?(data) + + # WebHook events often have an 'update' event that follows a 'open' or + # 'close' action. Ignore update events for now to prevent duplicate + # messages from arriving. + + message = get_message(object_kind, data) + + return false unless message + + event_type = data[:event_type] || object_kind + + channel_names = get_channel_field(event_type).presence || channel.presence + channels = channel_names&.split(',')&.map(&:strip) + + opts = {} + opts[:channel] = channels if channels.present? + opts[:username] = username if username + + if notify(message, opts) + log_usage(event_type, user_id_from_hook_data(data)) + return true + end + + false + end + + def event_channel_names + supported_events.map { |event| event_channel_name(event) } + end + + def event_field(event) + fields.find { |field| field[:name] == event_channel_name(event) } + end + + def global_fields + fields.reject { |field| field[:name].end_with?('channel') } + end + + def default_channel_placeholder + raise NotImplementedError + end + + private + + def log_usage(_, _) + # Implement in child class + end + + def labels_to_be_notified_list + return [] if labels_to_be_notified.nil? + + labels_to_be_notified.delete('~').split(',').map(&:strip) + end + + def notify_label?(data) + return true unless SUPPORTED_EVENTS_FOR_LABEL_FILTER.include?(data[:object_kind]) && labels_to_be_notified.present? + + labels = data[:labels] || data.dig(:issue, :labels) || data.dig(:merge_request, :labels) || data.dig(:object_attributes, :labels) + + return false if labels.blank? + + matching_labels = labels_to_be_notified_list & labels.pluck(:title) + + if labels_to_be_notified_behavior == MATCH_ALL_LABELS + labels_to_be_notified_list.difference(matching_labels).empty? + else + matching_labels.any? + end + end + + def user_id_from_hook_data(data) + data.dig(:user, :id) || data[:user_id] + end + + # every notifier must implement this independently + def notify(message, opts) + raise NotImplementedError + end + + def custom_data(data) + data.merge(project_url: project_url, project_name: project_name).with_indifferent_access + end + + def get_message(object_kind, data) + case object_kind + when "push", "tag_push" + Integrations::ChatMessage::PushMessage.new(data) if notify_for_ref?(data) + when "issue" + Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) + when "merge_request" + Integrations::ChatMessage::MergeMessage.new(data) unless update?(data) + when "note" + Integrations::ChatMessage::NoteMessage.new(data) + when "pipeline" + Integrations::ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) + when "wiki_page" + Integrations::ChatMessage::WikiPageMessage.new(data) + when "deployment" + Integrations::ChatMessage::DeploymentMessage.new(data) + end + end + + def get_channel_field(event) + field_name = event_channel_name(event) + self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend + end + + def build_event_channels + supported_events.reduce([]) do |channels, event| + channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel_placeholder } + end + end + + def event_channel_name(event) + EVENT_CHANNEL[event] + end + + def project_name + project.full_name + end + + def project_url + project.web_url + end + + def update?(data) + data[:object_attributes][:action] == 'update' + end + + def should_pipeline_be_notified?(data) + notify_for_ref?(data) && notify_for_pipeline?(data) + end + + def notify_for_ref?(data) + return true if data[:object_kind] == 'tag_push' + return true if data.dig(:object_attributes, :tag) + + notify_for_branch?(data) + end + + def notify_for_pipeline?(data) + case data[:object_attributes][:status] + when 'success' + !notify_only_broken_pipelines? + when 'failed' + true + else + false + end + end + end +end diff --git a/app/models/integrations/base_ci.rb b/app/models/integrations/base_ci.rb new file mode 100644 index 00000000000..b2e269b1b50 --- /dev/null +++ b/app/models/integrations/base_ci.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Base class for CI services +# List methods you need to implement to get your CI service +# working with GitLab merge requests +module Integrations + class BaseCi < Integration + default_value_for :category, 'ci' + + def valid_token?(token) + self.respond_to?(:token) && self.token.present? && ActiveSupport::SecurityUtils.secure_compare(token, self.token) + end + + def self.supported_events + %w(push) + end + + # Return complete url to build page + # + # Ex. + # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c + # + def build_page(sha, ref) + # implement inside child + end + + # Return string with build status or :error symbol + # + # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' + # + # + # Ex. + # @service.commit_status('13be4ac', 'master') + # # => 'success' + # + # @service.commit_status('2abe4ac', 'dev') + # # => 'running' + # + # + def commit_status(sha, ref) + # implement inside child + end + end +end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb new file mode 100644 index 00000000000..6c24f762cd5 --- /dev/null +++ b/app/models/integrations/base_issue_tracker.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Integrations + class BaseIssueTracker < Integration + validate :one_issue_tracker, if: :activated?, on: :manual_change + + # TODO: we can probably just delegate as part of + # https://gitlab.com/gitlab-org/gitlab/issues/29404 + data_field :project_url, :issues_url, :new_issue_url + + default_value_for :category, 'issue_tracker' + + before_validation :handle_properties + before_validation :set_default_data, on: :create + + # Pattern used to extract links from comments + # Override this method on services that uses different patterns + # This pattern does not support cross-project references + # The other code assumes that this pattern is a superset of all + # overridden patterns. See ReferenceRegexes.external_pattern + def self.reference_pattern(only_long: false) + if only_long + /(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/ + else + /(\b[A-Z][A-Z0-9_]*-|#{Issue.reference_prefix})#{Gitlab::Regex.issue}/ + end + end + + def handle_properties + # this has been moved from initialize_properties and should be improved + # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 + return unless properties + + @legacy_properties_data = properties.dup + data_values = properties.slice!('title', 'description') + data_values.reject! { |key| data_fields.changed.include?(key) } + data_values.slice!(*data_fields.attributes.keys) + data_fields.assign_attributes(data_values) if data_values.present? + + self.properties = {} + end + + def legacy_properties_data + @legacy_properties_data ||= {} + end + + def supports_data_fields? + true + end + + def data_fields + issue_tracker_data || self.build_issue_tracker_data + end + + def default? + default + end + + def issue_url(iid) + issues_url.gsub(':id', iid.to_s) + end + + def issue_tracker_path + project_url + end + + def new_issue_path + new_issue_url + end + + def issue_path(iid) + issue_url(iid) + end + + def fields + [ + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }, + { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true } + ] + end + + def initialize_properties + {} + end + + # Initialize with default properties values + def set_default_data + return unless issues_tracker.present? + + # we don't want to override if we have set something + return if project_url || issues_url || new_issue_url + + data_fields.project_url = issues_tracker['project_url'] + data_fields.issues_url = issues_tracker['issues_url'] + data_fields.new_issue_url = issues_tracker['new_issue_url'] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + message = "#{self.type} was unable to reach #{self.project_url}. Check the url and try again." + result = false + + begin + response = Gitlab::HTTP.head(self.project_url, verify: true) + + if response + message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" + result = true + end + rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error + message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" + end + log_info(message) + result + end + + def support_close_issue? + false + end + + def support_cross_reference? + false + end + + def create_cross_reference_note(mentioned, noteable, author) + # implement inside child + end + + private + + def enabled_in_gitlab_config + Gitlab.config.issues_tracker && + Gitlab.config.issues_tracker.values.any? && + issues_tracker + end + + def issues_tracker + Gitlab.config.issues_tracker[to_param] + end + + def one_issue_tracker + return if template? || instance? + return if project.blank? + + if project.integrations.external_issue_trackers.where.not(id: id).any? + errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time')) + end + end + end +end diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb new file mode 100644 index 00000000000..eacf1184aae --- /dev/null +++ b/app/models/integrations/base_slash_commands.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Base class for ChatOps integrations +# This class is not meant to be used directly, but only to inherrit from. +module Integrations + class BaseSlashCommands < Integration + default_value_for :category, 'chat' + + prop_accessor :token + + has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + def valid_token?(token) + self.respond_to?(:token) && + self.token.present? && + ActiveSupport::SecurityUtils.secure_compare(token, self.token) + end + + def self.supported_events + %w() + end + + def can_test? + false + end + + def fields + [ + { type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' } + ] + end + + def trigger(params) + return unless valid_token?(params[:token]) + + chat_user = find_chat_user(params) + user = chat_user&.user + + if user + unless user.can?(:use_slash_commands) + return Gitlab::SlashCommands::Presenters::Access.new.deactivated if user.deactivated? + + return Gitlab::SlashCommands::Presenters::Access.new.access_denied(project) + end + + Gitlab::SlashCommands::Command.new(project, chat_user, params).execute + else + url = authorize_chat_name_url(params) + Gitlab::SlashCommands::Presenters::Access.new(url).authorize + end + end + + private + + # rubocop: disable CodeReuse/ServiceClass + def find_chat_user(params) + ChatNames::FindUserService.new(self, params).execute + end + # rubocop: enable CodeReuse/ServiceClass + + # rubocop: disable CodeReuse/ServiceClass + def authorize_chat_name_url(params) + ChatNames::AuthorizeUserService.new(self, params).execute + end + # rubocop: enable CodeReuse/ServiceClass + end +end diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb new file mode 100644 index 00000000000..9251015acb8 --- /dev/null +++ b/app/models/integrations/bugzilla.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Integrations + class Bugzilla < BaseIssueTracker + include ActionView::Helpers::UrlHelper + + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def title + 'Bugzilla' + end + + def description + s_("IssueTracker|Use Bugzilla as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'bugzilla' + end + end +end diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb new file mode 100644 index 00000000000..906a5d02f9c --- /dev/null +++ b/app/models/integrations/buildkite.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "addressable/uri" + +module Integrations + class Buildkite < BaseCi + include ReactiveService + + ENDPOINT = "https://buildkite.com" + + prop_accessor :project_url, :token + + validates :project_url, presence: true, public_url: true, if: :activated? + validates :token, presence: true, if: :activated? + + after_save :compose_service_hook, if: :activated? + + def self.supported_events + %w(push merge_request tag_push) + end + + # This is a stub method to work with deprecated API response + # TODO: remove enable_ssl_verification after 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 + def enable_ssl_verification + true + end + + # Since SSL verification will always be enabled for Buildkite, + # we no longer needs to store the boolean. + # This is a stub method to work with deprecated API param. + # TODO: remove enable_ssl_verification after 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 + def enable_ssl_verification=(_value) + self.properties.delete('enable_ssl_verification') # Remove unused key + end + + def webhook_url + "#{buildkite_endpoint('webhook')}/deliver/#{webhook_token}" + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = webhook_url + hook.enable_ssl_verification = true + hook.save + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end + + def commit_status_path(sha) + "#{buildkite_endpoint('gitlab')}/status/#{status_token}.json?commit=#{sha}" + end + + def build_page(sha, ref) + "#{project_url}/builds?commit=#{sha}" + end + + def title + 'Buildkite' + end + + def description + 'Run CI/CD pipelines with Buildkite.' + end + + def self.to_param + 'buildkite' + end + + def fields + [ + { type: 'text', + name: 'token', + title: 'Integration Token', + help: 'This token will be provided when you create a Buildkite pipeline with a GitLab repository', + required: true }, + + { type: 'text', + name: 'project_url', + title: 'Pipeline URL', + placeholder: "#{ENDPOINT}/acme-inc/test-pipeline", + required: true } + ] + end + + def calculate_reactive_cache(sha, ref) + response = Gitlab::HTTP.try_get(commit_status_path(sha), request_options) + + status = + if response&.code == 200 && response['status'] + response['status'] + else + :error + end + + { commit_status: status } + end + + private + + def webhook_token + token_parts.first + end + + def status_token + token_parts.second + end + + def token_parts + if token.present? + token.split(':') + else + [] + end + end + + def buildkite_endpoint(subdomain = nil) + if subdomain.present? + uri = Addressable::URI.parse(ENDPOINT) + new_endpoint = "#{uri.scheme || 'http'}://#{subdomain}.#{uri.host}" + + if uri.port.present? + "#{new_endpoint}:#{uri.port}" + else + new_endpoint + end + else + ENDPOINT + end + end + + def request_options + { verify: false, extra_log_info: { project_id: project_id } } + end + end +end diff --git a/app/models/integrations/builds_email.rb b/app/models/integrations/builds_email.rb deleted file mode 100644 index 2628848667e..00000000000 --- a/app/models/integrations/builds_email.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This class is to be removed with 9.1 -# We should also by then remove BuildsEmailService from database -# https://gitlab.com/gitlab-org/gitlab/-/issues/331064 -module Integrations - class BuildsEmail < Integration - def self.to_param - 'builds_email' - end - - def self.supported_events - %w[] - end - end -end diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb index 2f70384d3b9..afe3ffc45a0 100644 --- a/app/models/integrations/chat_message/base_message.rb +++ b/app/models/integrations/chat_message/base_message.rb @@ -58,7 +58,7 @@ module Integrations end def format(string) - Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) + ::Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) end def format_relative_links(string) diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb index a0f6f582e4c..a3f68d34035 100644 --- a/app/models/integrations/chat_message/pipeline_message.rb +++ b/app/models/integrations/chat_message/pipeline_message.rb @@ -105,7 +105,7 @@ module Integrations def failed_stages_field { title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), + value: ::Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), short: true } end @@ -113,7 +113,7 @@ module Integrations def failed_jobs_field { title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), + value: ::Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), short: true } end @@ -130,12 +130,12 @@ module Integrations fields = [ { title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), - value: Slack::Messenger::Util::LinkFormatter.format(ref_link), + value: ::Slack::Messenger::Util::LinkFormatter.format(ref_link), short: true }, { title: s_("ChatMessage|Commit"), - value: Slack::Messenger::Util::LinkFormatter.format(commit_link), + value: ::Slack::Messenger::Util::LinkFormatter.format(commit_link), short: true } ] diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb index 0952986e923..fabd214633b 100644 --- a/app/models/integrations/chat_message/push_message.rb +++ b/app/models/integrations/chat_message/push_message.rb @@ -49,7 +49,7 @@ module Integrations end def format(string) - Slack::Messenger::Util::LinkFormatter.format(string) + ::Slack::Messenger::Util::LinkFormatter.format(string) end def commit_messages diff --git a/app/models/integrations/chat_message/wiki_page_message.rb b/app/models/integrations/chat_message/wiki_page_message.rb index 9b5275b8c03..00f0f911b0e 100644 --- a/app/models/integrations/chat_message/wiki_page_message.rb +++ b/app/models/integrations/chat_message/wiki_page_message.rb @@ -7,6 +7,7 @@ module Integrations attr_reader :wiki_page_url attr_reader :action attr_reader :description + attr_reader :diff_url def initialize(params) super @@ -16,6 +17,7 @@ module Integrations @title = obj_attr[:title] @wiki_page_url = obj_attr[:url] @description = obj_attr[:message] + @diff_url = obj_attr[:diff_url] @action = case obj_attr[:action] @@ -44,19 +46,23 @@ module Integrations private def message - "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*" + "#{user_combined_name} #{action} #{wiki_page_link} (#{diff_link}) in #{project_link}: *#{title}*" end def description_message [{ text: format(@description), color: attachment_color }] end + def diff_link + link('Compare changes', diff_url) + end + def project_link - "[#{project_name}](#{project_url})" + link(project_name, project_url) end def wiki_page_link - "[wiki page](#{wiki_page_url})" + link('wiki page', wiki_page_url) end end end diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb new file mode 100644 index 00000000000..635a9d093e9 --- /dev/null +++ b/app/models/integrations/custom_issue_tracker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Integrations + class CustomIssueTracker < BaseIssueTracker + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def title + s_('IssueTracker|Custom issue tracker') + end + + def description + s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' + s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'custom_issue_tracker' + end + end +end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb new file mode 100644 index 00000000000..ef6d46fd3d3 --- /dev/null +++ b/app/models/integrations/discord.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "discordrb/webhooks" + +module Integrations + class Discord < BaseChatNotification + include ActionView::Helpers::UrlHelper + + ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze + + def title + s_("DiscordService|Discord Notifications") + end + + def description + s_("DiscordService|Send notifications about project events to a Discord channel.") + end + + def self.to_param + "discord" + end + + def help + docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' + s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def event_field(event) + # No-op. + end + + def default_channel_placeholder + # No-op. + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] + end + + def default_fields + [ + { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." }, + { type: "checkbox", name: "notify_only_broken_pipelines" }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + client = Discordrb::Webhooks::Client.new(url: webhook) + + client.execute do |builder| + builder.add_embed do |embed| + embed.author = Discordrb::Webhooks::EmbedAuthor.new(name: message.user_name, icon_url: message.user_avatar) + embed.description = (message.pretext + "\n" + Array.wrap(message.attachments).join("\n")).gsub(ATTACHMENT_REGEX, " \\k<entry> - \\k<name>\n") + end + end + rescue RestClient::Exception => error + log_error(error.message) + false + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb new file mode 100644 index 00000000000..096f7093b8c --- /dev/null +++ b/app/models/integrations/drone_ci.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Integrations + class DroneCi < BaseCi + include ReactiveService + include ServicePushDataValidations + + prop_accessor :drone_url, :token + boolean_accessor :enable_ssl_verification + + validates :drone_url, presence: true, public_url: true, if: :activated? + validates :token, presence: true, if: :activated? + + after_save :compose_service_hook, if: :activated? + + def compose_service_hook + hook = service_hook || build_service_hook + # If using a service template, project may not be available + hook.url = [drone_url, "/api/hook", "?owner=#{project.namespace.full_path}", "&name=#{project.path}", "&access_token=#{token}"].join if project + hook.enable_ssl_verification = !!enable_ssl_verification + hook.save + end + + def execute(data) + case data[:object_kind] + when 'push' + service_hook.execute(data) if push_valid?(data) + when 'merge_request' + service_hook.execute(data) if merge_request_valid?(data) + when 'tag_push' + service_hook.execute(data) if tag_push_valid?(data) + end + end + + def allow_target_ci? + true + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def commit_status_path(sha, ref) + Gitlab::Utils.append_path( + drone_url, + "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}") + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) { |cached| cached[:commit_status] } + end + + def calculate_reactive_cache(sha, ref) + response = Gitlab::HTTP.try_get(commit_status_path(sha, ref), + verify: enable_ssl_verification, + extra_log_info: { project_id: project_id }) + + status = + if response && response.code == 200 && response['status'] + case response['status'] + when 'killed' + :canceled + when 'failure', 'error' + # Because drone return error if some test env failed + :failed + else + response["status"] + end + else + :error + end + + { commit_status: status } + end + + def build_page(sha, ref) + Gitlab::Utils.append_path( + drone_url, + "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}") + end + + def title + 'Drone' + end + + def description + s_('ProjectService|Run CI/CD pipelines with Drone.') + end + + def self.to_param + 'drone_ci' + end + + def help + s_('ProjectService|Run CI/CD pipelines with Drone.') + end + + def fields + [ + { type: 'text', name: 'token', help: s_('ProjectService|Token for the Drone project.'), required: true }, + { type: 'text', name: 'drone_url', title: s_('ProjectService|Drone server URL'), placeholder: 'http://drone.example.com', required: true }, + { type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" } + ] + end + end +end diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb new file mode 100644 index 00000000000..0a4e8d92ed7 --- /dev/null +++ b/app/models/integrations/ewm.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + class Ewm < BaseIssueTracker + include ActionView::Helpers::UrlHelper + + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def self.reference_pattern(only_long: true) + @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i + end + + def title + 'EWM' + end + + def description + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'ewm' + end + + def can_test? + false + end + + def issue_url(iid) + issues_url.gsub(':id', iid.to_s.split(' ')[-1]) + end + end +end diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb new file mode 100644 index 00000000000..fec435443fa --- /dev/null +++ b/app/models/integrations/external_wiki.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Integrations + class ExternalWiki < Integration + include ActionView::Helpers::UrlHelper + + prop_accessor :external_wiki_url + validates :external_wiki_url, presence: true, public_url: true, if: :activated? + + def title + s_('ExternalWikiService|External wiki') + end + + def description + s_('ExternalWikiService|Link to an external wiki from the sidebar.') + end + + def self.to_param + 'external_wiki' + end + + def fields + [ + { + type: 'text', + name: 'external_wiki_url', + title: s_('ExternalWikiService|External wiki URL'), + placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'), + help: 'Enter the URL to the external wiki.', + required: true + } + ] + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' + + s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def execute(_data) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) + response.body if response.code == 200 + rescue StandardError + nil + end + + def self.supported_events + %w() + end + end +end diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb new file mode 100644 index 00000000000..443f61e65dd --- /dev/null +++ b/app/models/integrations/flowdock.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Integrations + class Flowdock < Integration + include ActionView::Helpers::UrlHelper + + prop_accessor :token + validates :token, presence: true, if: :activated? + + def title + 'Flowdock' + end + + def description + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'flowdock' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: s_('FlowdockService|1b609b52537...'), required: true, help: 'Enter your Flowdock token.' } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + ::Flowdock::Git.post( + data[:ref], + data[:before], + data[:after], + token: token, + repo: project.repository, + repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", + commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/%s", + diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" + ) + end + end +end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb new file mode 100644 index 00000000000..d02cfe4ec56 --- /dev/null +++ b/app/models/integrations/hangouts_chat.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Integrations + class HangoutsChat < BaseChatNotification + include ActionView::Helpers::UrlHelper + + def title + 'Google Chat' + end + + def description + 'Send notifications from GitLab to a room in Google Chat.' + end + + def self.to_param + 'hangouts_chat' + end + + def help + docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' + s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def webhook_placeholder + 'https://chat.googleapis.com/v1/spaces…' + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + simple_text = parse_simple_text_message(message) + ::HangoutsChat::Sender.new(webhook).simple(simple_text) + end + + def parse_simple_text_message(message) + header = message.pretext + return header if message.attachments.empty? + + attachment = message.attachments.first + title = format_attachment_title(attachment) + body = attachment[:text] + + [header, title, body].compact.join("\n") + end + + def format_attachment_title(attachment) + return attachment[:title] unless attachment[:title_link] + + "<#{attachment[:title_link]}|#{attachment[:title]}>" + end + end +end diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb new file mode 100644 index 00000000000..7048dd641ea --- /dev/null +++ b/app/models/integrations/irker.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'uri' + +module Integrations + class Irker < Integration + prop_accessor :server_host, :server_port, :default_irc_uri + prop_accessor :recipients, :channels + boolean_accessor :colorize_messages + validates :recipients, presence: true, if: :validate_recipients? + + before_validation :get_channels + + def title + 'Irker (IRC gateway)' + end + + def description + 'Send IRC messages.' + end + + def self.to_param + 'irker' + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + IrkerWorker.perform_async(project_id, channels, + colorize_messages, data, settings) + end + + def settings + { + server_host: server_host.presence || 'localhost', + server_port: server_port.presence || 6659 + } + end + + def fields + [ + { type: 'text', name: 'server_host', placeholder: 'localhost', + help: 'Irker daemon hostname (defaults to localhost)' }, + { type: 'text', name: 'server_port', placeholder: 6659, + help: 'Irker daemon port (defaults to 6659)' }, + { type: 'text', name: 'default_irc_uri', title: 'Default IRC URI', + help: 'A default IRC URI to prepend before each recipient (optional)', + placeholder: 'irc://irc.network.net:6697/' }, + { type: 'textarea', name: 'recipients', + placeholder: 'Recipients/channels separated by whitespaces', required: true, + help: 'Recipients have to be specified with a full URI: '\ + 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ + 'you want the channel to be a nickname instead, append ",isnick" to ' \ + 'the channel name; if the channel is protected by a secret password, ' \ + ' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \ + ' want to use a password, you have to omit the "#" on the channel). If you ' \ + ' specify a default IRC URI to prepend before each recipient, you can just ' \ + ' give a channel name.' }, + { type: 'checkbox', name: 'colorize_messages' } + ] + end + + def help + ' NOTE: Irker does NOT have built-in authentication, which makes it' \ + ' vulnerable to spamming IRC channels if it is hosted outside of a ' \ + ' firewall. Please make sure you run the daemon within a secured network ' \ + ' to prevent abuse. For more details, read: http://www.catb.org/~esr/irker/security.html.' + end + + private + + def get_channels + return true unless activated? + return true if recipients.nil? || recipients.empty? + + map_recipients + + errors.add(:recipients, 'are all invalid') if channels.empty? + true + end + + def map_recipients + self.channels = recipients.split(/\s+/).map do |recipient| + format_channel(recipient) + end + channels.reject!(&:nil?) + end + + def format_channel(recipient) + uri = nil + + # Try to parse the chan as a full URI + begin + uri = consider_uri(URI.parse(recipient)) + rescue URI::InvalidURIError + end + + unless uri.present? && default_irc_uri.nil? + begin + new_recipient = URI.join(default_irc_uri, '/', recipient).to_s + uri = consider_uri(URI.parse(new_recipient)) + rescue StandardError + log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) + end + end + + uri + end + + def consider_uri(uri) + return if uri.scheme.nil? + + # Authorize both irc://domain.com/#chan and irc://domain.com/chan + if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil? + uri.to_s + end + end + end +end diff --git a/app/models/integrations/issue_tracker_data.rb b/app/models/integrations/issue_tracker_data.rb new file mode 100644 index 00000000000..8749075149f --- /dev/null +++ b/app/models/integrations/issue_tracker_data.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + class IssueTrackerData < ApplicationRecord + include BaseDataFields + + attr_encrypted :project_url, encryption_options + attr_encrypted :issues_url, encryption_options + attr_encrypted :new_issue_url, encryption_options + end +end diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb new file mode 100644 index 00000000000..815e86bcaa1 --- /dev/null +++ b/app/models/integrations/jenkins.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Integrations + class Jenkins < BaseCi + include ActionView::Helpers::UrlHelper + + prop_accessor :jenkins_url, :project_name, :username, :password + + before_update :reset_password + + validates :jenkins_url, presence: true, addressable_url: true, if: :activated? + validates :project_name, presence: true, if: :activated? + validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? } + + default_value_for :push_events, true + default_value_for :merge_requests_events, false + default_value_for :tag_push_events, false + + after_save :compose_service_hook, if: :activated? + + def reset_password + # don't reset the password if a new one is provided + if (jenkins_url_changed? || username.blank?) && !password_touched? + self.password = nil + end + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data, "#{data[:object_kind]}_hook") + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 200 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def hook_url + url = URI.parse(jenkins_url) + url.path = File.join(url.path || '/', "project/#{project_name}") + url.user = ERB::Util.url_encode(username) unless username.blank? + url.password = ERB::Util.url_encode(password) unless password.blank? + url.to_s + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def title + 'Jenkins' + end + + def description + s_('Run CI/CD pipelines with Jenkins.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' + s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'jenkins' + end + + def fields + [ + { + type: 'text', + name: 'jenkins_url', + title: s_('ProjectService|Jenkins server URL'), + required: true, + placeholder: 'http://jenkins.example.com', + help: s_('The URL of the Jenkins server.') + }, + { + type: 'text', + name: 'project_name', + required: true, + placeholder: 'my_project_name', + help: s_('The name of the Jenkins project. Copy the name from the end of the URL to the project.') + }, + { + type: 'text', + name: 'username', + required: true, + help: s_('The username for the Jenkins server.') + }, + { + type: 'password', + name: 'password', + help: s_('The password for the Jenkins server.'), + non_empty_password_title: s_('ProjectService|Enter new password.'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current password.') + } + ] + end + end +end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb new file mode 100644 index 00000000000..aa143cc28e1 --- /dev/null +++ b/app/models/integrations/jira.rb @@ -0,0 +1,588 @@ +# frozen_string_literal: true + +# Accessible as Project#external_issue_tracker +module Integrations + class Jira < BaseIssueTracker + extend ::Gitlab::Utils::Override + include Gitlab::Routing + include ApplicationHelper + include ActionView::Helpers::AssetUrlHelper + include Gitlab::Utils::StrongMemoize + + PROJECTS_PER_PAGE = 50 + JIRA_CLOUD_HOST = '.atlassian.net' + + ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze + ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze + + validates :url, public_url: true, presence: true, if: :activated? + validates :api_url, public_url: true, allow_blank: true + validates :username, presence: true, if: :activated? + validates :password, presence: true, if: :activated? + + validates :jira_issue_transition_id, + format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|IDs must be a list of numbers that can be split with , or ;") }, + allow_blank: true + + # Jira Cloud version is deprecating authentication via username and password. + # We should use username/password for Jira Server and email/api_token for Jira Cloud, + # for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936. + + # TODO: we can probably just delegate as part of + # https://gitlab.com/gitlab-org/gitlab/issues/29404 + data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled, + :vulnerabilities_enabled, :vulnerabilities_issuetype + + before_update :reset_password + after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type? + + enum comment_detail: { + standard: 1, + all_details: 2 + } + + # When these are false GitLab does not create cross reference + # comments on Jira except when an issue gets transitioned. + def self.supported_events + %w(commit merge_request) + end + + def self.supported_event_actions + %w(comment) + end + + # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 + def self.reference_pattern(only_long: true) + @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ + end + + def initialize_properties + {} + end + + def data_fields + jira_tracker_data || self.build_jira_tracker_data + end + + def reset_password + data_fields.password = nil if reset_password? + end + + def set_default_data + return unless issues_tracker.present? + + return if url + + data_fields.url ||= issues_tracker['url'] + data_fields.api_url ||= issues_tracker['api_url'] + end + + def options + url = URI.parse(client_url) + + { + username: username&.strip, + password: password, + site: URI.join(url, '/').to_s.delete_suffix('/'), # Intended to find the root + context_path: (url.path.presence || '/').delete_suffix('/'), + auth_type: :basic, + read_timeout: 120, + use_cookies: true, + additional_cookies: ['OBBasicAuth=fromDialog'], + use_ssl: url.scheme == 'https' + } + end + + def client + @client ||= begin + JIRA::Client.new(options).tap do |client| + # Replaces JIRA default http client with our implementation + client.request_client = Gitlab::Jira::HttpClient.new(client.options) + end + end + end + + def help + jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } + s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } + end + + def title + 'Jira' + end + + def description + s_("JiraService|Use Jira as this project's issue tracker.") + end + + def self.to_param + 'jira' + end + + def fields + [ + { + type: 'text', + name: 'url', + title: s_('JiraService|Web URL'), + placeholder: 'https://jira.example.com', + help: s_('JiraService|Base URL of the Jira instance.'), + required: true + }, + { + type: 'text', + name: 'api_url', + title: s_('JiraService|Jira API URL'), + help: s_('JiraService|If different from Web URL.') + }, + { + type: 'text', + name: 'username', + title: s_('JiraService|Username or Email'), + help: s_('JiraService|Use a username for server version and an email for cloud version.'), + required: true + }, + { + type: 'password', + name: 'password', + title: s_('JiraService|Password or API token'), + non_empty_password_title: s_('JiraService|Enter new password or API token'), + non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'), + help: s_('JiraService|Use a password for server version and an API token for cloud version.'), + required: true + } + ] + end + + def web_url(path = nil, **params) + return unless url.present? + + if Gitlab.com? + params.merge!(ATLASSIAN_REFERRER_GITLAB_COM) unless Gitlab.staging? + else + params.merge!(ATLASSIAN_REFERRER_SELF_MANAGED) unless Gitlab.dev_or_test_env? + end + + url = Addressable::URI.parse(self.url) + url.path = url.path.delete_suffix('/') + url.path << "/#{path.delete_prefix('/').delete_suffix('/')}" if path.present? + url.query_values = (url.query_values || {}).merge(params) + url.query_values = nil if url.query_values.empty? + + url.to_s + end + + override :project_url + def project_url + web_url + end + + override :issues_url + def issues_url + web_url('browse/:id') + end + + override :new_issue_url + def new_issue_url + web_url('secure/CreateIssue!default.jspa') + end + + alias_method :original_url, :url + def url + original_url&.delete_suffix('/') + end + + alias_method :original_api_url, :api_url + def api_url + original_api_url&.delete_suffix('/') + end + + def execute(push) + # This method is a no-op, because currently Integrations::Jira does not + # support any events. + end + + def find_issue(issue_key, rendered_fields: false, transitions: false) + expands = [] + expands << 'renderedFields' if rendered_fields + expands << 'transitions' if transitions + options = { expand: expands.join(',') } if expands.any? + + jira_request { client.Issue.find(issue_key, options || {}) } + end + + def close_issue(entity, external_issue, current_user) + issue = find_issue(external_issue.iid, transitions: jira_issue_transition_automatic) + + return if issue.nil? || has_resolution?(issue) || !issue_transition_enabled? + + commit_id = case entity + when Commit then entity.id + when MergeRequest then entity.diff_head_sha + end + + commit_url = build_entity_url(:commit, commit_id) + + # Depending on the Jira project's workflow, a comment during transition + # may or may not be allowed. Refresh the issue after transition and check + # if it is closed, so we don't have one comment for every commit. + issue = find_issue(issue.key) if transition_issue(issue) + add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue) + log_usage(:close_issue, current_user) + end + + override :create_cross_reference_note + def create_cross_reference_note(mentioned, noteable, author) + unless can_cross_reference?(noteable) + return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) } + end + + jira_issue = find_issue(mentioned.id) + + return unless jira_issue.present? + + noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id + noteable_type = noteable_name(noteable) + entity_url = build_entity_url(noteable_type, noteable_id) + entity_meta = build_entity_meta(noteable) + + data = { + user: { + name: author.name, + url: resource_url(user_path(author)) + }, + project: { + name: project.full_path, + url: resource_url(project_path(project)) + }, + entity: { + id: entity_meta[:id], + name: noteable_type.humanize.downcase, + url: entity_url, + title: noteable.title, + description: entity_meta[:description], + branch: entity_meta[:branch] + } + } + + add_comment(data, jira_issue).tap { log_usage(:cross_reference, author) } + end + + def valid_connection? + test(nil)[:success] + end + + def test(_) + result = server_info + success = result.present? + result = @error&.message unless success + + { success: success, result: result } + end + + override :support_close_issue? + def support_close_issue? + true + end + + override :support_cross_reference? + def support_cross_reference? + true + end + + def issue_transition_enabled? + jira_issue_transition_automatic || jira_issue_transition_id.present? + end + + private + + def server_info + strong_memoize(:server_info) do + client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil + end + end + + def can_cross_reference?(noteable) + case noteable + when Commit then commit_events + when MergeRequest then merge_requests_events + else true + end + end + + # jira_issue_transition_id can have multiple values split by , or ; + # the issue is transitioned at the order given by the user + # if any transition fails it will log the error message and stop the transition sequence + def transition_issue(issue) + return transition_issue_to_done(issue) if jira_issue_transition_automatic + + jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).all? do |transition_id| + transition_issue_to_id(issue, transition_id) + end + end + + def transition_issue_to_id(issue, transition_id) + issue.transitions.build.save!( + transition: { id: transition_id } + ) + + true + rescue StandardError => error + log_error( + "Issue transition failed", + error: { + exception_class: error.class.name, + exception_message: error.message, + exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace) + }, + client_url: client_url + ) + + false + end + + def transition_issue_to_done(issue) + transitions = issue.transitions rescue [] + + transition = transitions.find do |transition| + status = transition&.to&.statusCategory + status && status['key'] == 'done' + end + + return false unless transition + + transition_issue_to_id(issue, transition.id) + end + + def log_usage(action, user) + key = "i_ecosystem_jira_service_#{action}" + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id) + end + + def add_issue_solved_comment(issue, commit_id, commit_url) + link_title = "Solved by commit #{commit_id}." + comment = "Issue solved with [#{commit_id}|#{commit_url}]." + link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) + send_message(issue, comment, link_props) + end + + def add_comment(data, issue) + entity_name = data[:entity][:name] + entity_url = data[:entity][:url] + entity_title = data[:entity][:title] + + message = comment_message(data) + link_title = "#{entity_name.capitalize} - #{entity_title}" + link_props = build_remote_link_props(url: entity_url, title: link_title) + + unless comment_exists?(issue, message) + send_message(issue, message, link_props) + end + end + + def comment_message(data) + user_link = build_jira_link(data[:user][:name], data[:user][:url]) + + entity = data[:entity] + entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}" + entity_link = build_jira_link(entity_ref, entity[:url]) + + project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project)) + branch = + if entity[:branch].present? + s_('JiraService| on branch %{branch_link}') % { + branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch])) + } + end + + entity_message = entity[:description].presence if all_details? + entity_message ||= entity[:title].chomp + + s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % { + user_link: user_link, + entity_link: entity_link, + project_link: project_link, + branch: branch, + entity_message: entity_message + } + end + + def build_jira_link(title, url) + "[#{title}|#{url}]" + end + + def has_resolution?(issue) + issue.respond_to?(:resolution) && issue.resolution.present? + end + + def comment_exists?(issue, message) + comments = jira_request { issue.comments } + + comments.present? && comments.any? { |comment| comment.body.include?(message) } + end + + def send_message(issue, message, remote_link_props) + return unless client_url.present? + + jira_request do + remote_link = find_remote_link(issue, remote_link_props[:object][:url]) + + create_issue_comment(issue, message) unless remote_link + remote_link ||= issue.remotelink.build + remote_link.save!(remote_link_props) + + log_info("Successfully posted", client_url: client_url) + "SUCCESS: Successfully posted to #{client_url}." + end + end + + def create_issue_comment(issue, message) + return unless comment_on_event_enabled + + issue.comments.build.save!(body: message) + end + + def find_remote_link(issue, url) + links = jira_request { issue.remotelink.all } + return unless links + + links.find { |link| link.object["url"] == url } + end + + def build_remote_link_props(url:, title:, resolved: false) + status = { + resolved: resolved + } + + { + GlobalID: 'GitLab', + relationship: 'mentioned on', + object: { + url: url, + title: title, + status: status, + icon: { + title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.base_url) + } + } + } + end + + def resource_url(resource) + "#{Settings.gitlab.base_url.chomp("/")}#{resource}" + end + + def build_entity_url(noteable_type, entity_id) + polymorphic_url( + [ + self.project, + noteable_type.to_sym + ], + id: entity_id, + host: Settings.gitlab.base_url + ) + end + + def build_entity_meta(noteable) + if noteable.is_a?(Commit) + { + id: noteable.short_id, + description: noteable.safe_message, + branch: noteable.ref_names(project.repository).first + } + elsif noteable.is_a?(MergeRequest) + { + id: noteable.to_reference, + branch: noteable.source_branch + } + else + {} + end + end + + def noteable_name(noteable) + name = noteable.model_name.singular + + # ProjectSnippet inherits from Snippet class so it causes + # routing error building the URL. + name == "project_snippet" ? "snippet" : name + end + + # Handle errors when doing Jira API calls + def jira_request + yield + rescue StandardError => error + @error = error + log_error("Error sending message", client_url: client_url, error: @error.message) + nil + end + + def client_url + api_url.presence || url + end + + def reset_password? + # don't reset the password if a new one is provided + return false if password_touched? + return true if api_url_changed? + return false if api_url.present? + + url_changed? + end + + def update_deployment_type? + (api_url_changed? || url_changed? || username_changed? || password_changed?) && + can_test? + end + + def update_deployment_type + clear_memoization(:server_info) # ensure we run the request when we try to update deployment type + results = server_info + + unless results.present? + Gitlab::AppLogger.warn(message: "Jira API returned no ServerInfo, setting deployment_type from URL", server_info: results, url: client_url) + + return set_deployment_type_from_url + end + + if jira_cloud? + data_fields.deployment_cloud! + else + data_fields.deployment_server! + end + end + + def jira_cloud? + server_info['deploymentType'] == 'Cloud' || URI(client_url).hostname.end_with?(JIRA_CLOUD_HOST) + end + + def set_deployment_type_from_url + # This shouldn't happen but of course it will happen when an integration is removed. + # Instead of deleting the integration we set all fields to null + # and mark it as inactive + return data_fields.deployment_unknown! unless client_url + + # If API-based detection methods fail here then + # we can only assume it's either Cloud or Server + # based on the URL being *.atlassian.net + + if URI(client_url).hostname.end_with?(JIRA_CLOUD_HOST) + data_fields.deployment_cloud! + else + data_fields.deployment_server! + end + end + + def self.event_description(event) + case event + when "merge_request", "merge_request_events" + s_("JiraService|Jira comments are created when an issue is referenced in a merge request.") + when "commit", "commit_events" + s_("JiraService|Jira comments are created when an issue is referenced in a commit.") + end + end + end +end + +Integrations::Jira.prepend_mod_with('Integrations::Jira') diff --git a/app/models/integrations/jira_tracker_data.rb b/app/models/integrations/jira_tracker_data.rb new file mode 100644 index 00000000000..74352393b43 --- /dev/null +++ b/app/models/integrations/jira_tracker_data.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Integrations + class JiraTrackerData < ApplicationRecord + include BaseDataFields + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :username, encryption_options + attr_encrypted :password, encryption_options + + enum deployment_type: { unknown: 0, server: 1, cloud: 2 }, _prefix: :deployment + end +end diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb new file mode 100644 index 00000000000..07a5086b8e9 --- /dev/null +++ b/app/models/integrations/mattermost.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Integrations + class Mattermost < BaseChatNotification + include SlackMattermostNotifier + include ActionView::Helpers::UrlHelper + + def title + s_('Mattermost notifications') + end + + def description + s_('Send notifications about project events to Mattermost channels.') + end + + def self.to_param + 'mattermost' + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer' + s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def default_channel_placeholder + 'my-channel' + end + + def webhook_placeholder + 'http://mattermost.example.com/hooks/' + end + end +end diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb new file mode 100644 index 00000000000..6cd664da9e7 --- /dev/null +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class MattermostSlashCommands < BaseSlashCommands + include Ci::TriggersHelper + + prop_accessor :token + + def can_test? + false + end + + def title + 'Mattermost slash commands' + end + + def description + "Perform common tasks with slash commands." + end + + def self.to_param + 'mattermost_slash_commands' + end + + def configure(user, params) + token = ::Mattermost::Command.new(user) + .create(command(params)) + + update(active: true, token: token) if token + rescue ::Mattermost::Error => e + [false, e.message] + end + + def list_teams(current_user) + [::Mattermost::Team.new(current_user).all, nil] + rescue ::Mattermost::Error => e + [[], e.message] + end + + def chat_responder + ::Gitlab::Chat::Responder::Mattermost + end + + private + + def command(params) + pretty_project_name = project.full_name + + params.merge( + auto_complete: true, + auto_complete_desc: "Perform common operations on: #{pretty_project_name}", + auto_complete_hint: '[help]', + description: "Perform common operations on: #{pretty_project_name}", + display_name: "GitLab / #{pretty_project_name}", + method: 'P', + username: 'GitLab') + end + end +end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb new file mode 100644 index 00000000000..91e6800f03c --- /dev/null +++ b/app/models/integrations/microsoft_teams.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class MicrosoftTeams < BaseChatNotification + def title + 'Microsoft Teams notifications' + end + + def description + 'Send notifications about project events to Microsoft Teams.' + end + + def self.to_param + 'microsoft_teams' + end + + def help + '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html">How do I configure this integration?</a></p>' + end + + def webhook_placeholder + 'https://outlook.office.com/webhook/…' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, + { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'If selected, successful pipelines do not trigger a notification event.' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + ::MicrosoftTeams::Notifier.new(webhook).ping( + title: message.project_name, + summary: message.summary, + activity: message.activity, + attachments: message.attachments + ) + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb new file mode 100644 index 00000000000..d31f6381767 --- /dev/null +++ b/app/models/integrations/mock_ci.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service +module Integrations + class MockCi < BaseCi + ALLOWED_STATES = %w[failed canceled running pending success success-with-warnings skipped not_found].freeze + + prop_accessor :mock_service_url + validates :mock_service_url, presence: true, public_url: true, if: :activated? + + def title + 'MockCI' + end + + def description + 'Mock an external CI' + end + + def self.to_param + 'mock_ci' + end + + def fields + [ + { + type: 'text', + name: 'mock_service_url', + title: s_('ProjectService|Mock service URL'), + placeholder: 'http://localhost:4004', + required: true + } + ] + end + + # Return complete url to build page + # + # Ex. + # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c + # + def build_page(sha, ref) + Gitlab::Utils.append_path( + mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}") + end + + # Return string with build status or :error symbol + # + # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' + # + # Ex. + # @service.commit_status('13be4ac', 'master') + # # => 'success' + # + # @service.commit_status('2abe4ac', 'dev') + # # => 'running' + # + def commit_status(sha, ref) + response = Gitlab::HTTP.get(commit_status_path(sha), verify: false) + read_commit_status(response) + rescue Errno::ECONNREFUSED + :error + end + + def commit_status_path(sha) + Gitlab::Utils.append_path( + mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}.json") + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'pending' + else + response['status'] + end + + if status.present? && ALLOWED_STATES.include?(status) + status + else + :error + end + end + + def can_test? + false + end + end +end diff --git a/app/models/integrations/open_project.rb b/app/models/integrations/open_project.rb new file mode 100644 index 00000000000..e4cfb24151a --- /dev/null +++ b/app/models/integrations/open_project.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + class OpenProject < BaseIssueTracker + validates :url, public_url: true, presence: true, if: :activated? + validates :api_url, public_url: true, allow_blank: true, if: :activated? + validates :token, presence: true, if: :activated? + validates :project_identifier_code, presence: true, if: :activated? + + data_field :url, :api_url, :token, :closed_status_id, :project_identifier_code + + def data_fields + open_project_tracker_data || self.build_open_project_tracker_data + end + + def self.to_param + 'open_project' + end + end +end diff --git a/app/models/integrations/open_project_tracker_data.rb b/app/models/integrations/open_project_tracker_data.rb new file mode 100644 index 00000000000..b3f2618b94f --- /dev/null +++ b/app/models/integrations/open_project_tracker_data.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Integrations + class OpenProjectTrackerData < ApplicationRecord + include BaseDataFields + + # When the Open Project is fresh installed, the default closed status id is "13" based on current version: v8. + DEFAULT_CLOSED_STATUS_ID = "13" + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :token, encryption_options + + def closed_status_id + super || DEFAULT_CLOSED_STATUS_ID + end + end +end diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb new file mode 100644 index 00000000000..b597bd11175 --- /dev/null +++ b/app/models/integrations/packagist.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Integrations + class Packagist < Integration + prop_accessor :username, :token, :server + + validates :username, presence: true, if: :activated? + validates :token, presence: true, if: :activated? + + default_value_for :push_events, true + default_value_for :tag_push_events, true + + after_save :compose_service_hook, if: :activated? + + def title + 'Packagist' + end + + def description + s_('Integrations|Update your Packagist projects.') + end + + def self.to_param + 'packagist' + end + + def fields + [ + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } + ] + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 202 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + base_url = server.presence || 'https://packagist.org' + "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" + end + end +end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb new file mode 100644 index 00000000000..585bc14242a --- /dev/null +++ b/app/models/integrations/pipelines_email.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Integrations + class PipelinesEmail < Integration + include NotificationBranchSelection + + prop_accessor :recipients, :branches_to_be_notified + boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch + validates :recipients, presence: true, if: :validate_recipients? + + def initialize_properties + if properties.nil? + self.properties = {} + self.notify_only_broken_pipelines = true + self.branches_to_be_notified = "default" + elsif !self.notify_only_default_branch.nil? + # In older versions, there was only a boolean property named + # `notify_only_default_branch`. Now we have a string property named + # `branches_to_be_notified`. Instead of doing a background migration, we + # opted to set a value for the new property based on the old one, if + # users hasn't specified one already. When users edit the service and + # selects a value for this new property, it will override everything. + + self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" + end + end + + def title + _('Pipeline status emails') + end + + def description + _('Email the pipeline status to a list of recipients.') + end + + def self.to_param + 'pipelines_email' + end + + def self.supported_events + %w[pipeline] + end + + def self.default_test_event + 'pipeline' + end + + def execute(data, force: false) + return unless supported_events.include?(data[:object_kind]) + return unless force || should_pipeline_be_notified?(data) + + all_recipients = retrieve_recipients(data) + + return unless all_recipients.any? + + pipeline_id = data[:object_attributes][:id] + PipelineNotificationWorker.new.perform(pipeline_id, recipients: all_recipients) + end + + def can_test? + project&.ci_pipelines&.any? + end + + def fields + [ + { type: 'textarea', + name: 'recipients', + help: _('Comma-separated list of email addresses.'), + required: true }, + { type: 'checkbox', + name: 'notify_only_broken_pipelines' }, + { type: 'select', + name: 'branches_to_be_notified', + choices: branch_choices } + ] + end + + def test(data) + result = execute(data, force: true) + + { success: true, result: result } + rescue StandardError => error + { success: false, result: error } + end + + def should_pipeline_be_notified?(data) + notify_for_branch?(data) && notify_for_pipeline?(data) + end + + def notify_for_pipeline?(data) + case data[:object_attributes][:status] + when 'success' + !notify_only_broken_pipelines? + when 'failed' + true + else + false + end + end + + def retrieve_recipients(data) + recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?) + end + end +end diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb new file mode 100644 index 00000000000..46f97cc3c6b --- /dev/null +++ b/app/models/integrations/pivotaltracker.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Integrations + class Pivotaltracker < Integration + API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' + + prop_accessor :token, :restrict_to_branch + validates :token, presence: true, if: :activated? + + def title + 'PivotalTracker' + end + + def description + s_('PivotalTrackerService|Add commit messages as comments to PivotalTracker stories.') + end + + def self.to_param + 'pivotaltracker' + end + + def fields + [ + { + type: 'text', + name: 'token', + placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'), + required: true + }, + { + type: 'text', + name: 'restrict_to_branch', + placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \ + 'automatically inspected. Leave blank to include all branches.') + } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + return unless allowed_branch?(data[:ref]) + + data[:commits].each do |commit| + message = { + 'source_commit' => { + 'commit_id' => commit[:id], + 'author' => commit[:author][:name], + 'url' => commit[:url], + 'message' => commit[:message] + } + } + Gitlab::HTTP.post( + API_ENDPOINT, + body: message.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-TrackerToken' => token + } + ) + end + end + + private + + def allowed_branch?(ref) + return true unless ref.present? && restrict_to_branch.present? + + branch = Gitlab::Git.ref_name(ref) + allowed_branches = restrict_to_branch.split(',').map(&:strip) + + branch.present? && allowed_branches.include?(branch) + end + end +end diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb new file mode 100644 index 00000000000..b0cadc7ef4e --- /dev/null +++ b/app/models/integrations/pushover.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Integrations + class Pushover < Integration + BASE_URI = 'https://api.pushover.net/1' + + prop_accessor :api_key, :user_key, :device, :priority, :sound + validates :api_key, :user_key, :priority, presence: true, if: :activated? + + def title + 'Pushover' + end + + def description + s_('PushoverService|Get real-time notifications on your device.') + end + + def self.to_param + 'pushover' + end + + def fields + [ + { type: 'text', name: 'api_key', title: _('API key'), placeholder: s_('PushoverService|Your application key'), required: true }, + { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true }, + { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') }, + { type: 'select', name: 'priority', required: true, choices: + [ + [s_('PushoverService|Lowest Priority'), -2], + [s_('PushoverService|Low Priority'), -1], + [s_('PushoverService|Normal Priority'), 0], + [s_('PushoverService|High Priority'), 1] + ], + default_choice: 0 }, + { type: 'select', name: 'sound', choices: + [ + ['Device default sound', nil], + ['Pushover (default)', 'pushover'], + %w(Bike bike), + %w(Bugle bugle), + ['Cash Register', 'cashregister'], + %w(Classical classical), + %w(Cosmic cosmic), + %w(Falling falling), + %w(Gamelan gamelan), + %w(Incoming incoming), + %w(Intermission intermission), + %w(Magic magic), + %w(Mechanical mechanical), + ['Piano Bar', 'pianobar'], + %w(Siren siren), + ['Space Alarm', 'spacealarm'], + ['Tug Boat', 'tugboat'], + ['Alien Alarm (long)', 'alien'], + ['Climb (long)', 'climb'], + ['Persistent (long)', 'persistent'], + ['Pushover Echo (long)', 'echo'], + ['Up Down (long)', 'updown'], + ['None (silent)', 'none'] + ] } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + ref = Gitlab::Git.ref_name(data[:ref]) + before = data[:before] + after = data[:after] + + message = + if Gitlab::Git.blank_ref?(before) + s_("PushoverService|%{user_name} pushed new branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } + elsif Gitlab::Git.blank_ref?(after) + s_("PushoverService|%{user_name} deleted branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } + else + s_("PushoverService|%{user_name} push to branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } + end + + if data[:total_commits_count] > 0 + message = [message, s_("PushoverService|Total commits count: %{total_commits_count}") % { total_commits_count: data[:total_commits_count] }].join("\n") + end + + pushover_data = { + token: api_key, + user: user_key, + device: device, + priority: priority, + title: "#{project.full_name}", + message: message, + url: data[:project][:web_url], + url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name } + } + + # Sound parameter MUST NOT be sent to API if not selected + if sound + pushover_data[:sound] = sound + end + + Gitlab::HTTP.post('/messages.json', base_uri: BASE_URI, body: pushover_data) + end + end +end diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb new file mode 100644 index 00000000000..990b538f294 --- /dev/null +++ b/app/models/integrations/redmine.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Integrations + class Redmine < BaseIssueTracker + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def title + 'Redmine' + end + + def description + s_("IssueTracker|Use Redmine as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer' + s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'redmine' + end + end +end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb new file mode 100644 index 00000000000..0381db3a67e --- /dev/null +++ b/app/models/integrations/slack.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class Slack < BaseChatNotification + include SlackMattermostNotifier + extend ::Gitlab::Utils::Override + + SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[ + push issue confidential_issue merge_request note confidential_note + tag_push wiki_page deployment + ].freeze + + prop_accessor EVENT_CHANNEL['alert'] + + def title + 'Slack notifications' + end + + def description + 'Send notifications about project events to Slack.' + end + + def self.to_param + 'slack' + end + + def default_channel_placeholder + _('#general, #development') + end + + def webhook_placeholder + 'https://hooks.slack.com/services/…' + end + + def supported_events + additional = [] + additional << 'alert' + + super + additional + end + + def get_message(object_kind, data) + return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' + + super + end + + override :log_usage + def log_usage(event, user_id) + return unless user_id + + return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event) + + key = "i_ecosystem_slack_service_#{event}_notification" + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) + end + end +end diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb new file mode 100644 index 00000000000..ff1f806df45 --- /dev/null +++ b/app/models/integrations/slack_slash_commands.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Integrations + class SlackSlashCommands < BaseSlashCommands + include Ci::TriggersHelper + + def title + 'Slack slash commands' + end + + def description + "Perform common operations in Slack" + end + + def self.to_param + 'slack_slash_commands' + end + + def trigger(params) + # Format messages to be Slack-compatible + super.tap do |result| + result[:text] = format(result[:text]) if result.is_a?(Hash) + end + end + + def chat_responder + ::Gitlab::Chat::Responder::Slack + end + + private + + def format(text) + ::Slack::Messenger::Util::LinkFormatter.format(text) if text + end + end +end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb new file mode 100644 index 00000000000..8284d5963ae --- /dev/null +++ b/app/models/integrations/teamcity.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +module Integrations + class Teamcity < BaseCi + include ReactiveService + include ServicePushDataValidations + + prop_accessor :teamcity_url, :build_type, :username, :password + + validates :teamcity_url, presence: true, public_url: true, if: :activated? + validates :build_type, presence: true, if: :activated? + validates :username, + presence: true, + if: ->(service) { service.activated? && service.password } + validates :password, + presence: true, + if: ->(service) { service.activated? && service.username } + + attr_accessor :response + + after_save :compose_service_hook, if: :activated? + before_update :reset_password + + class << self + def to_param + 'teamcity' + end + + def supported_events + %w(push merge_request) + end + + def event_description(event) + case event + when 'push', 'push_events' + 'TeamCity CI will be triggered after every push to the repository except branch delete' + when 'merge_request', 'merge_request_events' + 'TeamCity CI will be triggered after a merge request has been created or updated' + end + end + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.save + end + + def reset_password + if teamcity_url_changed? && !password_touched? + self.password = nil + end + end + + def title + 'JetBrains TeamCity' + end + + def description + s_('ProjectService|Run CI/CD pipelines with JetBrains TeamCity.') + end + + def help + s_('To run CI/CD pipelines with JetBrains TeamCity, input the GitLab project details in the TeamCity project Version Control Settings.') + end + + def fields + [ + { + type: 'text', + name: 'teamcity_url', + title: s_('ProjectService|TeamCity server URL'), + placeholder: 'https://teamcity.example.com', + required: true + }, + { + type: 'text', + name: 'build_type', + help: s_('ProjectService|The build configuration ID of the TeamCity project.'), + required: true + }, + { + type: 'text', + name: 'username', + help: s_('ProjectService|Must have permission to trigger a manual build in TeamCity.') + }, + { + type: 'password', + name: 'password', + non_empty_password_title: s_('ProjectService|Enter new password'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current password') + } + ] + end + + def build_page(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:build_page] } + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end + + def calculate_reactive_cache(sha, ref) + response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}") + + if response + { build_page: read_build_page(response), commit_status: read_commit_status(response) } + else + { build_page: teamcity_url, commit_status: :error } + end + end + + def execute(data) + case data[:object_kind] + when 'push' + execute_push(data) + when 'merge_request' + execute_merge_request(data) + end + end + + private + + def execute_push(data) + branch = Gitlab::Git.ref_name(data[:ref]) + post_to_build_queue(data, branch) if push_valid?(data) + end + + def execute_merge_request(data) + branch = data[:object_attributes][:source_branch] + post_to_build_queue(data, branch) if merge_request_valid?(data) + end + + def read_build_page(response) + if response.code != 200 + # If actual build link can't be determined, + # send user to build summary page. + build_url("viewLog.html?buildTypeId=#{build_type}") + else + # If actual build link is available, go to build result page. + built_id = response['build']['id'] + build_url("viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}") + end + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'Pending' + else + response['build']['status'] + end + + return :error unless status.present? + + if status.include?('SUCCESS') + 'success' + elsif status.include?('FAILURE') + 'failed' + elsif status.include?('Pending') + 'pending' + else + :error + end + end + + def build_url(path) + Gitlab::Utils.append_path(teamcity_url, path) + end + + def get_path(path) + Gitlab::HTTP.try_get(build_url(path), verify: false, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) + end + + def post_to_build_queue(data, branch) + Gitlab::HTTP.post( + build_url('httpAuth/app/rest/buildQueue'), + body: "<build branchName=#{branch.encode(xml: :attr)}>"\ + "<buildType id=#{build_type.encode(xml: :attr)}/>"\ + '</build>', + headers: { 'Content-type' => 'application/xml' }, + basic_auth: basic_auth + ) + end + + def basic_auth + { username: username, password: password } + end + end +end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb new file mode 100644 index 00000000000..03363c7c8b0 --- /dev/null +++ b/app/models/integrations/unify_circuit.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Integrations + class UnifyCircuit < BaseChatNotification + def title + 'Unify Circuit' + end + + def description + s_('Integrations|Send notifications about project events to Unify Circuit.') + end + + def self.to_param + 'unify_circuit' + end + + def help + 'This service sends notifications about projects events to a Unify Circuit conversation.<br /> + To set up this service: + <ol> + <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications.</li> + </ol>' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + response = Gitlab::HTTP.post(webhook, body: { + subject: message.project_name, + text: message.summary, + markdown: true + }.to_json) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb new file mode 100644 index 00000000000..3f420331035 --- /dev/null +++ b/app/models/integrations/webex_teams.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Integrations + class WebexTeams < BaseChatNotification + include ActionView::Helpers::UrlHelper + + def title + s_("WebexTeamsService|Webex Teams") + end + + def description + s_("WebexTeamsService|Send notifications about project events to Webex Teams.") + end + + def self.to_param + 'webex_teams' + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' + s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + header = { 'Content-Type' => 'application/json' } + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb new file mode 100644 index 00000000000..10531717f11 --- /dev/null +++ b/app/models/integrations/youtrack.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Integrations + class Youtrack < BaseIssueTracker + include ActionView::Helpers::UrlHelper + + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? + + # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 + def self.reference_pattern(only_long: false) + if only_long + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ + else + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/ + end + end + + def title + 'YouTrack' + end + + def description + s_("IssueTracker|Use YouTrack as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'youtrack' + end + + def fields + [ + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } + ] + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index 2077f9bfdbb..b0a126c4442 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -464,6 +464,10 @@ class Issue < ApplicationRecord issue_type_supports?(:assignee) end + def supports_time_tracking? + issue_type_supports?(:time_tracking) + end + def email_participants_emails issue_email_participants.pluck(:email) end @@ -524,7 +528,7 @@ class Issue < ApplicationRecord def could_not_move(exception) # Symptom of running out of space - schedule rebalancing - IssueRebalancingWorker.perform_async(nil, project_id) + IssueRebalancingWorker.perform_async(nil, *project.self_or_root_group_ids) end end diff --git a/app/models/key.rb b/app/models/key.rb index 15b3c460b52..64385953865 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -7,6 +7,7 @@ class Key < ApplicationRecord include Sortable include Sha256Attribute include Expirable + include FromUnion sha256_attribute :fingerprint_sha256 @@ -43,7 +44,9 @@ class Key < ApplicationRecord scope :preload_users, -> { preload(:user) } scope :for_user, -> (user) { where(user: user) } scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) } - scope :expired_today_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') = CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) } + + # Date is set specifically in this scope to improve query time. + scope :expired_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') BETWEEN '2000-01-01' AND CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) } scope :expiring_soon_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') > CURRENT_DATE AND date(expires_at AT TIME ZONE 'UTC') < ? AND before_expiry_notification_delivered_at IS NULL", DAYS_TO_EXPIRE.days.from_now.to_date]) } def self.regular_keys diff --git a/app/models/label.rb b/app/models/label.rb index a46d6bc5c0f..1a07620f944 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -9,6 +9,10 @@ class Label < ApplicationRecord include Sortable include FromUnion include Presentable + include IgnorableColumns + + # TODO: Project#create_labels can remove column exception when this column is dropped from all envs + ignore_column :remove_on_close, remove_with: '14.1', remove_after: '2021-06-22' cache_markdown_field :description, pipeline: :single_line diff --git a/app/models/label_link.rb b/app/models/label_link.rb index a466fe69300..4fb5fd8c58a 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -11,5 +11,4 @@ class LabelLink < ApplicationRecord validates :label, presence: true, unless: :importing? scope :for_target, -> (target_id, target_type) { where(target_id: target_id, target_type: target_type) } - scope :with_remove_on_close_labels, -> { joins(:label).where(labels: { remove_on_close: true }) } end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index b837b902e2d..53e7d52c558 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -7,7 +7,7 @@ class LfsObject < ApplicationRecord include ObjectStorage::BackgroundMove include FileStoreMounter - has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :lfs_objects_projects has_many :projects, -> { distinct }, through: :lfs_objects_projects scope :with_files_stored_locally, -> { where(file_store: LfsObjectUploader::Store::LOCAL) } @@ -18,6 +18,8 @@ class LfsObject < ApplicationRecord mount_file_store_uploader LfsObjectUploader + BATCH_SIZE = 3000 + def self.not_linked_to_project(project) where('NOT EXISTS (?)', project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) @@ -37,13 +39,14 @@ class LfsObject < ApplicationRecord file_store == LfsObjectUploader::Store::LOCAL end - # rubocop: disable Cop/DestroyAll - def self.destroy_unreferenced - joins("LEFT JOIN lfs_objects_projects ON lfs_objects_projects.lfs_object_id = #{table_name}.id") - .where(lfs_objects_projects: { id: nil }) - .destroy_all + def self.unreferenced_in_batches + each_batch(of: BATCH_SIZE, order: :desc) do |lfs_objects| + relation = lfs_objects.where('NOT EXISTS (?)', + LfsObjectsProject.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) + + yield relation if relation.any? + end end - # rubocop: enable Cop/DestroyAll def self.calculate_oid(path) self.hexdigest(path) diff --git a/app/models/member.rb b/app/models/member.rb index 044b662e10f..0636c3c2d4e 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -14,6 +14,7 @@ class Member < ApplicationRecord include UpdateHighestRole AVATAR_SIZE = 40 + ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 attr_accessor :raw_invite_token @@ -107,10 +108,14 @@ class Member < ApplicationRecord scope :active_without_invites_and_requests, -> do left_join_users .where(users: { state: 'active' }) - .non_request + .without_invites_and_requests + .reorder(nil) + end + + scope :without_invites_and_requests, -> do + non_request .non_invite .non_minimal_access - .reorder(nil) end scope :invite, -> { where.not(invite_token: nil) } @@ -166,10 +171,10 @@ class Member < ApplicationRecord after_create :send_invite, if: :invite?, unless: :importing? after_create :send_request, if: :request?, unless: :importing? after_create :create_notification_setting, unless: [:pending?, :importing?] - after_create :post_create_hook, unless: [:pending?, :importing?] - after_update :post_update_hook, unless: [:pending?, :importing?] + after_create :post_create_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met? + after_update :post_update_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met? after_destroy :destroy_notification_setting - after_destroy :post_destroy_hook, unless: :pending? + after_destroy :post_destroy_hook, unless: :pending?, if: :hook_prerequisites_met? after_commit :refresh_member_authorized_projects default_value_for :notification_level, NotificationSetting.levels[:global] @@ -336,7 +341,7 @@ class Member < ApplicationRecord return User.find_by(id: user) if user.is_a?(Integer) - User.find_by(email: user) || user + User.find_by_any_email(user) || user end def retrieve_member(source, user, existing_members) @@ -383,6 +388,12 @@ class Member < ApplicationRecord invite? || request? end + def hook_prerequisites_met? + # It is essential that an associated user record exists + # so that we can successfully fire any member related hooks/notifications. + user.present? + end + def accept_request return false unless request? diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index b22a4fa9ef6..c7bc31cde5d 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -8,7 +8,7 @@ class GroupMember < Member belongs_to :group, foreign_key: 'source_id' alias_attribute :namespace_id, :source_id - delegate :update_two_factor_requirement, to: :user + delegate :update_two_factor_requirement, to: :user, allow_nil: true # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE @@ -36,6 +36,10 @@ class GroupMember < Member Gitlab::Access.sym_options_with_owner end + def self.pluck_user_ids + pluck(:user_id) + end + def group source end diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb index 64decb1df36..dcf0a2d0ad3 100644 --- a/app/models/members/last_group_owner_assigner.rb +++ b/app/models/members/last_group_owner_assigner.rb @@ -1,46 +1,44 @@ # frozen_string_literal: true -module Members - class LastGroupOwnerAssigner - def initialize(group, members) - @group = group - @members = members - end +class LastGroupOwnerAssigner + def initialize(group, members) + @group = group + @members = members + end - def execute - @last_blocked_owner = no_owners_in_heirarchy? && group.single_blocked_owner? - @group_single_owner = owners.size == 1 + def execute + @last_blocked_owner = no_owners_in_heirarchy? && group.single_blocked_owner? + @group_single_owner = owners.size == 1 - members.each { |member| set_last_owner(member) } - end + members.each { |member| set_last_owner(member) } + end - private + private - attr_reader :group, :members, :last_blocked_owner, :group_single_owner + attr_reader :group, :members, :last_blocked_owner, :group_single_owner - def no_owners_in_heirarchy? - owners.empty? - end + def no_owners_in_heirarchy? + owners.empty? + end - def set_last_owner(member) - member.last_owner = member.id.in?(owner_ids) && group_single_owner - member.last_blocked_owner = member.id.in?(blocked_owner_ids) && last_blocked_owner - end + def set_last_owner(member) + member.last_owner = member.id.in?(owner_ids) && group_single_owner + member.last_blocked_owner = member.id.in?(blocked_owner_ids) && last_blocked_owner + end - def owner_ids - @owner_ids ||= owners.where(id: member_ids).ids - end + def owner_ids + @owner_ids ||= owners.where(id: member_ids).ids + end - def blocked_owner_ids - @blocked_owner_ids ||= group.blocked_owners.where(id: member_ids).ids - end + def blocked_owner_ids + @blocked_owner_ids ||= group.blocked_owners.where(id: member_ids).ids + end - def member_ids - @members_ids ||= members.pluck(:id) - end + def member_ids + @members_ids ||= members.pluck(:id) + end - def owners - @owners ||= group.members_with_parents.owners.load - end + def owners + @owners ||= group.members_with_parents.owners.load end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index aaef56418d2..15f112690d5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -37,7 +37,7 @@ class MergeRequest < ApplicationRecord SORTING_PREFERENCE_FIELD = :merge_requests_sort ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = { - 'Ci::CompareMetricsReportsService' => ->(project) { ::Gitlab::Ci::Features.merge_base_pipeline_for_metrics_comparison?(project) }, + 'Ci::CompareMetricsReportsService' => ->(project) { true }, 'Ci::CompareCodequalityReportsService' => ->(project) { true } }.freeze @@ -125,6 +125,8 @@ class MergeRequest < ApplicationRecord ].freeze serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize + before_validation :set_draft_status + after_create :ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed @@ -267,6 +269,7 @@ class MergeRequest < ApplicationRecord scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } scope :open_and_closed, -> { with_states(:opened, :closed) } + scope :drafts, -> { where(draft: true) } scope :from_source_branches, ->(branches) { where(source_branch: branches) } scope :by_commit_sha, ->(sha) do where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil) @@ -1908,6 +1911,10 @@ class MergeRequest < ApplicationRecord private + def set_draft_status + self.draft = draft? + end + def missing_report_error(report_type) { status: :error, status_reason: "This merge request does not have #{report_type} reports" } end diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index e081a96dc10..0f2a7515462 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -12,7 +12,7 @@ class MergeRequestContextCommit < ApplicationRecord validates :sha, presence: true validates :sha, uniqueness: { message: 'has already been added' } - serialize :trailers, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize + serialize :trailers, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize validates :trailers, json_schema: { filename: 'git_trailers' } # Sort by committed date in descending order to ensure latest commits comes on the top diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 2dc6796732f..f58d7788432 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -389,11 +389,23 @@ class MergeRequestDiff < ApplicationRecord def diffs_in_batch(batch_page, batch_size, diff_options:) fetching_repository_diffs(diff_options) do |comparison| + reorder_diff_files! + diffs_batch = diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options) + if comparison - comparison.diffs_in_batch(batch_page, batch_size, diff_options: diff_options) + if diff_options[:paths].blank? && !without_files? + # Return the empty MergeRequestDiffBatch for an out of bound batch request + break diffs_batch if diffs_batch.diff_file_paths.blank? + + diff_options.merge!( + paths: diffs_batch.diff_file_paths, + pagination_data: diffs_batch.pagination_data + ) + end + + comparison.diffs(diff_options) else - reorder_diff_files! - diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options) + diffs_batch end end end diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 259690ef308..ed398e0d2e0 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -12,7 +12,7 @@ class MergeRequestDiffCommit < ApplicationRecord sha_attribute :sha alias_attribute :id, :sha - serialize :trailers, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize + serialize :trailers, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize validates :trailers, json_schema: { filename: 'git_trailers' } # Deprecated; use `bulk_insert!` from `BulkInsertSafe` mixin instead. diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 16090f0ebfa..9ed6c106e45 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -36,6 +36,7 @@ class Milestone < ApplicationRecord scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) } validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } + validate :uniqueness_of_title, if: :title_changed? state_machine :state, initial: :active do event :close do @@ -172,4 +173,16 @@ class Milestone < ApplicationRecord def issues_finder_params { project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact end + + # milestone titles must be unique across project and group milestones + def uniqueness_of_title + if project + relation = self.class.for_projects_and_groups([project_id], [project.group&.id]) + elsif group + relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id]) + end + + title_exists = relation.find_by_title(title) + errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 8f03c6145cb..90e06e44165 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -271,14 +271,9 @@ class Namespace < ApplicationRecord # Includes projects from this namespace and projects from all subgroups # that belongs to this namespace def all_projects - namespace = user? ? self : self_and_descendants - Project.where(namespace: namespace) - end + namespace = user? ? self : self_and_descendant_ids - # Includes pipelines from this namespace and pipelines from all subgroups - # that belongs to this namespace - def all_pipelines - Ci::Pipeline.where(project: all_projects) + Project.where(namespace: namespace) end def has_parent? @@ -442,12 +437,6 @@ class Namespace < ApplicationRecord end def all_projects_with_pages - if all_projects.pages_metadata_not_migrated.exists? - Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation( - all_projects.pages_metadata_not_migrated - ) - end - all_projects.with_pages_deployed end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 75b8169b58e..600abc33471 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -14,7 +14,8 @@ class NamespaceSetting < ApplicationRecord before_validation :normalize_default_branch_name NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, - :lock_delayed_project_removal, :resource_access_token_creation_allowed].freeze + :lock_delayed_project_removal, :resource_access_token_creation_allowed, + :prevent_sharing_groups_outside_hierarchy].freeze self.primary_key = :namespace_id diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index a1711bc5ee0..d0281f4d974 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -46,6 +46,12 @@ module Namespaces after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? } scope :traversal_ids_contains, ->(ids) { where("traversal_ids @> (?)", ids) } + # When filtering namespaces by the traversal_ids column to compile a + # list of namespace IDs, it's much faster to reference the ID in + # traversal_ids than the primary key ID column. + # WARNING This scope must be used behind a linear query feature flag + # such as `use_traversal_ids`. + scope :as_ids, -> { select('traversal_ids[array_length(traversal_ids, 1)] AS id') } end def sync_traversal_ids? @@ -58,12 +64,30 @@ module Namespaces traversal_ids.present? end + def root_ancestor + return super if parent.nil? + return super unless persisted? + + return super if traversal_ids.blank? + return super unless Feature.enabled?(:use_traversal_ids_for_root_ancestor, default_enabled: :yaml) + + strong_memoize(:root_ancestor) do + Namespace.find_by(id: traversal_ids.first) + end + end + def self_and_descendants return super unless use_traversal_ids? lineage(top: self) end + def self_and_descendant_ids + return super unless use_traversal_ids? + + self_and_descendants.as_ids + end + def descendants return super unless use_traversal_ids? @@ -88,7 +112,8 @@ module Namespaces # Clear any previously memoized root_ancestor as our ancestors have changed. clear_memoization(:root_ancestor) - Namespace::TraversalHierarchy.for_namespace(root_ancestor).sync_traversal_ids! + # We cannot rely on Namespaces::Traversal::Linear#root_ancestor because it might be stale + Namespace::TraversalHierarchy.for_namespace(recursive_root_ancestor).sync_traversal_ids! end # Lock the root of the hierarchy we just left, and lock the root of the hierarchy diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb index 409438f53d2..5a1a9d24117 100644 --- a/app/models/namespaces/traversal/recursive.rb +++ b/app/models/namespaces/traversal/recursive.rb @@ -16,6 +16,7 @@ module Namespaces parent.root_ancestor end end + alias_method :recursive_root_ancestor, :root_ancestor # Returns all ancestors, self, and descendants of the current namespace. def self_and_hierarchy @@ -61,6 +62,11 @@ module Namespaces end alias_method :recursive_self_and_descendants, :self_and_descendants + def self_and_descendant_ids + self_and_descendants.select(:id) + end + alias_method :recursive_self_and_descendant_ids, :self_and_descendant_ids + def object_hierarchy(ancestors_base) Gitlab::ObjectHierarchy.new(ancestors_base, options: { use_distinct: Feature.enabled?(:use_distinct_in_object_hierarchy, self) }) end diff --git a/app/models/note.rb b/app/models/note.rb index ae4a8859d4d..d1a59394ba1 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -96,7 +96,9 @@ class Note < ApplicationRecord validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?] - # @deprecated attachments are handler by the MarkdownUploader + # @deprecated attachments are handled by the Upload model. + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/20830 mount_uploader :attachment, AttachmentUploader # Scopes @@ -274,6 +276,10 @@ class Note < ApplicationRecord noteable_type == 'AlertManagement::Alert' end + def for_vulnerability? + noteable_type == "Vulnerability" + end + def for_project_snippet? noteable.is_a?(ProjectSnippet) end @@ -409,6 +415,8 @@ class Note < ApplicationRecord 'snippet' elsif for_alert_mangement_alert? 'alert_management_alert' + elsif for_vulnerability? + 'security_resource' else noteable_type.demodulize.underscore end diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb index be76c3dbf9d..9185547d7cd 100644 --- a/app/models/onboarding_progress.rb +++ b/app/models/onboarding_progress.rb @@ -85,6 +85,10 @@ class OnboardingProgress < ApplicationRecord end end + def number_of_completed_actions + attributes.extract!(*ACTIONS.map { |action| self.class.column_name(action).to_s }).compact!.size + end + private def namespace_is_root_namespace diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index 537543a7ff0..8b052f80395 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -49,6 +49,8 @@ module Operations scope :enabled, -> { where(active: true) } scope :disabled, -> { where(active: false) } + scope :new_version_only, -> { where(version: :new_version_flag)} + enum version: { legacy_flag: 1, new_version_flag: 2 diff --git a/app/models/packages/debian/group_distribution_key.rb b/app/models/packages/debian/group_distribution_key.rb new file mode 100644 index 00000000000..a60ddca32e2 --- /dev/null +++ b/app/models/packages/debian/group_distribution_key.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Packages::Debian::GroupDistributionKey < ApplicationRecord + def self.container_type + :group + end + + include Packages::Debian::DistributionKey +end diff --git a/app/models/packages/debian/project_distribution_key.rb b/app/models/packages/debian/project_distribution_key.rb new file mode 100644 index 00000000000..69cf2791b02 --- /dev/null +++ b/app/models/packages/debian/project_distribution_key.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Packages::Debian::ProjectDistributionKey < ApplicationRecord + def self.container_type + :project + end + + include Packages::Debian::DistributionKey +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 36edf646658..7b0bb72940e 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -8,6 +8,23 @@ class Packages::Package < ApplicationRecord DISPLAYABLE_STATUSES = [:default, :error].freeze INSTALLABLE_STATUSES = [:default].freeze + enum package_type: { + maven: 1, + npm: 2, + conan: 3, + nuget: 4, + pypi: 5, + composer: 6, + generic: 7, + golang: 8, + debian: 9, + rubygems: 10, + helm: 11, + terraform_module: 12 + } + + enum status: { default: 0, hidden: 1, processing: 2, error: 3 } + belongs_to :project belongs_to :creator, class_name: 'User' @@ -59,7 +76,7 @@ class Packages::Package < ApplicationRecord validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi? validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang? - validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :helm? + validates :version, format: { with: Gitlab::Regex.helm_version_regex }, if: :helm? validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? } validates :version, @@ -72,12 +89,6 @@ class Packages::Package < ApplicationRecord if: :debian_package? validate :forbidden_debian_changes, if: :debian? - enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, - composer: 6, generic: 7, golang: 8, debian: 9, - rubygems: 10, helm: 11, terraform_module: 12 } - - enum status: { default: 0, hidden: 1, processing: 2, error: 3 } - scope :for_projects, ->(project_ids) { where(project_id: project_ids) } scope :with_name, ->(name) { where(name: name) } scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } @@ -133,14 +144,24 @@ class Packages::Package < ApplicationRecord scope :order_type_desc, -> { reorder(package_type: :desc) } scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') } scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') } - scope :order_project_path, -> { joins(:project).reorder('projects.path ASC, id ASC') } - scope :order_project_path_desc, -> { joins(:project).reorder('projects.path DESC, id DESC') } scope :order_by_package_file, -> { joins(:package_files).order('packages_package_files.created_at ASC') } + scope :order_project_path, -> do + keyset_order = keyset_pagination_order(join_class: Project, column_name: :path, direction: :asc) + + joins(:project).reorder(keyset_order) + end + + scope :order_project_path_desc, -> do + keyset_order = keyset_pagination_order(join_class: Project, column_name: :path, direction: :desc) + + joins(:project).reorder(keyset_order) + end + after_commit :update_composer_cache, on: :destroy, if: -> { composer? } def self.only_maven_packages_with_path(path, use_cte: false) - if use_cte && Feature.enabled?(:maven_metadata_by_path_with_optimization_fence, default_enabled: :yaml) + if use_cte # This is an optimization fence which assumes that looking up the Metadatum record by path (globally) # and then filter down the packages (by project or by group and subgroups) will be cheaper than # looking up all packages within a project or group and filter them by path. @@ -196,6 +217,32 @@ class Packages::Package < ApplicationRecord end end + def self.keyset_pagination_order(join_class:, column_name:, direction: :asc) + join_table = join_class.table_name + asc_order_expression = Gitlab::Database.nulls_last_order("#{join_table}.#{column_name}", :asc) + desc_order_expression = Gitlab::Database.nulls_first_order("#{join_table}.#{column_name}", :desc) + order_direction = direction == :asc ? asc_order_expression : desc_order_expression + reverse_order_direction = direction == :asc ? desc_order_expression : asc_order_expression + arel_order_classes = ::Gitlab::Pagination::Keyset::ColumnOrderDefinition::AREL_ORDER_CLASSES.invert + + ::Gitlab::Pagination::Keyset::Order.build([ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: "#{join_table}_#{column_name}", + column_expression: join_class.arel_table[column_name], + order_expression: order_direction, + reversed_order_expression: reverse_order_direction, + order_direction: direction, + distinct: false, + add_to_projections: true + ), + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_order_classes[direction].new(Packages::Package.arel_table[:id]), + add_to_projections: true + ) + ]) + end + def versions project.packages .including_build_info @@ -222,6 +269,10 @@ class Packages::Package < ApplicationRecord tags.pluck(:name) end + def infrastructure_package? + terraform_module? + end + def debian_incoming? debian? && version.nil? end diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 3d8641ca2fa..3ef30c035e8 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -33,11 +33,18 @@ class Packages::PackageFile < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) } scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) } scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) } + scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) } scope :for_rubygem_with_file_name, ->(project, file_name) do joins(:package).merge(project.packages.rubygems).with_file_name(file_name) end + scope :for_helm_with_channel, ->(project, channel) do + joins(:package).merge(project.packages.helm.installable) + .joins(:helm_file_metadatum) + .where(packages_helm_file_metadata: { channel: channel }) + end + scope :with_conan_file_type, ->(file_type) do joins(:conan_file_metadatum) .where(packages_conan_file_metadata: { conan_file_type: ::Packages::Conan::FileMetadatum.conan_file_types[file_type] }) diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 17131cd736d..e7d455085c0 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -26,7 +26,18 @@ module Pages end def source - zip_source || legacy_source + return unless deployment&.file + + global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s + + { + type: 'zip', + path: deployment.file.url_or_file_path(expire_at: 1.day.from_now), + global_id: global_id, + sha256: deployment.file_sha256, + file_size: deployment.size, + file_count: deployment.file_count + } end def prefix @@ -46,32 +57,5 @@ module Pages project.pages_metadatum.pages_deployment end end - - def zip_source - return unless deployment&.file - - global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s - - { - type: 'zip', - path: deployment.file.url_or_file_path(expire_at: 1.day.from_now), - global_id: global_id, - sha256: deployment.file_sha256, - file_size: deployment.size, - file_count: deployment.file_count - } - end - - # TODO: remove support for legacy storage in 14.3 https://gitlab.com/gitlab-org/gitlab/-/issues/328712 - # we support this till 14.3 to allow people to still use legacy storage if something goes very wrong - # on self-hosted installations, and we'll need some time to fix it - def legacy_source - return unless ::Settings.pages.local_store.enabled - - { - type: 'file', - path: File.join(project.full_path, 'public/') - } - end end end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 4668fc265a0..c932d0bf800 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -50,6 +50,8 @@ class PagesDomain < ApplicationRecord after_update :update_daemon, if: :saved_change_to_pages_config? after_destroy :update_daemon + scope :for_project, ->(project) { where(project: project) } + scope :enabled, -> { where('enabled_until >= ?', Time.current ) } scope :needs_verification, -> do verified_at = arel_table[:verified_at] @@ -225,16 +227,6 @@ class PagesDomain < ApplicationRecord def pages_deployed? return false unless project - # TODO: remove once `pages_metadatum` is migrated - # https://gitlab.com/gitlab-org/gitlab/issues/33106 - unless project.pages_metadatum - Gitlab::BackgroundMigration::MigratePagesMetadata - .new - .perform_on_relation(Project.where(id: project_id)) - - project.reset - end - project.pages_metadatum&.deployed? end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index c96786423e5..77b42c34ad9 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -26,8 +26,8 @@ module Postgresql "(pg_current_wal_insert_lsn(), restart_lsn)::bigint" # We force the use of a transaction here so the query always goes to the - # primary, even when using the EE DB load balancer. - sizes = transaction { pluck(lag_function) } + # primary, even when using the DB load balancer. + sizes = transaction { pluck(Arel.sql(lag_function)) } too_great = sizes.compact.count { |size| size >= max } # If too many replicas are falling behind too much, the availability of a diff --git a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb index 671091480ee..c0ed56057ae 100644 --- a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb +++ b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Preloaders - # This class preloads the max access level for the user within the given projects and + # This class preloads the max access level (role) for the user within the given projects and # stores the values in requests store via the ProjectTeam class. class UserMaxAccessLevelInProjectsPreloader def initialize(projects, user) diff --git a/app/models/project.rb b/app/models/project.rb index 9d572b7e2f8..735dc185575 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -63,8 +63,6 @@ class Project < ApplicationRecord VALID_MIRROR_PORTS = [22, 80, 443].freeze VALID_MIRROR_PROTOCOLS = %w(http https ssh git).freeze - ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 - SORTING_PREFERENCE_FIELD = :projects_sort MAX_BUILD_TIMEOUT = 1.month @@ -129,40 +127,6 @@ class Project < ApplicationRecord after_create :check_repository_absence! acts_as_ordered_taggable_on :topics - # The 'tag_list' alias and the 'has_many' associations are required during the 'tags -> topics' migration - # TODO: eliminate 'tag_list', 'topic_taggings' and 'tags' in the further process of the migration - # https://gitlab.com/gitlab-org/gitlab/-/issues/331081 - alias_attribute :tag_list, :topic_list - has_many :topic_taggings, -> { includes(:tag).order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, - as: :taggable, - class_name: 'ActsAsTaggableOn::Tagging', - after_add: :dirtify_tag_list, - after_remove: :dirtify_tag_list - has_many :topics, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, - class_name: 'ActsAsTaggableOn::Tag', - through: :topic_taggings, - source: :tag - has_many :tags, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, - class_name: 'ActsAsTaggableOn::Tag', - through: :topic_taggings, - source: :tag - - # Overwriting 'topic_list' and 'topic_list=' is necessary to ensure functionality during the background migration [1]. - # [1] https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61237 - # TODO: remove 'topic_list' and 'topic_list=' once the background migration is complete - # https://gitlab.com/gitlab-org/gitlab/-/issues/331081 - def topic_list - # Return both old topics (context 'tags') and new topics (context 'topics') - tag_list_on('tags') + tag_list_on('topics') - end - - def topic_list=(new_tags) - # Old topics with context 'tags' are added as new topics with context 'topics' - super(new_tags) - - # Remove old topics with context 'tags' - set_tag_list_on('tags', '') - end attr_accessor :old_path_with_namespace attr_accessor :template_name @@ -182,44 +146,51 @@ class Project < ApplicationRecord has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards + def self.integration_association_name(name) + if ::Integration.renamed?(name) + "#{name}_integration" + else + "#{name}_service" + end + end + # Project integrations - has_one :asana_service, class_name: 'Integrations::Asana' - has_one :assembla_service, class_name: 'Integrations::Assembla' - has_one :bamboo_service, class_name: 'Integrations::Bamboo' - has_one :campfire_service, class_name: 'Integrations::Campfire' - has_one :confluence_service, class_name: 'Integrations::Confluence' - has_one :datadog_service, class_name: 'Integrations::Datadog' + has_one :asana_integration, class_name: 'Integrations::Asana' + has_one :assembla_integration, class_name: 'Integrations::Assembla' + has_one :bamboo_integration, class_name: 'Integrations::Bamboo' + has_one :bugzilla_integration, class_name: 'Integrations::Bugzilla' + has_one :buildkite_integration, class_name: 'Integrations::Buildkite' + has_one :campfire_integration, class_name: 'Integrations::Campfire' + has_one :confluence_integration, class_name: 'Integrations::Confluence' + has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker' + has_one :datadog_integration, class_name: 'Integrations::Datadog' + has_one :discord_integration, class_name: 'Integrations::Discord' + has_one :drone_ci_integration, class_name: 'Integrations::DroneCi' has_one :emails_on_push_service, class_name: 'Integrations::EmailsOnPush' - has_one :discord_service - has_one :drone_ci_service - has_one :ewm_service - has_one :pipelines_email_service - has_one :irker_service - has_one :pivotaltracker_service - has_one :flowdock_service - has_one :mattermost_slash_commands_service - has_one :mattermost_service - has_one :slack_slash_commands_service - has_one :slack_service - has_one :buildkite_service - has_one :teamcity_service - has_one :pushover_service - has_one :jenkins_service - has_one :jira_service - has_one :redmine_service - has_one :youtrack_service - has_one :custom_issue_tracker_service - has_one :bugzilla_service - has_one :external_wiki_service + has_one :ewm_service, class_name: 'Integrations::Ewm' + has_one :external_wiki_service, class_name: 'Integrations::ExternalWiki' + has_one :flowdock_service, class_name: 'Integrations::Flowdock' + has_one :hangouts_chat_service, class_name: 'Integrations::HangoutsChat' + has_one :irker_service, class_name: 'Integrations::Irker' + has_one :jenkins_service, class_name: 'Integrations::Jenkins' + has_one :jira_service, class_name: 'Integrations::Jira' + has_one :mattermost_service, class_name: 'Integrations::Mattermost' + has_one :mattermost_slash_commands_service, class_name: 'Integrations::MattermostSlashCommands' + has_one :microsoft_teams_service, class_name: 'Integrations::MicrosoftTeams' + has_one :mock_ci_service, class_name: 'Integrations::MockCi' + has_one :packagist_service, class_name: 'Integrations::Packagist' + has_one :pipelines_email_service, class_name: 'Integrations::PipelinesEmail' + has_one :pivotaltracker_service, class_name: 'Integrations::Pivotaltracker' + has_one :pushover_service, class_name: 'Integrations::Pushover' + has_one :redmine_service, class_name: 'Integrations::Redmine' + has_one :slack_service, class_name: 'Integrations::Slack' + has_one :slack_slash_commands_service, class_name: 'Integrations::SlackSlashCommands' + has_one :teamcity_service, class_name: 'Integrations::Teamcity' + has_one :unify_circuit_service, class_name: 'Integrations::UnifyCircuit' + has_one :webex_teams_service, class_name: 'Integrations::WebexTeams' + has_one :youtrack_service, class_name: 'Integrations::Youtrack' has_one :prometheus_service, inverse_of: :project - has_one :mock_ci_service - has_one :mock_deployment_service has_one :mock_monitoring_service - has_one :microsoft_teams_service - has_one :packagist_service - has_one :hangouts_chat_service - has_one :unify_circuit_service - has_one :webex_teams_service has_one :root_of_fork_network, foreign_key: 'root_project_id', @@ -261,7 +232,15 @@ class Project < ApplicationRecord has_many :events has_many :milestones has_many :iterations - has_many :notes + + # Projects with a very large number of notes may time out destroying them + # through the foreign key. Additionally, the deprecated attachment uploader + # for notes requires us to use dependent: :destroy to avoid orphaning uploaded + # files. + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/207222 + has_many :notes, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches @@ -287,7 +266,7 @@ class Project < ApplicationRecord has_many :users_star_projects has_many :starrers, through: :users_star_projects, source: :user has_many :releases - has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :lfs_objects_projects has_many :lfs_objects, -> { distinct }, through: :lfs_objects_projects has_many :lfs_file_locks has_many :project_group_links @@ -439,6 +418,7 @@ class Project < ApplicationRecord delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting + delegate :squash_option, to: :project_setting delegate :no_import?, to: :import_state, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true @@ -449,11 +429,12 @@ class Project < ApplicationRecord delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true - delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci - delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci - delegate :keep_latest_artifact, :keep_latest_artifact=, :keep_latest_artifact?, :keep_latest_artifacts_available?, to: :ci_cd_settings + delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci, allow_nil: true + delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci, allow_nil: true + delegate :job_token_scope_enabled, :job_token_scope_enabled=, :job_token_scope_enabled?, to: :ci_cd_settings, prefix: :ci + delegate :keep_latest_artifact, :keep_latest_artifact=, :keep_latest_artifact?, :keep_latest_artifacts_available?, to: :ci_cd_settings, allow_nil: true delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, :restrict_user_defined_variables?, - to: :ci_cd_settings + to: :ci_cd_settings, allow_nil: true delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, :allow_editing_commit_messages?, @@ -561,7 +542,7 @@ class Project < ApplicationRecord scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } scope :with_push, -> { joins(:events).merge(Event.pushed_action) } scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } - scope :with_active_jira_services, -> { joins(:integrations).merge(::JiraService.active) } # rubocop:disable CodeReuse/ServiceClass + scope :with_active_jira_services, -> { joins(:integrations).merge(::Integrations::Jira.active) } scope :with_jira_dvcs_cloud, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: true)) } scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) } scope :inc_routes, -> { includes(:route, namespace: :route) } @@ -637,6 +618,12 @@ class Project < ApplicationRecord scope :with_tracing_enabled, -> { joins(:tracing_setting) } scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) } + scope :with_service_desk_key, -> (key) do + # project_key is not indexed for now + # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24063#note_282435524 for details + joins(:service_desk_setting).where('service_desk_settings.project_key' => key) + end + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } chronic_duration_attr :build_timeout_human_readable, :build_timeout, @@ -652,7 +639,7 @@ class Project < ApplicationRecord mount_uploader :bfg_object_map, AttachmentUploader def self.with_api_entity_associations - preload(:project_feature, :route, :tags, :group, :timelogs, namespace: [:route, :owner]) + preload(:project_feature, :route, :topics, :group, :timelogs, namespace: [:route, :owner]) end def self.with_web_entity_associations @@ -838,12 +825,6 @@ class Project < ApplicationRecord from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id) end - - def find_by_service_desk_project_key(key) - # project_key is not indexed for now - # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24063#note_282435524 for details - joins(:service_desk_setting).find_by('service_desk_settings.project_key' => key) - end end def initialize(attributes = nil) @@ -921,6 +902,10 @@ class Project < ApplicationRecord alias_method :ancestors, :ancestors_upto + def ancestors_upto_ids(...) + ancestors_upto(...).pluck(:id) + end + def emails_disabled? strong_memoize(:emails_disabled) do # disabling in the namespace overrides the project setting @@ -1407,9 +1392,9 @@ class Project < ApplicationRecord end def disabled_services - return %w[datadog hipchat] unless Feature.enabled?(:datadog_ci_integration, self) + return %w[datadog] unless Feature.enabled?(:datadog_ci_integration, self) - %w[hipchat] + [] end def find_or_initialize_service(name) @@ -1421,7 +1406,8 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') + # TODO: remove_on_close exception can be removed after the column is dropped from all envs + params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type', 'remove_on_close') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end @@ -1735,7 +1721,11 @@ class Project < ApplicationRecord end def shared_runners - @shared_runners ||= shared_runners_available? ? Ci::Runner.instance_type : Ci::Runner.none + @shared_runners ||= shared_runners_enabled? ? Ci::Runner.instance_type : Ci::Runner.none + end + + def available_shared_runners + @available_shared_runners ||= shared_runners_available? ? shared_runners : Ci::Runner.none end def group_runners @@ -1746,17 +1736,16 @@ class Project < ApplicationRecord Ci::Runner.from_union([runners, group_runners, shared_runners]) end + def all_available_runners + Ci::Runner.from_union([runners, group_runners, available_shared_runners]) + end + def active_runners strong_memoize(:active_runners) do - all_runners.active + all_available_runners.active end end - # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326989 - def any_active_runners?(&block) - active_runners_with_tags.any?(&block) - end - def any_online_runners?(&block) online_runners_with_tags.any?(&block) end @@ -1772,7 +1761,7 @@ class Project < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass - def open_merge_requests_count + def open_merge_requests_count(_current_user = nil) Projects::OpenMergeRequestsCountService.new(self).count end # rubocop: enable CodeReuse/ServiceClass @@ -2006,7 +1995,11 @@ class Project < ApplicationRecord end def export_file_exists? - export_file&.file + import_export_upload&.export_file_exists? + end + + def export_archive_exists? + import_export_upload&.export_archive_exists? end def export_file @@ -2046,7 +2039,6 @@ class Project < ApplicationRecord .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level)) .append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase) .append(key: 'CI_DEFAULT_BRANCH', value: default_branch) - .append(key: 'CI_PROJECT_CONFIG_PATH', value: ci_config_path_or_default) .append(key: 'CI_CONFIG_PATH', value: ci_config_path_or_default) end @@ -2377,7 +2369,7 @@ class Project < ApplicationRecord end def mark_primary_write_location - # Overriden in EE + ::Gitlab::Database::LoadBalancing::Sticking.mark_primary_write_location(:project, self.id) end def toggle_ci_cd_settings!(settings_attribute) @@ -2454,7 +2446,7 @@ class Project < ApplicationRecord end def access_request_approvers_to_be_notified - members.maintainers.connected_to_user.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + members.maintainers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def pages_lookup_path(trim_prefix: nil, domain: nil) @@ -2562,6 +2554,17 @@ class Project < ApplicationRecord end end + # for projects that are part of user namespace, return project. + def self_or_root_group_ids + if group + root_group = root_namespace + else + project = self + end + + [project&.id, root_group&.id] + end + def package_already_taken?(package_name) namespace.root_ancestor.all_projects .joins(:packages) @@ -2604,10 +2607,6 @@ class Project < ApplicationRecord Projects::GitGarbageCollectWorker end - def inherited_issuable_templates_enabled? - Feature.enabled?(:inherited_issuable_templates, self, default_enabled: :yaml) - end - def activity_path Gitlab::Routing.url_helpers.activity_project_path(self) end @@ -2618,6 +2617,19 @@ class Project < ApplicationRecord ProjectStatistics.increment_statistic(self, statistic, delta) end + def merge_requests_author_approval + !!read_attribute(:merge_requests_author_approval) + end + + def container_registry_enabled + if Feature.enabled?(:read_container_registry_access_level, self.namespace, default_enabled: :yaml) + project_feature.container_registry_enabled? + else + read_attribute(:container_registry_enabled) + end + end + alias_method :container_registry_enabled?, :container_registry_enabled + private def set_container_registry_access_level @@ -2647,7 +2659,7 @@ class Project < ApplicationRecord end def build_service(name) - Integration.service_name_to_model(name).new(project_id: id) + Integration.integration_name_to_model(name).new(project_id: id) end def services_templates diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 1fed166e4d0..64e768007ee 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -29,6 +29,15 @@ class ProjectAuthorization < ApplicationRecord EOF end end + + # This method overrides its ActiveRecord's version in order to work correctly + # with composite primary keys and fix the tests for Rails 6.1 + # + # Consider using BulkInsertSafe module instead since we plan to refactor it in + # https://gitlab.com/gitlab-org/gitlab/-/issues/331264 + def self.insert_all(attributes) + super(attributes, unique_by: connection.schema_cache.primary_keys(table_name)) + end end ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index c0c2ea42d46..b025326c6f8 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -16,6 +16,7 @@ class ProjectCiCdSetting < ApplicationRecord allow_nil: true default_value_for :forward_deployment_enabled, true + default_value_for :job_token_scope_enabled, true def forward_deployment_enabled? super && ::Feature.enabled?(:forward_deployment_enabled, project, default_enabled: true) diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index eb4ad327438..f6e889396c6 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -24,7 +24,11 @@ class ProjectFeature < ApplicationRecord set_available_features(FEATURES) - PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze + PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { + merge_requests: Gitlab::Access::REPORTER, + metrics_dashboard: Gitlab::Access::REPORTER, + container_registry: Gitlab::Access::REPORTER + }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze class << self @@ -92,7 +96,7 @@ class ProjectFeature < ApplicationRecord def set_container_registry_access_level self.container_registry_access_level = - if project&.container_registry_enabled + if project&.read_attribute(:container_registry_enabled) ENABLED else DISABLED diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb index d993db860c3..dba81a6cb60 100644 --- a/app/models/project_feature_usage.rb +++ b/app/models/project_feature_usage.rb @@ -20,14 +20,16 @@ class ProjectFeatureUsage < ApplicationRecord end def log_jira_dvcs_integration_usage(cloud: true) - integration_field = self.class.jira_dvcs_integration_field(cloud: cloud) + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do + integration_field = self.class.jira_dvcs_integration_field(cloud: cloud) - # The feature usage is used only once later to query the feature usage in a - # long date range. Therefore, we just need to update the timestamp once per - # day - return if persisted? && updated_today?(integration_field) + # The feature usage is used only once later to query the feature usage in a + # long date range. Therefore, we just need to update the timestamp once per + # day + break if persisted? && updated_today?(integration_field) - persist_jira_dvcs_usage(integration_field) + persist_jira_dvcs_usage(integration_field) + end end private diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb deleted file mode 100644 index e54489ddb88..00000000000 --- a/app/models/project_repository_storage_move.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853. -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 14.0 -# https://gitlab.com/gitlab-org/gitlab/-/issues/322393 -class ProjectRepositoryStorageMove < Projects::RepositoryStorageMove -end diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb deleted file mode 100644 index d1c56d2a4d5..00000000000 --- a/app/models/project_services/bugzilla_service.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class BugzillaService < IssueTrackerService - include ActionView::Helpers::UrlHelper - - validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - - def title - 'Bugzilla' - end - - def description - s_("IssueTracker|Use Bugzilla as this project's issue tracker.") - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' - s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'bugzilla' - end -end diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb deleted file mode 100644 index f2ea5066e37..00000000000 --- a/app/models/project_services/buildkite_service.rb +++ /dev/null @@ -1,143 +0,0 @@ -# frozen_string_literal: true - -require "addressable/uri" - -class BuildkiteService < CiService - include ReactiveService - - ENDPOINT = "https://buildkite.com" - - prop_accessor :project_url, :token - - validates :project_url, presence: true, public_url: true, if: :activated? - validates :token, presence: true, if: :activated? - - after_save :compose_service_hook, if: :activated? - - def self.supported_events - %w(push merge_request tag_push) - end - - # This is a stub method to work with deprecated API response - # TODO: remove enable_ssl_verification after 14.0 - # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 - def enable_ssl_verification - true - end - - # Since SSL verification will always be enabled for Buildkite, - # we no longer needs to store the boolean. - # This is a stub method to work with deprecated API param. - # TODO: remove enable_ssl_verification after 14.0 - # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 - def enable_ssl_verification=(_value) - self.properties.delete('enable_ssl_verification') # Remove unused key - end - - def webhook_url - "#{buildkite_endpoint('webhook')}/deliver/#{webhook_token}" - end - - def compose_service_hook - hook = service_hook || build_service_hook - hook.url = webhook_url - hook.enable_ssl_verification = true - hook.save - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - service_hook.execute(data) - end - - def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } - end - - def commit_status_path(sha) - "#{buildkite_endpoint('gitlab')}/status/#{status_token}.json?commit=#{sha}" - end - - def build_page(sha, ref) - "#{project_url}/builds?commit=#{sha}" - end - - def title - 'Buildkite' - end - - def description - 'Run CI/CD pipelines with Buildkite.' - end - - def self.to_param - 'buildkite' - end - - def fields - [ - { type: 'text', - name: 'token', - title: 'Integration Token', - help: 'This token will be provided when you create a Buildkite pipeline with a GitLab repository', - required: true }, - - { type: 'text', - name: 'project_url', - title: 'Pipeline URL', - placeholder: "#{ENDPOINT}/acme-inc/test-pipeline", - required: true } - ] - end - - def calculate_reactive_cache(sha, ref) - response = Gitlab::HTTP.try_get(commit_status_path(sha), request_options) - - status = - if response&.code == 200 && response['status'] - response['status'] - else - :error - end - - { commit_status: status } - end - - private - - def webhook_token - token_parts.first - end - - def status_token - token_parts.second - end - - def token_parts - if token.present? - token.split(':') - else - [] - end - end - - def buildkite_endpoint(subdomain = nil) - if subdomain.present? - uri = Addressable::URI.parse(ENDPOINT) - new_endpoint = "#{uri.scheme || 'http'}://#{subdomain}.#{uri.host}" - - if uri.port.present? - "#{new_endpoint}:#{uri.port}" - else - new_endpoint - end - else - ENDPOINT - end - end - - def request_options - { verify: false, extra_log_info: { project_id: project_id } } - end -end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb deleted file mode 100644 index 2f841bf903e..00000000000 --- a/app/models/project_services/chat_notification_service.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -# Base class for Chat notifications services -# This class is not meant to be used directly, but only to inherit from. -class ChatNotificationService < Integration - include ChatMessage - include NotificationBranchSelection - - SUPPORTED_EVENTS = %w[ - push issue confidential_issue merge_request note confidential_note - tag_push pipeline wiki_page deployment - ].freeze - - SUPPORTED_EVENTS_FOR_LABEL_FILTER = %w[issue confidential_issue merge_request note confidential_note].freeze - - EVENT_CHANNEL = proc { |event| "#{event}_channel" } - - LABEL_NOTIFICATION_BEHAVIOURS = [ - MATCH_ANY_LABEL = 'match_any', - MATCH_ALL_LABELS = 'match_all' - ].freeze - - default_value_for :category, 'chat' - - prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior - - # Custom serialized properties initialization - prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) - - boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch - - validates :webhook, presence: true, public_url: true, if: :activated? - validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - self.branches_to_be_notified = "default" - self.labels_to_be_notified_behavior = MATCH_ANY_LABEL - elsif !self.notify_only_default_branch.nil? - # In older versions, there was only a boolean property named - # `notify_only_default_branch`. Now we have a string property named - # `branches_to_be_notified`. Instead of doing a background migration, we - # opted to set a value for the new property based on the old one, if - # users hasn't specified one already. When users edit the service and - # selects a value for this new property, it will override everything. - - self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" - end - end - - def confidential_issue_channel - properties['confidential_issue_channel'].presence || properties['issue_channel'] - end - - def confidential_note_channel - properties['confidential_note_channel'].presence || properties['note_channel'] - end - - def self.supported_events - SUPPORTED_EVENTS - end - - def fields - default_fields + build_event_channels - end - - def default_fields - [ - { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}", required: true }.freeze, - { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, - { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, - { - type: 'text', - name: 'labels_to_be_notified', - placeholder: '~backend,~frontend', - help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' - }.freeze, - { - type: 'select', - name: 'labels_to_be_notified_behavior', - choices: [ - ['Match any of the labels', MATCH_ANY_LABEL], - ['Match all of the labels', MATCH_ALL_LABELS] - ] - }.freeze - ].freeze - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - return unless notify_label?(data) - - return unless webhook.present? - - object_kind = data[:object_kind] - - data = custom_data(data) - - # WebHook events often have an 'update' event that follows a 'open' or - # 'close' action. Ignore update events for now to prevent duplicate - # messages from arriving. - - message = get_message(object_kind, data) - - return false unless message - - event_type = data[:event_type] || object_kind - - channel_names = get_channel_field(event_type).presence || channel.presence - channels = channel_names&.split(',')&.map(&:strip) - - opts = {} - opts[:channel] = channels if channels.present? - opts[:username] = username if username - - if notify(message, opts) - log_usage(event_type, user_id_from_hook_data(data)) - return true - end - - false - end - - def event_channel_names - supported_events.map { |event| event_channel_name(event) } - end - - def event_field(event) - fields.find { |field| field[:name] == event_channel_name(event) } - end - - def global_fields - fields.reject { |field| field[:name].end_with?('channel') } - end - - def default_channel_placeholder - raise NotImplementedError - end - - private - - def log_usage(_, _) - # Implement in child class - end - - def labels_to_be_notified_list - return [] if labels_to_be_notified.nil? - - labels_to_be_notified.delete('~').split(',').map(&:strip) - end - - def notify_label?(data) - return true unless SUPPORTED_EVENTS_FOR_LABEL_FILTER.include?(data[:object_kind]) && labels_to_be_notified.present? - - labels = data.dig(:issue, :labels) || data.dig(:merge_request, :labels) - - return false if labels.nil? - - matching_labels = labels_to_be_notified_list & labels.pluck(:title) - - if labels_to_be_notified_behavior == MATCH_ALL_LABELS - labels_to_be_notified_list.difference(matching_labels).empty? - else - matching_labels.any? - end - end - - def user_id_from_hook_data(data) - data.dig(:user, :id) || data[:user_id] - end - - # every notifier must implement this independently - def notify(message, opts) - raise NotImplementedError - end - - def custom_data(data) - data.merge(project_url: project_url, project_name: project_name) - end - - def get_message(object_kind, data) - case object_kind - when "push", "tag_push" - Integrations::ChatMessage::PushMessage.new(data) if notify_for_ref?(data) - when "issue" - Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) - when "merge_request" - Integrations::ChatMessage::MergeMessage.new(data) unless update?(data) - when "note" - Integrations::ChatMessage::NoteMessage.new(data) - when "pipeline" - Integrations::ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) - when "wiki_page" - Integrations::ChatMessage::WikiPageMessage.new(data) - when "deployment" - Integrations::ChatMessage::DeploymentMessage.new(data) - end - end - - def get_channel_field(event) - field_name = event_channel_name(event) - self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend - end - - def build_event_channels - supported_events.reduce([]) do |channels, event| - channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel_placeholder } - end - end - - def event_channel_name(event) - EVENT_CHANNEL[event] - end - - def project_name - project.full_name - end - - def project_url - project.web_url - end - - def update?(data) - data[:object_attributes][:action] == 'update' - end - - def should_pipeline_be_notified?(data) - notify_for_ref?(data) && notify_for_pipeline?(data) - end - - def notify_for_ref?(data) - return true if data[:object_kind] == 'tag_push' - return true if data.dig(:object_attributes, :tag) - - notify_for_branch?(data) - end - - def notify_for_pipeline?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end - end -end diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb deleted file mode 100644 index 0733da761d5..00000000000 --- a/app/models/project_services/ci_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -# Base class for CI services -# List methods you need to implement to get your CI service -# working with GitLab merge requests -class CiService < Integration - default_value_for :category, 'ci' - - def valid_token?(token) - self.respond_to?(:token) && self.token.present? && ActiveSupport::SecurityUtils.secure_compare(token, self.token) - end - - def self.supported_events - %w(push) - end - - # Return complete url to build page - # - # Ex. - # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c - # - def build_page(sha, ref) - # implement inside child - end - - # Return string with build status or :error symbol - # - # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' - # - # - # Ex. - # @service.commit_status('13be4ac', 'master') - # # => 'success' - # - # @service.commit_status('2abe4ac', 'dev') - # # => 'running' - # - # - def commit_status(sha, ref) - # implement inside child - end -end diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb deleted file mode 100644 index 6f99d104904..00000000000 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class CustomIssueTrackerService < IssueTrackerService - include ActionView::Helpers::UrlHelper - validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - - def title - s_('IssueTracker|Custom issue tracker') - end - - def description - s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.") - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' - s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'custom_issue_tracker' - end -end diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb deleted file mode 100644 index ca4dc0375fb..00000000000 --- a/app/models/project_services/data_fields.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module DataFields - extend ActiveSupport::Concern - - class_methods do - # Provide convenient accessor methods for data fields. - # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 - def data_field(*args) - args.each do |arg| - self.class_eval <<-RUBY, __FILE__, __LINE__ + 1 - unless method_defined?(arg) - def #{arg} - data_fields.send('#{arg}') || (properties && properties['#{arg}']) - end - end - - def #{arg}=(value) - @old_data_fields ||= {} - @old_data_fields['#{arg}'] ||= #{arg} # set only on the first assignment, IOW we remember the original value only - data_fields.send('#{arg}=', value) - end - - def #{arg}_touched? - @old_data_fields ||= {} - @old_data_fields.has_key?('#{arg}') - end - - def #{arg}_changed? - #{arg}_touched? && @old_data_fields['#{arg}'] != #{arg} - end - - def #{arg}_was - return unless #{arg}_touched? - return if data_fields.persisted? # arg_was does not work for attr_encrypted - - legacy_properties_data['#{arg}'] - end - RUBY - end - end - end - - included do - has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id - has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id - has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id - - def data_fields - raise NotImplementedError - end - - def data_fields_present? - data_fields.present? - rescue NotImplementedError - false - end - end -end diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb deleted file mode 100644 index d7adf63fde4..00000000000 --- a/app/models/project_services/discord_service.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require "discordrb/webhooks" - -class DiscordService < ChatNotificationService - include ActionView::Helpers::UrlHelper - - ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze - - def title - s_("DiscordService|Discord Notifications") - end - - def description - s_("DiscordService|Send notifications about project events to a Discord channel.") - end - - def self.to_param - "discord" - end - - def help - docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' - s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def event_field(event) - # No-op. - end - - def default_channel_placeholder - # No-op. - end - - def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] - end - - def default_fields - [ - { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." }, - { type: "checkbox", name: "notify_only_broken_pipelines" }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } - ] - end - - private - - def notify(message, opts) - client = Discordrb::Webhooks::Client.new(url: webhook) - - client.execute do |builder| - builder.add_embed do |embed| - embed.author = Discordrb::Webhooks::EmbedAuthor.new(name: message.user_name, icon_url: message.user_avatar) - embed.description = (message.pretext + "\n" + Array.wrap(message.attachments).join("\n")).gsub(ATTACHMENT_REGEX, " \\k<entry> - \\k<name>\n") - end - end - rescue RestClient::Exception => error - log_error(error.message) - false - end - - def custom_data(data) - super(data).merge(markdown: true) - end -end diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb deleted file mode 100644 index ab1ba768a8f..00000000000 --- a/app/models/project_services/drone_ci_service.rb +++ /dev/null @@ -1,104 +0,0 @@ -# frozen_string_literal: true - -class DroneCiService < CiService - include ReactiveService - include ServicePushDataValidations - - prop_accessor :drone_url, :token - boolean_accessor :enable_ssl_verification - - validates :drone_url, presence: true, public_url: true, if: :activated? - validates :token, presence: true, if: :activated? - - after_save :compose_service_hook, if: :activated? - - def compose_service_hook - hook = service_hook || build_service_hook - # If using a service template, project may not be available - hook.url = [drone_url, "/api/hook", "?owner=#{project.namespace.full_path}", "&name=#{project.path}", "&access_token=#{token}"].join if project - hook.enable_ssl_verification = !!enable_ssl_verification - hook.save - end - - def execute(data) - case data[:object_kind] - when 'push' - service_hook.execute(data) if push_valid?(data) - when 'merge_request' - service_hook.execute(data) if merge_request_valid?(data) - when 'tag_push' - service_hook.execute(data) if tag_push_valid?(data) - end - end - - def allow_target_ci? - true - end - - def self.supported_events - %w(push merge_request tag_push) - end - - def commit_status_path(sha, ref) - Gitlab::Utils.append_path( - drone_url, - "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}") - end - - def commit_status(sha, ref) - with_reactive_cache(sha, ref) { |cached| cached[:commit_status] } - end - - def calculate_reactive_cache(sha, ref) - response = Gitlab::HTTP.try_get(commit_status_path(sha, ref), - verify: enable_ssl_verification, - extra_log_info: { project_id: project_id }) - - status = - if response && response.code == 200 && response['status'] - case response['status'] - when 'killed' - :canceled - when 'failure', 'error' - # Because drone return error if some test env failed - :failed - else - response["status"] - end - else - :error - end - - { commit_status: status } - end - - def build_page(sha, ref) - Gitlab::Utils.append_path( - drone_url, - "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}") - end - - def title - 'Drone' - end - - def description - s_('ProjectService|Run CI/CD pipelines with Drone.') - end - - def self.to_param - 'drone_ci' - end - - def help - s_('ProjectService|Run CI/CD pipelines with Drone.') - end - - def fields - [ - { type: 'text', name: 'token', help: s_('ProjectService|Token for the Drone project.'), required: true }, - { type: 'text', name: 'drone_url', title: s_('ProjectService|Drone server URL'), placeholder: 'http://drone.example.com', required: true }, - { type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" } - ] - end -end diff --git a/app/models/project_services/ewm_service.rb b/app/models/project_services/ewm_service.rb deleted file mode 100644 index 90fcbb10d2b..00000000000 --- a/app/models/project_services/ewm_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class EwmService < IssueTrackerService - include ActionView::Helpers::UrlHelper - - validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - - def self.reference_pattern(only_long: true) - @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i - end - - def title - 'EWM' - end - - def description - s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.") - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' - s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'ewm' - end - - def can_test? - false - end - - def issue_url(iid) - issues_url.gsub(':id', iid.to_s.split(' ')[-1]) - end -end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb deleted file mode 100644 index f49b008533d..00000000000 --- a/app/models/project_services/external_wiki_service.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -class ExternalWikiService < Integration - include ActionView::Helpers::UrlHelper - - prop_accessor :external_wiki_url - validates :external_wiki_url, presence: true, public_url: true, if: :activated? - - def title - s_('ExternalWikiService|External wiki') - end - - def description - s_('ExternalWikiService|Link to an external wiki from the sidebar.') - end - - def self.to_param - 'external_wiki' - end - - def fields - [ - { - type: 'text', - name: 'external_wiki_url', - title: s_('ExternalWikiService|External wiki URL'), - placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'), - help: 'Enter the URL to the external wiki.', - required: true - } - ] - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' - - s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) - response.body if response.code == 200 - rescue StandardError - nil - end - - def self.supported_events - %w() - end -end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb deleted file mode 100644 index 7aae5af7454..00000000000 --- a/app/models/project_services/flowdock_service.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -class FlowdockService < Integration - include ActionView::Helpers::UrlHelper - - prop_accessor :token - validates :token, presence: true, if: :activated? - - def title - 'Flowdock' - end - - def description - s_('FlowdockService|Send event notifications from GitLab to Flowdock flows.') - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' - s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'flowdock' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: s_('FlowdockService|1b609b52537...'), required: true, help: 'Enter your Flowdock token.' } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - Flowdock::Git.post( - data[:ref], - data[:before], - data[:after], - token: token, - repo: project.repository, - repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", - commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/%s", - diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" - ) - end -end diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb deleted file mode 100644 index 6e7708a169f..00000000000 --- a/app/models/project_services/hangouts_chat_service.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require 'hangouts_chat' - -class HangoutsChatService < ChatNotificationService - include ActionView::Helpers::UrlHelper - - def title - 'Google Chat' - end - - def description - 'Send notifications from GitLab to a room in Google Chat.' - end - - def self.to_param - 'hangouts_chat' - end - - def help - docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' - s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def event_field(event) - end - - def default_channel_placeholder - end - - def webhook_placeholder - 'https://chat.googleapis.com/v1/spaces…' - end - - def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] - end - - def default_fields - [ - { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } - ] - end - - private - - def notify(message, opts) - simple_text = parse_simple_text_message(message) - HangoutsChat::Sender.new(webhook).simple(simple_text) - end - - def parse_simple_text_message(message) - header = message.pretext - return header if message.attachments.empty? - - attachment = message.attachments.first - title = format_attachment_title(attachment) - body = attachment[:text] - - [header, title, body].compact.join("\n") - end - - def format_attachment_title(attachment) - return attachment[:title] unless attachment[:title_link] - - "<#{attachment[:title_link]}|#{attachment[:title]}>" - end -end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb deleted file mode 100644 index 71d8e7bfac4..00000000000 --- a/app/models/project_services/hipchat_service.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -# This service is scheduled for removal. All records must -# be deleted before the class can be removed. -# https://gitlab.com/gitlab-org/gitlab/-/issues/27954 -class HipchatService < Integration - before_save :prevent_save - - def self.to_param - 'hipchat' - end - - def self.supported_events - [] - end - - def execute(data) - # We removed the hipchat gem due to https://gitlab.com/gitlab-org/gitlab/-/issues/325851#note_537143149 - # HipChat is unusable anyway, so do nothing in this method - end - - private - - def prevent_save - errors.add(:base, _('HipChat endpoint is deprecated and should not be created or modified.')) - - # Stops execution of callbacks and database operation while - # preserving expectations of #save (will not raise) & #save! (raises) - # https://guides.rubyonrails.org/active_record_callbacks.html#halting-execution - throw :abort # rubocop:disable Cop/BanCatchThrow - end -end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb deleted file mode 100644 index 5cca620c659..00000000000 --- a/app/models/project_services/irker_service.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -require 'uri' - -class IrkerService < Integration - prop_accessor :server_host, :server_port, :default_irc_uri - prop_accessor :recipients, :channels - boolean_accessor :colorize_messages - validates :recipients, presence: true, if: :validate_recipients? - - before_validation :get_channels - - def title - 'Irker (IRC gateway)' - end - - def description - 'Send IRC messages.' - end - - def self.to_param - 'irker' - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - IrkerWorker.perform_async(project_id, channels, - colorize_messages, data, settings) - end - - def settings - { - server_host: server_host.presence || 'localhost', - server_port: server_port.presence || 6659 - } - end - - def fields - [ - { type: 'text', name: 'server_host', placeholder: 'localhost', - help: 'Irker daemon hostname (defaults to localhost)' }, - { type: 'text', name: 'server_port', placeholder: 6659, - help: 'Irker daemon port (defaults to 6659)' }, - { type: 'text', name: 'default_irc_uri', title: 'Default IRC URI', - help: 'A default IRC URI to prepend before each recipient (optional)', - placeholder: 'irc://irc.network.net:6697/' }, - { type: 'textarea', name: 'recipients', - placeholder: 'Recipients/channels separated by whitespaces', required: true, - help: 'Recipients have to be specified with a full URI: '\ - 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ - 'you want the channel to be a nickname instead, append ",isnick" to ' \ - 'the channel name; if the channel is protected by a secret password, ' \ - ' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \ - ' want to use a password, you have to omit the "#" on the channel). If you ' \ - ' specify a default IRC URI to prepend before each recipient, you can just ' \ - ' give a channel name.' }, - { type: 'checkbox', name: 'colorize_messages' } - ] - end - - def help - ' NOTE: Irker does NOT have built-in authentication, which makes it' \ - ' vulnerable to spamming IRC channels if it is hosted outside of a ' \ - ' firewall. Please make sure you run the daemon within a secured network ' \ - ' to prevent abuse. For more details, read: http://www.catb.org/~esr/irker/security.html.' - end - - private - - def get_channels - return true unless activated? - return true if recipients.nil? || recipients.empty? - - map_recipients - - errors.add(:recipients, 'are all invalid') if channels.empty? - true - end - - def map_recipients - self.channels = recipients.split(/\s+/).map do |recipient| - format_channel(recipient) - end - channels.reject!(&:nil?) - end - - def format_channel(recipient) - uri = nil - - # Try to parse the chan as a full URI - begin - uri = consider_uri(URI.parse(recipient)) - rescue URI::InvalidURIError - end - - unless uri.present? && default_irc_uri.nil? - begin - new_recipient = URI.join(default_irc_uri, '/', recipient).to_s - uri = consider_uri(URI.parse(new_recipient)) - rescue StandardError - log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) - end - end - - uri - end - - def consider_uri(uri) - return if uri.scheme.nil? - - # Authorize both irc://domain.com/#chan and irc://domain.com/chan - if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil? - uri.to_s - end - end -end diff --git a/app/models/project_services/issue_tracker_data.rb b/app/models/project_services/issue_tracker_data.rb deleted file mode 100644 index 414f2c1da4d..00000000000 --- a/app/models/project_services/issue_tracker_data.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class IssueTrackerData < ApplicationRecord - include Services::DataFields - - attr_encrypted :project_url, encryption_options - attr_encrypted :issues_url, encryption_options - attr_encrypted :new_issue_url, encryption_options -end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb deleted file mode 100644 index 099e3c336dd..00000000000 --- a/app/models/project_services/issue_tracker_service.rb +++ /dev/null @@ -1,152 +0,0 @@ -# frozen_string_literal: true - -class IssueTrackerService < Integration - validate :one_issue_tracker, if: :activated?, on: :manual_change - - # TODO: we can probably just delegate as part of - # https://gitlab.com/gitlab-org/gitlab/issues/29404 - data_field :project_url, :issues_url, :new_issue_url - - default_value_for :category, 'issue_tracker' - - before_validation :handle_properties - before_validation :set_default_data, on: :create - - # Pattern used to extract links from comments - # Override this method on services that uses different patterns - # This pattern does not support cross-project references - # The other code assumes that this pattern is a superset of all - # overridden patterns. See ReferenceRegexes.external_pattern - def self.reference_pattern(only_long: false) - if only_long - /(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/ - else - /(\b[A-Z][A-Z0-9_]*-|#{Issue.reference_prefix})#{Gitlab::Regex.issue}/ - end - end - - def handle_properties - # this has been moved from initialize_properties and should be improved - # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 - return unless properties - - @legacy_properties_data = properties.dup - data_values = properties.slice!('title', 'description') - data_values.reject! { |key| data_fields.changed.include?(key) } - data_values.slice!(*data_fields.attributes.keys) - data_fields.assign_attributes(data_values) if data_values.present? - - self.properties = {} - end - - def legacy_properties_data - @legacy_properties_data ||= {} - end - - def supports_data_fields? - true - end - - def data_fields - issue_tracker_data || self.build_issue_tracker_data - end - - def default? - default - end - - def issue_url(iid) - issues_url.gsub(':id', iid.to_s) - end - - def issue_tracker_path - project_url - end - - def new_issue_path - new_issue_url - end - - def issue_path(iid) - issue_url(iid) - end - - def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true }, - { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }, - { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true } - ] - end - - def initialize_properties - {} - end - - # Initialize with default properties values - def set_default_data - return unless issues_tracker.present? - - # we don't want to override if we have set something - return if project_url || issues_url || new_issue_url - - data_fields.project_url = issues_tracker['project_url'] - data_fields.issues_url = issues_tracker['issues_url'] - data_fields.new_issue_url = issues_tracker['new_issue_url'] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - message = "#{self.type} was unable to reach #{self.project_url}. Check the url and try again." - result = false - - begin - response = Gitlab::HTTP.head(self.project_url, verify: true) - - if response - message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" - result = true - end - rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error - message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" - end - log_info(message) - result - end - - def support_close_issue? - false - end - - def support_cross_reference? - false - end - - private - - def enabled_in_gitlab_config - Gitlab.config.issues_tracker && - Gitlab.config.issues_tracker.values.any? && - issues_tracker - end - - def issues_tracker - Gitlab.config.issues_tracker[to_param] - end - - def one_issue_tracker - return if template? || instance? - return if project.blank? - - if project.integrations.external_issue_trackers.where.not(id: id).any? - errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time')) - end - end -end - -IssueTrackerService.prepend_mod_with('IssueTrackerService') diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb deleted file mode 100644 index 990a35cd617..00000000000 --- a/app/models/project_services/jenkins_service.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -class JenkinsService < CiService - include ActionView::Helpers::UrlHelper - - prop_accessor :jenkins_url, :project_name, :username, :password - - before_update :reset_password - - validates :jenkins_url, presence: true, addressable_url: true, if: :activated? - validates :project_name, presence: true, if: :activated? - validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? } - - default_value_for :push_events, true - default_value_for :merge_requests_events, false - default_value_for :tag_push_events, false - - after_save :compose_service_hook, if: :activated? - - def reset_password - # don't reset the password if a new one is provided - if (jenkins_url_changed? || username.blank?) && !password_touched? - self.password = nil - end - end - - def compose_service_hook - hook = service_hook || build_service_hook - hook.url = hook_url - hook.save - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - service_hook.execute(data, "#{data[:object_kind]}_hook") - end - - def test(data) - begin - result = execute(data) - return { success: false, result: result[:message] } if result[:http_status] != 200 - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result[:message] } - end - - def hook_url - url = URI.parse(jenkins_url) - url.path = File.join(url.path || '/', "project/#{project_name}") - url.user = ERB::Util.url_encode(username) unless username.blank? - url.password = ERB::Util.url_encode(password) unless password.blank? - url.to_s - end - - def self.supported_events - %w(push merge_request tag_push) - end - - def title - 'Jenkins' - end - - def description - s_('Run CI/CD pipelines with Jenkins.') - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' - s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'jenkins' - end - - def fields - [ - { - type: 'text', - name: 'jenkins_url', - title: s_('ProjectService|Jenkins server URL'), - required: true, - placeholder: 'http://jenkins.example.com', - help: s_('The URL of the Jenkins server.') - }, - { - type: 'text', - name: 'project_name', - required: true, - placeholder: 'my_project_name', - help: s_('The name of the Jenkins project. Copy the name from the end of the URL to the project.') - }, - { - type: 'text', - name: 'username', - required: true, - help: s_('The username for the Jenkins server.') - }, - { - type: 'password', - name: 'password', - help: s_('The password for the Jenkins server.'), - non_empty_password_title: s_('ProjectService|Enter new password.'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current password.') - } - ] - end -end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb deleted file mode 100644 index 5cd6e79eb1d..00000000000 --- a/app/models/project_services/jira_service.rb +++ /dev/null @@ -1,541 +0,0 @@ -# frozen_string_literal: true - -# Accessible as Project#external_issue_tracker -class JiraService < IssueTrackerService - extend ::Gitlab::Utils::Override - include Gitlab::Routing - include ApplicationHelper - include ActionView::Helpers::AssetUrlHelper - include Gitlab::Utils::StrongMemoize - - PROJECTS_PER_PAGE = 50 - - # TODO: use jira_service.deployment_type enum when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged - DEPLOYMENT_TYPES = { - server: 'SERVER', - cloud: 'CLOUD' - }.freeze - - validates :url, public_url: true, presence: true, if: :activated? - validates :api_url, public_url: true, allow_blank: true - validates :username, presence: true, if: :activated? - validates :password, presence: true, if: :activated? - - validates :jira_issue_transition_id, - format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") }, - allow_blank: true - - # Jira Cloud version is deprecating authentication via username and password. - # We should use username/password for Jira Server and email/api_token for Jira Cloud, - # for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936. - - # TODO: we can probably just delegate as part of - # https://gitlab.com/gitlab-org/gitlab/issues/29404 - data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled, - :vulnerabilities_enabled, :vulnerabilities_issuetype - - before_update :reset_password - after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type? - - enum comment_detail: { - standard: 1, - all_details: 2 - } - - alias_method :project_url, :url - - # When these are false GitLab does not create cross reference - # comments on Jira except when an issue gets transitioned. - def self.supported_events - %w(commit merge_request) - end - - def self.supported_event_actions - %w(comment) - end - - # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 - def self.reference_pattern(only_long: true) - @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ - end - - def initialize_properties - {} - end - - def data_fields - jira_tracker_data || self.build_jira_tracker_data - end - - def reset_password - data_fields.password = nil if reset_password? - end - - def set_default_data - return unless issues_tracker.present? - - return if url - - data_fields.url ||= issues_tracker['url'] - data_fields.api_url ||= issues_tracker['api_url'] - end - - def options - url = URI.parse(client_url) - - { - username: username&.strip, - password: password, - site: URI.join(url, '/').to_s, # Intended to find the root - context_path: url.path, - auth_type: :basic, - read_timeout: 120, - use_cookies: true, - additional_cookies: ['OBBasicAuth=fromDialog'], - use_ssl: url.scheme == 'https' - } - end - - def client - @client ||= begin - JIRA::Client.new(options).tap do |client| - # Replaces JIRA default http client with our implementation - client.request_client = Gitlab::Jira::HttpClient.new(client.options) - end - end - end - - def help - jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } - s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } - end - - def title - 'Jira' - end - - def description - s_("JiraService|Use Jira as this project's issue tracker.") - end - - def self.to_param - 'jira' - end - - def fields - [ - { - type: 'text', - name: 'url', - title: s_('JiraService|Web URL'), - placeholder: 'https://jira.example.com', - help: s_('JiraService|Base URL of the Jira instance.'), - required: true - }, - { - type: 'text', - name: 'api_url', - title: s_('JiraService|Jira API URL'), - help: s_('JiraService|If different from Web URL.') - }, - { - type: 'text', - name: 'username', - title: s_('JiraService|Username or Email'), - help: s_('JiraService|Use a username for server version and an email for cloud version.'), - required: true - }, - { - type: 'password', - name: 'password', - title: s_('JiraService|Password or API token'), - non_empty_password_title: s_('JiraService|Enter new password or API token'), - non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'), - help: s_('JiraService|Use a password for server version and an API token for cloud version.'), - required: true - } - ] - end - - def issues_url - "#{url}/browse/:id" - end - - def new_issue_url - "#{url}/secure/CreateIssue!default.jspa" - end - - alias_method :original_url, :url - def url - original_url&.delete_suffix('/') - end - - alias_method :original_api_url, :api_url - def api_url - original_api_url&.delete_suffix('/') - end - - def execute(push) - # This method is a no-op, because currently JiraService does not - # support any events. - end - - def find_issue(issue_key, rendered_fields: false, transitions: false) - expands = [] - expands << 'renderedFields' if rendered_fields - expands << 'transitions' if transitions - options = { expand: expands.join(',') } if expands.any? - - jira_request { client.Issue.find(issue_key, options || {}) } - end - - def close_issue(entity, external_issue, current_user) - issue = find_issue(external_issue.iid, transitions: jira_issue_transition_automatic) - - return if issue.nil? || has_resolution?(issue) || !issue_transition_enabled? - - commit_id = case entity - when Commit then entity.id - when MergeRequest then entity.diff_head_sha - end - - commit_url = build_entity_url(:commit, commit_id) - - # Depending on the Jira project's workflow, a comment during transition - # may or may not be allowed. Refresh the issue after transition and check - # if it is closed, so we don't have one comment for every commit. - issue = find_issue(issue.key) if transition_issue(issue) - add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue) - log_usage(:close_issue, current_user) - end - - def create_cross_reference_note(mentioned, noteable, author) - unless can_cross_reference?(noteable) - return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) } - end - - jira_issue = find_issue(mentioned.id) - - return unless jira_issue.present? - - noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id - noteable_type = noteable_name(noteable) - entity_url = build_entity_url(noteable_type, noteable_id) - entity_meta = build_entity_meta(noteable) - - data = { - user: { - name: author.name, - url: resource_url(user_path(author)) - }, - project: { - name: project.full_path, - url: resource_url(project_path(project)) - }, - entity: { - id: entity_meta[:id], - name: noteable_type.humanize.downcase, - url: entity_url, - title: noteable.title, - description: entity_meta[:description], - branch: entity_meta[:branch] - } - } - - add_comment(data, jira_issue).tap { log_usage(:cross_reference, author) } - end - - def valid_connection? - test(nil)[:success] - end - - def test(_) - result = server_info - success = result.present? - result = @error&.message unless success - - { success: success, result: result } - end - - override :support_close_issue? - def support_close_issue? - true - end - - override :support_cross_reference? - def support_cross_reference? - true - end - - def issue_transition_enabled? - jira_issue_transition_automatic || jira_issue_transition_id.present? - end - - private - - def server_info - strong_memoize(:server_info) do - client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil - end - end - - def can_cross_reference?(noteable) - case noteable - when Commit then commit_events - when MergeRequest then merge_requests_events - else true - end - end - - # jira_issue_transition_id can have multiple values split by , or ; - # the issue is transitioned at the order given by the user - # if any transition fails it will log the error message and stop the transition sequence - def transition_issue(issue) - return transition_issue_to_done(issue) if jira_issue_transition_automatic - - jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).all? do |transition_id| - transition_issue_to_id(issue, transition_id) - end - end - - def transition_issue_to_id(issue, transition_id) - issue.transitions.build.save!( - transition: { id: transition_id } - ) - - true - rescue StandardError => error - log_error( - "Issue transition failed", - error: { - exception_class: error.class.name, - exception_message: error.message, - exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace) - }, - client_url: client_url - ) - - false - end - - def transition_issue_to_done(issue) - transitions = issue.transitions rescue [] - - transition = transitions.find do |transition| - status = transition&.to&.statusCategory - status && status['key'] == 'done' - end - - return false unless transition - - transition_issue_to_id(issue, transition.id) - end - - def log_usage(action, user) - key = "i_ecosystem_jira_service_#{action}" - - Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id) - end - - def add_issue_solved_comment(issue, commit_id, commit_url) - link_title = "Solved by commit #{commit_id}." - comment = "Issue solved with [#{commit_id}|#{commit_url}]." - link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) - send_message(issue, comment, link_props) - end - - def add_comment(data, issue) - entity_name = data[:entity][:name] - entity_url = data[:entity][:url] - entity_title = data[:entity][:title] - - message = comment_message(data) - link_title = "#{entity_name.capitalize} - #{entity_title}" - link_props = build_remote_link_props(url: entity_url, title: link_title) - - unless comment_exists?(issue, message) - send_message(issue, message, link_props) - end - end - - def comment_message(data) - user_link = build_jira_link(data[:user][:name], data[:user][:url]) - - entity = data[:entity] - entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}" - entity_link = build_jira_link(entity_ref, entity[:url]) - - project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project)) - branch = - if entity[:branch].present? - s_('JiraService| on branch %{branch_link}') % { - branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch])) - } - end - - entity_message = entity[:description].presence if all_details? - entity_message ||= entity[:title].chomp - - s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % { - user_link: user_link, - entity_link: entity_link, - project_link: project_link, - branch: branch, - entity_message: entity_message - } - end - - def build_jira_link(title, url) - "[#{title}|#{url}]" - end - - def has_resolution?(issue) - issue.respond_to?(:resolution) && issue.resolution.present? - end - - def comment_exists?(issue, message) - comments = jira_request { issue.comments } - - comments.present? && comments.any? { |comment| comment.body.include?(message) } - end - - def send_message(issue, message, remote_link_props) - return unless client_url.present? - - jira_request do - remote_link = find_remote_link(issue, remote_link_props[:object][:url]) - - create_issue_comment(issue, message) unless remote_link - remote_link ||= issue.remotelink.build - remote_link.save!(remote_link_props) - - log_info("Successfully posted", client_url: client_url) - "SUCCESS: Successfully posted to #{client_url}." - end - end - - def create_issue_comment(issue, message) - return unless comment_on_event_enabled - - issue.comments.build.save!(body: message) - end - - def find_remote_link(issue, url) - links = jira_request { issue.remotelink.all } - return unless links - - links.find { |link| link.object["url"] == url } - end - - def build_remote_link_props(url:, title:, resolved: false) - status = { - resolved: resolved - } - - { - GlobalID: 'GitLab', - relationship: 'mentioned on', - object: { - url: url, - title: title, - status: status, - icon: { - title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.base_url) - } - } - } - end - - def resource_url(resource) - "#{Settings.gitlab.base_url.chomp("/")}#{resource}" - end - - def build_entity_url(noteable_type, entity_id) - polymorphic_url( - [ - self.project, - noteable_type.to_sym - ], - id: entity_id, - host: Settings.gitlab.base_url - ) - end - - def build_entity_meta(noteable) - if noteable.is_a?(Commit) - { - id: noteable.short_id, - description: noteable.safe_message, - branch: noteable.ref_names(project.repository).first - } - elsif noteable.is_a?(MergeRequest) - { - id: noteable.to_reference, - branch: noteable.source_branch - } - else - {} - end - end - - def noteable_name(noteable) - name = noteable.model_name.singular - - # ProjectSnippet inherits from Snippet class so it causes - # routing error building the URL. - name == "project_snippet" ? "snippet" : name - end - - # Handle errors when doing Jira API calls - def jira_request - yield - rescue StandardError => error - @error = error - log_error("Error sending message", client_url: client_url, error: @error.message) - nil - end - - def client_url - api_url.presence || url - end - - def reset_password? - # don't reset the password if a new one is provided - return false if password_touched? - return true if api_url_changed? - return false if api_url.present? - - url_changed? - end - - def update_deployment_type? - (api_url_changed? || url_changed? || username_changed? || password_changed?) && - can_test? - end - - def update_deployment_type - clear_memoization(:server_info) # ensure we run the request when we try to update deployment type - results = server_info - return data_fields.deployment_unknown! unless results.present? - - case results['deploymentType'] - when 'Server' - data_fields.deployment_server! - when 'Cloud' - data_fields.deployment_cloud! - else - data_fields.deployment_unknown! - end - end - - def self.event_description(event) - case event - when "merge_request", "merge_request_events" - s_("JiraService|Jira comments will be created when an issue gets referenced in a merge request.") - when "commit", "commit_events" - s_("JiraService|Jira comments will be created when an issue gets referenced in a commit.") - end - end -end - -JiraService.prepend_mod_with('JiraService') diff --git a/app/models/project_services/jira_tracker_data.rb b/app/models/project_services/jira_tracker_data.rb deleted file mode 100644 index 2c145abf5c9..00000000000 --- a/app/models/project_services/jira_tracker_data.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class JiraTrackerData < ApplicationRecord - include Services::DataFields - include IgnorableColumns - - ignore_columns %i[ - encrypted_proxy_address - encrypted_proxy_address_iv - encrypted_proxy_port - encrypted_proxy_port_iv - encrypted_proxy_username - encrypted_proxy_username_iv - encrypted_proxy_password - encrypted_proxy_password_iv - ], remove_with: '14.0', remove_after: '2021-05-22' - - attr_encrypted :url, encryption_options - attr_encrypted :api_url, encryption_options - attr_encrypted :username, encryption_options - attr_encrypted :password, encryption_options - - enum deployment_type: { unknown: 0, server: 1, cloud: 2 }, _prefix: :deployment -end diff --git a/app/models/project_services/mattermost_service.rb b/app/models/project_services/mattermost_service.rb deleted file mode 100644 index 732a7c32a03..00000000000 --- a/app/models/project_services/mattermost_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class MattermostService < ChatNotificationService - include SlackMattermost::Notifier - include ActionView::Helpers::UrlHelper - - def title - s_('Mattermost notifications') - end - - def description - s_('Send notifications about project events to Mattermost channels.') - end - - def self.to_param - 'mattermost' - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer' - s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def default_channel_placeholder - 'my-channel' - end - - def webhook_placeholder - 'http://mattermost.example.com/hooks/' - end -end diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb deleted file mode 100644 index 60235a09dcd..00000000000 --- a/app/models/project_services/mattermost_slash_commands_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -class MattermostSlashCommandsService < SlashCommandsService - include Ci::TriggersHelper - - prop_accessor :token - - def can_test? - false - end - - def title - 'Mattermost slash commands' - end - - def description - "Perform common tasks with slash commands." - end - - def self.to_param - 'mattermost_slash_commands' - end - - def configure(user, params) - token = Mattermost::Command.new(user) - .create(command(params)) - - update(active: true, token: token) if token - rescue Mattermost::Error => e - [false, e.message] - end - - def list_teams(current_user) - [Mattermost::Team.new(current_user).all, nil] - rescue Mattermost::Error => e - [[], e.message] - end - - def chat_responder - ::Gitlab::Chat::Responder::Mattermost - end - - private - - def command(params) - pretty_project_name = project.full_name - - params.merge( - auto_complete: true, - auto_complete_desc: "Perform common operations on: #{pretty_project_name}", - auto_complete_hint: '[help]', - description: "Perform common operations on: #{pretty_project_name}", - display_name: "GitLab / #{pretty_project_name}", - method: 'P', - username: 'GitLab') - end -end diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb deleted file mode 100644 index 1d2067067da..00000000000 --- a/app/models/project_services/microsoft_teams_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -class MicrosoftTeamsService < ChatNotificationService - def title - 'Microsoft Teams notifications' - end - - def description - 'Send notifications about project events to Microsoft Teams.' - end - - def self.to_param - 'microsoft_teams' - end - - def help - '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html">How do I configure this integration?</a></p>' - end - - def webhook_placeholder - 'https://outlook.office.com/webhook/…' - end - - def event_field(event) - end - - def default_channel_placeholder - end - - def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] - end - - def default_fields - [ - { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, - { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'If selected, successful pipelines do not trigger a notification event.' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } - ] - end - - private - - def notify(message, opts) - MicrosoftTeams::Notifier.new(webhook).ping( - title: message.project_name, - summary: message.summary, - activity: message.activity, - attachments: message.attachments - ) - end - - def custom_data(data) - super(data).merge(markdown: true) - end -end diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb deleted file mode 100644 index bd6344c6e1a..00000000000 --- a/app/models/project_services/mock_ci_service.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service -class MockCiService < CiService - ALLOWED_STATES = %w[failed canceled running pending success success-with-warnings skipped not_found].freeze - - prop_accessor :mock_service_url - validates :mock_service_url, presence: true, public_url: true, if: :activated? - - def title - 'MockCI' - end - - def description - 'Mock an external CI' - end - - def self.to_param - 'mock_ci' - end - - def fields - [ - { - type: 'text', - name: 'mock_service_url', - title: s_('ProjectService|Mock service URL'), - placeholder: 'http://localhost:4004', - required: true - } - ] - end - - # Return complete url to build page - # - # Ex. - # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c - # - def build_page(sha, ref) - Gitlab::Utils.append_path( - mock_service_url, - "#{project.namespace.path}/#{project.path}/status/#{sha}") - end - - # Return string with build status or :error symbol - # - # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' - # - # - # Ex. - # @service.commit_status('13be4ac', 'master') - # # => 'success' - # - # @service.commit_status('2abe4ac', 'dev') - # # => 'running' - # - # - def commit_status(sha, ref) - response = Gitlab::HTTP.get(commit_status_path(sha), verify: false) - read_commit_status(response) - rescue Errno::ECONNREFUSED - :error - end - - def commit_status_path(sha) - Gitlab::Utils.append_path( - mock_service_url, - "#{project.namespace.path}/#{project.path}/status/#{sha}.json") - end - - def read_commit_status(response) - return :error unless response.code == 200 || response.code == 404 - - status = if response.code == 404 - 'pending' - else - response['status'] - end - - if status.present? && ALLOWED_STATES.include?(status) - status - else - :error - end - end - - def can_test? - false - end -end diff --git a/app/models/project_services/open_project_service.rb b/app/models/project_services/open_project_service.rb deleted file mode 100644 index a24fbc1611d..00000000000 --- a/app/models/project_services/open_project_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class OpenProjectService < IssueTrackerService - validates :url, public_url: true, presence: true, if: :activated? - validates :api_url, public_url: true, allow_blank: true, if: :activated? - validates :token, presence: true, if: :activated? - validates :project_identifier_code, presence: true, if: :activated? - - data_field :url, :api_url, :token, :closed_status_id, :project_identifier_code - - def data_fields - open_project_tracker_data || self.build_open_project_tracker_data - end - - def self.to_param - 'open_project' - end -end diff --git a/app/models/project_services/open_project_tracker_data.rb b/app/models/project_services/open_project_tracker_data.rb deleted file mode 100644 index 20de60e40c1..00000000000 --- a/app/models/project_services/open_project_tracker_data.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class OpenProjectTrackerData < ApplicationRecord - include Services::DataFields - - # When the Open Project is fresh installed, the default closed status id is "13" based on current version: v8. - DEFAULT_CLOSED_STATUS_ID = "13" - - attr_encrypted :url, encryption_options - attr_encrypted :api_url, encryption_options - attr_encrypted :token, encryption_options - - def closed_status_id - super || DEFAULT_CLOSED_STATUS_ID - end -end diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb deleted file mode 100644 index f3ea8c64302..00000000000 --- a/app/models/project_services/packagist_service.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -class PackagistService < Integration - prop_accessor :username, :token, :server - - validates :username, presence: true, if: :activated? - validates :token, presence: true, if: :activated? - - default_value_for :push_events, true - default_value_for :tag_push_events, true - - after_save :compose_service_hook, if: :activated? - - def title - 'Packagist' - end - - def description - s_('Integrations|Update your Packagist projects.') - end - - def self.to_param - 'packagist' - end - - def fields - [ - { type: 'text', name: 'username', placeholder: '', required: true }, - { type: 'text', name: 'token', placeholder: '', required: true }, - { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } - ] - end - - def self.supported_events - %w(push merge_request tag_push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - service_hook.execute(data) - end - - def test(data) - begin - result = execute(data) - return { success: false, result: result[:message] } if result[:http_status] != 202 - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result[:message] } - end - - def compose_service_hook - hook = service_hook || build_service_hook - hook.url = hook_url - hook.save - end - - def hook_url - base_url = server.presence || 'https://packagist.org' - "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" - end -end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb deleted file mode 100644 index 4603193ac8e..00000000000 --- a/app/models/project_services/pipelines_email_service.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -class PipelinesEmailService < Integration - include NotificationBranchSelection - - prop_accessor :recipients, :branches_to_be_notified - boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch - validates :recipients, presence: true, if: :validate_recipients? - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - self.branches_to_be_notified = "default" - elsif !self.notify_only_default_branch.nil? - # In older versions, there was only a boolean property named - # `notify_only_default_branch`. Now we have a string property named - # `branches_to_be_notified`. Instead of doing a background migration, we - # opted to set a value for the new property based on the old one, if - # users hasn't specified one already. When users edit the service and - # selects a value for this new property, it will override everything. - - self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" - end - end - - def title - _('Pipeline status emails') - end - - def description - _('Email the pipeline status to a list of recipients.') - end - - def self.to_param - 'pipelines_email' - end - - def self.supported_events - %w[pipeline] - end - - def self.default_test_event - 'pipeline' - end - - def execute(data, force: false) - return unless supported_events.include?(data[:object_kind]) - return unless force || should_pipeline_be_notified?(data) - - all_recipients = retrieve_recipients(data) - - return unless all_recipients.any? - - pipeline_id = data[:object_attributes][:id] - PipelineNotificationWorker.new.perform(pipeline_id, recipients: all_recipients) - end - - def can_test? - project&.ci_pipelines&.any? - end - - def fields - [ - { type: 'textarea', - name: 'recipients', - help: _('Comma-separated list of email addresses.'), - required: true }, - { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, - { type: 'select', - name: 'branches_to_be_notified', - choices: branch_choices } - ] - end - - def test(data) - result = execute(data, force: true) - - { success: true, result: result } - rescue StandardError => error - { success: false, result: error } - end - - def should_pipeline_be_notified?(data) - notify_for_branch?(data) && notify_for_pipeline?(data) - end - - def notify_for_pipeline?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end - end - - def retrieve_recipients(data) - recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?) - end -end diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb deleted file mode 100644 index 6e67984591d..00000000000 --- a/app/models/project_services/pivotaltracker_service.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -class PivotaltrackerService < Integration - API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' - - prop_accessor :token, :restrict_to_branch - validates :token, presence: true, if: :activated? - - def title - 'PivotalTracker' - end - - def description - s_('PivotalTrackerService|Add commit messages as comments to PivotalTracker stories.') - end - - def self.to_param - 'pivotaltracker' - end - - def fields - [ - { - type: 'text', - name: 'token', - placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'), - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \ - 'automatically inspected. Leave blank to include all branches.') - } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - return unless allowed_branch?(data[:ref]) - - data[:commits].each do |commit| - message = { - 'source_commit' => { - 'commit_id' => commit[:id], - 'author' => commit[:author][:name], - 'url' => commit[:url], - 'message' => commit[:message] - } - } - Gitlab::HTTP.post( - API_ENDPOINT, - body: message.to_json, - headers: { - 'Content-Type' => 'application/json', - 'X-TrackerToken' => token - } - ) - end - end - - private - - def allowed_branch?(ref) - return true unless ref.present? && restrict_to_branch.present? - - branch = Gitlab::Git.ref_name(ref) - allowed_branches = restrict_to_branch.split(',').map(&:strip) - - branch.present? && allowed_branches.include?(branch) - end -end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index b8869547a37..a289c1c2afb 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -117,8 +117,8 @@ class PrometheusService < MonitoringService return false if template? return false unless project - project.all_clusters.enabled.eager_load(:application_prometheus).any? do |cluster| - cluster.application_prometheus&.available? + project.all_clusters.enabled.eager_load(:integration_prometheus).any? do |cluster| + cluster.integration_prometheus_available? end end diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb deleted file mode 100644 index 89765fbdf41..00000000000 --- a/app/models/project_services/pushover_service.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true - -class PushoverService < Integration - BASE_URI = 'https://api.pushover.net/1' - - prop_accessor :api_key, :user_key, :device, :priority, :sound - validates :api_key, :user_key, :priority, presence: true, if: :activated? - - def title - 'Pushover' - end - - def description - s_('PushoverService|Get real-time notifications on your device.') - end - - def self.to_param - 'pushover' - end - - def fields - [ - { type: 'text', name: 'api_key', title: _('API key'), placeholder: s_('PushoverService|Your application key'), required: true }, - { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true }, - { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') }, - { type: 'select', name: 'priority', required: true, choices: - [ - [s_('PushoverService|Lowest Priority'), -2], - [s_('PushoverService|Low Priority'), -1], - [s_('PushoverService|Normal Priority'), 0], - [s_('PushoverService|High Priority'), 1] - ], - default_choice: 0 }, - { type: 'select', name: 'sound', choices: - [ - ['Device default sound', nil], - ['Pushover (default)', 'pushover'], - %w(Bike bike), - %w(Bugle bugle), - ['Cash Register', 'cashregister'], - %w(Classical classical), - %w(Cosmic cosmic), - %w(Falling falling), - %w(Gamelan gamelan), - %w(Incoming incoming), - %w(Intermission intermission), - %w(Magic magic), - %w(Mechanical mechanical), - ['Piano Bar', 'pianobar'], - %w(Siren siren), - ['Space Alarm', 'spacealarm'], - ['Tug Boat', 'tugboat'], - ['Alien Alarm (long)', 'alien'], - ['Climb (long)', 'climb'], - ['Persistent (long)', 'persistent'], - ['Pushover Echo (long)', 'echo'], - ['Up Down (long)', 'updown'], - ['None (silent)', 'none'] - ] } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - ref = Gitlab::Git.ref_name(data[:ref]) - before = data[:before] - after = data[:after] - - message = - if Gitlab::Git.blank_ref?(before) - s_("PushoverService|%{user_name} pushed new branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } - elsif Gitlab::Git.blank_ref?(after) - s_("PushoverService|%{user_name} deleted branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } - else - s_("PushoverService|%{user_name} push to branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } - end - - if data[:total_commits_count] > 0 - message = [message, s_("PushoverService|Total commits count: %{total_commits_count}") % { total_commits_count: data[:total_commits_count] }].join("\n") - end - - pushover_data = { - token: api_key, - user: user_key, - device: device, - priority: priority, - title: "#{project.full_name}", - message: message, - url: data[:project][:web_url], - url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name } - } - - # Sound parameter MUST NOT be sent to API if not selected - if sound - pushover_data[:sound] = sound - end - - Gitlab::HTTP.post('/messages.json', base_uri: BASE_URI, body: pushover_data) - end -end diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb deleted file mode 100644 index 7a0f500209c..00000000000 --- a/app/models/project_services/redmine_service.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class RedmineService < IssueTrackerService - include ActionView::Helpers::UrlHelper - validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - - def title - 'Redmine' - end - - def description - s_("IssueTracker|Use Redmine as this project's issue tracker.") - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer' - s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'redmine' - end -end diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb deleted file mode 100644 index 92a46f8d01f..00000000000 --- a/app/models/project_services/slack_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -class SlackService < ChatNotificationService - include SlackMattermost::Notifier - extend ::Gitlab::Utils::Override - - SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[ - push issue confidential_issue merge_request note confidential_note - tag_push wiki_page deployment - ].freeze - - prop_accessor EVENT_CHANNEL['alert'] - - def title - 'Slack notifications' - end - - def description - 'Send notifications about project events to Slack.' - end - - def self.to_param - 'slack' - end - - def default_channel_placeholder - _('general, development') - end - - def webhook_placeholder - 'https://hooks.slack.com/services/…' - end - - def supported_events - additional = [] - additional << 'alert' - - super + additional - end - - def get_message(object_kind, data) - return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' - - super - end - - override :log_usage - def log_usage(event, user_id) - return unless user_id - - return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event) - - key = "i_ecosystem_slack_service_#{event}_notification" - - Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) - end -end diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb deleted file mode 100644 index 548f3623504..00000000000 --- a/app/models/project_services/slack_slash_commands_service.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class SlackSlashCommandsService < SlashCommandsService - include Ci::TriggersHelper - - def title - 'Slack slash commands' - end - - def description - "Perform common operations in Slack" - end - - def self.to_param - 'slack_slash_commands' - end - - def trigger(params) - # Format messages to be Slack-compatible - super.tap do |result| - result[:text] = format(result[:text]) if result.is_a?(Hash) - end - end - - def chat_responder - ::Gitlab::Chat::Responder::Slack - end - - private - - def format(text) - Slack::Messenger::Util::LinkFormatter.format(text) if text - end -end diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb deleted file mode 100644 index 37d16737052..00000000000 --- a/app/models/project_services/slash_commands_service.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -# Base class for Chat services -# This class is not meant to be used directly, but only to inherrit from. -class SlashCommandsService < Integration - default_value_for :category, 'chat' - - prop_accessor :token - - has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - - def valid_token?(token) - self.respond_to?(:token) && - self.token.present? && - ActiveSupport::SecurityUtils.secure_compare(token, self.token) - end - - def self.supported_events - %w() - end - - def can_test? - false - end - - def fields - [ - { type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' } - ] - end - - def trigger(params) - return unless valid_token?(params[:token]) - - chat_user = find_chat_user(params) - user = chat_user&.user - - if user - unless user.can?(:use_slash_commands) - return Gitlab::SlashCommands::Presenters::Access.new.deactivated if user.deactivated? - - return Gitlab::SlashCommands::Presenters::Access.new.access_denied(project) - end - - Gitlab::SlashCommands::Command.new(project, chat_user, params).execute - else - url = authorize_chat_name_url(params) - Gitlab::SlashCommands::Presenters::Access.new(url).authorize - end - end - - private - - # rubocop: disable CodeReuse/ServiceClass - def find_chat_user(params) - ChatNames::FindUserService.new(self, params).execute - end - # rubocop: enable CodeReuse/ServiceClass - - # rubocop: disable CodeReuse/ServiceClass - def authorize_chat_name_url(params) - ChatNames::AuthorizeUserService.new(self, params).execute - end - # rubocop: enable CodeReuse/ServiceClass -end diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb deleted file mode 100644 index 6fc24a4778c..00000000000 --- a/app/models/project_services/teamcity_service.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -class TeamcityService < CiService - include ReactiveService - include ServicePushDataValidations - - prop_accessor :teamcity_url, :build_type, :username, :password - - validates :teamcity_url, presence: true, public_url: true, if: :activated? - validates :build_type, presence: true, if: :activated? - validates :username, - presence: true, - if: ->(service) { service.activated? && service.password } - validates :password, - presence: true, - if: ->(service) { service.activated? && service.username } - - attr_accessor :response - - after_save :compose_service_hook, if: :activated? - before_update :reset_password - - class << self - def to_param - 'teamcity' - end - - def supported_events - %w(push merge_request) - end - - def event_description(event) - case event - when 'push', 'push_events' - 'TeamCity CI will be triggered after every push to the repository except branch delete' - when 'merge_request', 'merge_request_events' - 'TeamCity CI will be triggered after a merge request has been created or updated' - end - end - end - - def compose_service_hook - hook = service_hook || build_service_hook - hook.save - end - - def reset_password - if teamcity_url_changed? && !password_touched? - self.password = nil - end - end - - def title - 'JetBrains TeamCity' - end - - def description - s_('ProjectService|Run CI/CD pipelines with JetBrains TeamCity.') - end - - def help - s_('To run CI/CD pipelines with JetBrains TeamCity, input the GitLab project details in the TeamCity project Version Control Settings.') - end - - def fields - [ - { - type: 'text', - name: 'teamcity_url', - title: s_('ProjectService|TeamCity server URL'), - placeholder: 'https://teamcity.example.com', - required: true - }, - { - type: 'text', - name: 'build_type', - help: s_('ProjectService|The build configuration ID of the TeamCity project.'), - required: true - }, - { - type: 'text', - name: 'username', - help: s_('ProjectService|Must have permission to trigger a manual build in TeamCity.') - }, - { - type: 'password', - name: 'password', - non_empty_password_title: s_('ProjectService|Enter new password'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current password') - } - ] - end - - def build_page(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:build_page] } - end - - def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } - end - - def calculate_reactive_cache(sha, ref) - response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}") - - if response - { build_page: read_build_page(response), commit_status: read_commit_status(response) } - else - { build_page: teamcity_url, commit_status: :error } - end - end - - def execute(data) - case data[:object_kind] - when 'push' - execute_push(data) - when 'merge_request' - execute_merge_request(data) - end - end - - private - - def execute_push(data) - branch = Gitlab::Git.ref_name(data[:ref]) - post_to_build_queue(data, branch) if push_valid?(data) - end - - def execute_merge_request(data) - branch = data[:object_attributes][:source_branch] - post_to_build_queue(data, branch) if merge_request_valid?(data) - end - - def read_build_page(response) - if response.code != 200 - # If actual build link can't be determined, - # send user to build summary page. - build_url("viewLog.html?buildTypeId=#{build_type}") - else - # If actual build link is available, go to build result page. - built_id = response['build']['id'] - build_url("viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}") - end - end - - def read_commit_status(response) - return :error unless response.code == 200 || response.code == 404 - - status = if response.code == 404 - 'Pending' - else - response['build']['status'] - end - - return :error unless status.present? - - if status.include?('SUCCESS') - 'success' - elsif status.include?('FAILURE') - 'failed' - elsif status.include?('Pending') - 'pending' - else - :error - end - end - - def build_url(path) - Gitlab::Utils.append_path(teamcity_url, path) - end - - def get_path(path) - Gitlab::HTTP.try_get(build_url(path), verify: false, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) - end - - def post_to_build_queue(data, branch) - Gitlab::HTTP.post( - build_url('httpAuth/app/rest/buildQueue'), - body: "<build branchName=#{branch.encode(xml: :attr)}>"\ - "<buildType id=#{build_type.encode(xml: :attr)}/>"\ - '</build>', - headers: { 'Content-type' => 'application/xml' }, - basic_auth: basic_auth - ) - end - - def basic_auth - { username: username, password: password } - end -end diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb deleted file mode 100644 index 5f43388e1c9..00000000000 --- a/app/models/project_services/unify_circuit_service.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -class UnifyCircuitService < ChatNotificationService - def title - 'Unify Circuit' - end - - def description - s_('Integrations|Send notifications about project events to Unify Circuit.') - end - - def self.to_param - 'unify_circuit' - end - - def help - 'This service sends notifications about projects events to a Unify Circuit conversation.<br /> - To set up this service: - <ol> - <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> - <li>Paste the <strong>Webhook URL</strong> into the field below.</li> - <li>Select events below to enable notifications.</li> - </ol>' - end - - def event_field(event) - end - - def default_channel_placeholder - end - - def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] - end - - def default_fields - [ - { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } - ] - end - - private - - def notify(message, opts) - response = Gitlab::HTTP.post(webhook, body: { - subject: message.project_name, - text: message.summary, - markdown: true - }.to_json) - - response if response.success? - end - - def custom_data(data) - super(data).merge(markdown: true) - end -end diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb deleted file mode 100644 index 3d92d3bb85e..00000000000 --- a/app/models/project_services/webex_teams_service.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -class WebexTeamsService < ChatNotificationService - include ActionView::Helpers::UrlHelper - - def title - s_("WebexTeamsService|Webex Teams") - end - - def description - s_("WebexTeamsService|Send notifications about project events to Webex Teams.") - end - - def self.to_param - 'webex_teams' - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' - s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } - end - - def event_field(event) - end - - def default_channel_placeholder - end - - def self.supported_events - %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] - end - - def default_fields - [ - { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } - ] - end - - private - - def notify(message, opts) - header = { 'Content-Type' => 'application/json' } - response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) - - response if response.success? - end - - def custom_data(data) - super(data).merge(markdown: true) - end -end diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb deleted file mode 100644 index 9760a22a872..00000000000 --- a/app/models/project_services/youtrack_service.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -class YoutrackService < IssueTrackerService - include ActionView::Helpers::UrlHelper - - validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? - - # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 - def self.reference_pattern(only_long: false) - if only_long - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ - else - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/ - end - end - - def title - 'YouTrack' - end - - def description - s_("IssueTracker|Use YouTrack as this project's issue tracker.") - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' - s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'youtrack' - end - - def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } - ] - end -end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 37ddd2d030d..387732cf151 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -94,18 +94,14 @@ class ProjectStatistics < ApplicationRecord end def update_storage_size - storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size - # The `snippets_size` column was added on 20200622095419 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb - # might try to update project statistics before the `snippets_size` column has been created. - storage_size += snippets_size if self.class.column_names.include?('snippets_size') - - # The `pipeline_artifacts_size` column was added on 20200817142800 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb - # might try to update project statistics before the `pipeline_artifacts_size` column has been created. - storage_size += pipeline_artifacts_size if self.class.column_names.include?('pipeline_artifacts_size') - - # The `uploads_size` column was added on 20201105021637 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb - # might try to update project statistics before the `uploads_size` column has been created. - storage_size += uploads_size if self.class.column_names.include?('uploads_size') + storage_size = repository_size + + wiki_size + + lfs_objects_size + + build_artifacts_size + + packages_size + + snippets_size + + pipeline_artifacts_size + + uploads_size self.storage_size = storage_size end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 889eaed138d..3df8fe31826 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -30,8 +30,6 @@ class ProtectedBranch < ApplicationRecord end def self.allow_force_push?(project, ref_name) - return false unless ::Feature.enabled?(:allow_force_push_to_protected_branches, project, default_enabled: :yaml) - project.protected_branches.allowing_force_push.matching(ref_name).any? end diff --git a/app/models/release.rb b/app/models/release.rb index 1889a0707b4..aad1cbeabdb 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -39,10 +39,10 @@ class Release < ApplicationRecord scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } # Sorting - scope :order_created, -> { reorder('created_at ASC') } - scope :order_created_desc, -> { reorder('created_at DESC') } - scope :order_released, -> { reorder('released_at ASC') } - scope :order_released_desc, -> { reorder('released_at DESC') } + scope :order_created, -> { reorder(created_at: :asc) } + scope :order_created_desc, -> { reorder(created_at: :desc) } + scope :order_released, -> { reorder(released_at: :asc) } + scope :order_released_desc, -> { reorder(released_at: :desc) } delegate :repository, to: :project diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index 9c30d0611e6..84e0a43670b 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -33,7 +33,7 @@ class ReleaseHighlight next unless include_item?(item) begin - item.tap {|i| i['body'] = Kramdown::Document.new(i['body']).to_html } + item.tap {|i| i['body'] = Banzai.render(i['body'], { project: nil }) } rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, file_path: file_path) diff --git a/app/models/repository.rb b/app/models/repository.rb index 7dca8e52403..1bd61fe48cb 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -938,6 +938,8 @@ class Repository end def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true) + return fetch_remote(remote_name, url: url, refmap: refmap, forced: forced, prune: prune) if Feature.enabled?(:fetch_remote_params, project, default_enabled: :yaml) + unless remote_name remote_name = "tmp-#{SecureRandom.hex}" tmp_remote_name = true diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb index bcc17d32272..c5203354b9d 100644 --- a/app/models/service_desk_setting.rb +++ b/app/models/service_desk_setting.rb @@ -6,9 +6,12 @@ class ServiceDeskSetting < ApplicationRecord belongs_to :project validates :project_id, presence: true validate :valid_issue_template + validate :valid_project_key validates :outgoing_name, length: { maximum: 255 }, allow_blank: true validates :project_key, length: { maximum: 255 }, allow_blank: true, format: { with: /\A[a-z0-9_]+\z/ } + scope :with_project_key, ->(key) { where(project_key: key) } + def issue_template_content strong_memoize(:issue_template_content) do next unless issue_template_key.present? @@ -27,4 +30,23 @@ class ServiceDeskSetting < ApplicationRecord errors.add(:issue_template_key, 'is empty or does not exist') end end + + def valid_project_key + if projects_with_same_slug_and_key_exists? + errors.add(:project_key, 'already in use for another service desk address.') + end + end + + private + + def projects_with_same_slug_and_key_exists? + return false unless project_key + + settings = self.class.with_project_key(project_key).preload(:project) + project_slug = self.project.full_path_slug + + settings.any? do |setting| + setting.project.full_path_slug == project_slug + end + end end diff --git a/app/models/snippet_repository_storage_move.rb b/app/models/snippet_repository_storage_move.rb deleted file mode 100644 index 8234905a7e1..00000000000 --- a/app/models/snippet_repository_storage_move.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853. -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 14.0 -# https://gitlab.com/gitlab-org/gitlab/-/issues/322393 -class SnippetRepositoryStorageMove < Snippets::RepositoryStorageMove -end diff --git a/app/models/timelog.rb b/app/models/timelog.rb index bd543526685..96fd485b797 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -18,8 +18,12 @@ class Timelog < ApplicationRecord joins(:project).where(projects: { namespace: group.self_and_descendants }) end - scope :between_times, -> (start_time, end_time) do - where('spent_at BETWEEN ? AND ?', start_time, end_time) + scope :at_or_after, -> (start_time) do + where('spent_at >= ?', start_time) + end + + scope :at_or_before, -> (end_time) do + where('spent_at <= ?', end_time) end def issuable diff --git a/app/models/todo.rb b/app/models/todo.rb index 23685fb68e0..94a99603848 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -61,6 +61,7 @@ class Todo < ApplicationRecord scope :for_author, -> (author) { where(author: author) } scope :for_user, -> (user) { where(user: user) } scope :for_project, -> (projects) { where(project: projects) } + scope :for_note, -> (notes) { where(note: notes) } scope :for_undeleted_projects, -> { joins(:project).merge(Project.without_deleted) } scope :for_group, -> (group) { where(group: group) } scope :for_type, -> (type) { where(target_type: type) } diff --git a/app/models/user.rb b/app/models/user.rb index 0eb58baae11..8ee0421e45f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,6 +35,9 @@ class User < ApplicationRecord COUNT_CACHE_VALIDITY_PERIOD = 24.hours + MAX_USERNAME_LENGTH = 255 + MIN_USERNAME_LENGTH = 2 + add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } add_authentication_token_field :feed_token add_authentication_token_field :static_object_token @@ -96,12 +99,6 @@ class User < ApplicationRecord # Virtual attribute for impersonator attr_accessor :impersonator - attr_writer :max_access_for_group - - def max_access_for_group - @max_access_for_group ||= {} - end - # # Relations # @@ -111,7 +108,7 @@ class User < ApplicationRecord # Profile has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key' + has_many :expired_and_unnotified_keys, -> { expired_and_not_notified }, class_name: 'Key' has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key' has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent has_many :group_deploy_keys @@ -315,10 +312,11 @@ class User < ApplicationRecord delegate :other_role, :other_role=, to: :user_detail, allow_nil: true delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true + delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true - accepts_nested_attributes_for :credit_card_validation, update_only: true + accepts_nested_attributes_for :credit_card_validation, update_only: true, allow_destroy: true state_machine :state, initial: :active do event :block do @@ -414,14 +412,7 @@ class User < ApplicationRecord .without_impersonation .expired_today_and_not_notified) end - scope :with_ssh_key_expired_today, -> do - includes(:expired_today_and_unnotified_keys) - .where('EXISTS (?)', - ::Key - .select(1) - .where('keys.user_id = users.id') - .expired_today_and_not_notified) - end + scope :with_ssh_key_expiring_soon, -> do includes(:expiring_soon_and_unnotified_keys) .where('EXISTS (?)', @@ -791,6 +782,16 @@ class User < ApplicationRecord end end + def automation_bot + email_pattern = "automation%s@#{Settings.gitlab.host}" + + unique_internal(where(user_type: :automation_bot), 'automation-bot', email_pattern) do |u| + u.bio = 'The GitLab automation bot used for automated workflows and tasks' + u.name = 'GitLab Automation Bot' + u.avatar = bot_avatar(image: 'support-bot.png') # todo: add an avatar for automation-bot + end + end + # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -1703,12 +1704,6 @@ class User < ApplicationRecord def invalidate_issue_cache_counts Rails.cache.delete(['users', id, 'assigned_open_issues_count']) - - if Feature.enabled?(:assigned_open_issues_cache, default_enabled: :yaml) - run_after_commit do - Users::UpdateOpenIssueCountWorker.perform_async(self.id) - end - end end def invalidate_merge_request_cache_counts @@ -1928,6 +1923,20 @@ class User < ApplicationRecord confirmed? && !blocked? && !ghost? end + # This attribute hosts a Ci::JobToken::Scope object which is set when + # the user is authenticated successfully via CI_JOB_TOKEN. + def ci_job_token_scope + Gitlab::SafeRequestStore[ci_job_token_scope_cache_key] + end + + def set_ci_job_token_scope!(job) + Gitlab::SafeRequestStore[ci_job_token_scope_cache_key] = Ci::JobToken::Scope.new(job.project) + end + + def from_ci_job_token? + ci_job_token_scope.present? + end + protected # override, from Devise::Validatable @@ -2091,6 +2100,10 @@ class User < ApplicationRecord def update_highest_role_attribute id end + + def ci_job_token_scope_cache_key + "users:#{id}:ci:job_token_scope" + end end User.prepend_mod_with('User') diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 8fc9efddac9..2e8ff1b7b49 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -16,9 +16,7 @@ class UserCallout < ApplicationRecord tabs_position_highlight: 10, threat_monitoring_info: 11, # EE-only account_recovery_regular_check: 12, # EE-only - webhooks_moved: 13, service_templates_deprecated_callout: 14, - admin_integrations_moved: 15, web_ide_alert_dismissed: 16, # no longer in use active_user_count_threshold: 18, # EE-only buy_pipeline_minutes_notification_dot: 19, # EE-only @@ -32,7 +30,8 @@ class UserCallout < ApplicationRecord eoa_bronze_plan_banner: 28, # EE-only pipeline_needs_banner: 29, pipeline_needs_hover_tip: 30, - web_ide_ci_environments_guidance: 31 + web_ide_ci_environments_guidance: 31, + security_configuration_upgrade_banner: 32 } validates :user, presence: true diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 458764632ed..47537e5885f 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -6,6 +6,7 @@ class UserDetail < ApplicationRecord belongs_to :user + validates :pronouns, length: { maximum: 50 } validates :job_title, length: { maximum: 200 } validates :bio, length: { maximum: 255 }, allow_blank: true diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index 195cfe162ac..3e5e7b259d8 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -18,7 +18,8 @@ module Users create: 0, verify: 1, trial: 2, - team: 3 + team: 3, + experience: 4 }, _suffix: true scope :without_track_and_series, -> (track, series) do diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index 08a26da6673..cbc34bdeed3 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -84,6 +84,10 @@ module PolicyActor def password_expired? false end + + def from_ci_job_token? + false + end end PolicyActor.prepend_mod_with('PolicyActor') diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 73757891cd6..35d38bac7fa 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -115,6 +115,7 @@ class GlobalPolicy < BasePolicy enable :approve_user enable :reject_user enable :read_usage_trends_measurement + enable :update_runners_registration_token end # We can't use `read_statistics` because the user may have different permissions for different projects diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 821fabec266..ba06b98e906 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -131,7 +131,6 @@ class GroupPolicy < BasePolicy enable :read_prometheus enable :read_package enable :read_package_settings - enable :read_group_timelogs end rule { maintainer }.policy do @@ -145,6 +144,7 @@ class GroupPolicy < BasePolicy enable :admin_cluster enable :read_deploy_token enable :create_jira_connect_subscription + enable :update_runners_registration_token end rule { owner }.policy do @@ -155,6 +155,7 @@ class GroupPolicy < BasePolicy enable :set_note_created_at enable :set_emails_disabled + enable :change_prevent_sharing_groups_outside_hierarchy enable :update_default_branch_protection enable :create_deploy_token enable :destroy_deploy_token diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 6eec03d6d75..e58179e320d 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -15,6 +15,9 @@ class IssuePolicy < IssuablePolicy desc "Issue is confidential" condition(:confidential, scope: :subject) { @subject.confidential? } + desc "Issue is persisted" + condition(:persisted, scope: :subject) { @subject.persisted? } + rule { confidential & ~can_read_confidential }.policy do prevent(*create_read_update_admin_destroy(:issue)) prevent :read_issue_iid @@ -38,6 +41,15 @@ class IssuePolicy < IssuablePolicy rule { ~anonymous & can?(:read_issue) }.policy do enable :create_todo + enable :update_subscription + end + + rule { ~persisted & can?(:guest_access) }.policy do + enable :set_issue_metadata + end + + rule { persisted & can?(:admin_issue) }.policy do + enable :set_issue_metadata end end diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index e53a916f3ca..96002d98afe 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -20,6 +20,7 @@ class MergeRequestPolicy < IssuablePolicy rule { ~anonymous & can?(:read_merge_request) }.policy do enable :create_todo + enable :update_subscription end condition(:can_merge) { @subject.can_be_merged_by?(@user) } @@ -27,6 +28,10 @@ class MergeRequestPolicy < IssuablePolicy rule { can_merge }.policy do enable :accept_merge_request end + + rule { can?(:admin_merge_request) }.policy do + enable :set_merge_request_metadata + end end MergeRequestPolicy.prepend_mod_with('MergeRequestPolicy') diff --git a/app/policies/packages/pypi/metadatum_policy.rb b/app/policies/packages/pypi/metadatum_policy.rb new file mode 100644 index 00000000000..5cdcb613f61 --- /dev/null +++ b/app/policies/packages/pypi/metadatum_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Pypi + class MetadatumPolicy < BasePolicy + delegate { @subject.package } + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 1ce19511bef..e93c60c3710 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -51,7 +51,11 @@ class ProjectPolicy < BasePolicy desc "Container registry is disabled" condition(:container_registry_disabled, scope: :subject) do - !project.container_registry_enabled + if ::Feature.enabled?(:read_container_registry_access_level, @subject&.namespace, default_enabled: :yaml) + !access_allowed_to?(:container_registry) + else + !project.container_registry_enabled + end end desc "Project has an external wiki" @@ -75,6 +79,11 @@ class ProjectPolicy < BasePolicy user.is_a?(DeployToken) && user.has_access_to?(project) && user.write_package_registry end + desc "If user is authenticated via CI job token then the target project should be in scope" + condition(:project_allowed_for_job_token) do + !@user&.from_ci_job_token? || @user.ci_job_token_scope.includes?(project) + end + with_scope :subject condition(:forking_allowed) do @subject.feature_available?(:forking, @user) @@ -238,6 +247,7 @@ class ProjectPolicy < BasePolicy enable :admin_issue_board enable :download_code enable :read_statistics + enable :daily_statistics enable :download_wiki_code enable :create_snippet enable :update_issue @@ -263,7 +273,6 @@ class ProjectPolicy < BasePolicy enable :read_confidential_issues enable :read_package enable :read_product_analytics - enable :read_group_timelogs end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -347,7 +356,6 @@ class ProjectPolicy < BasePolicy enable :update_deployment enable :create_release enable :update_release - enable :daily_statistics enable :create_metrics_dashboard_annotation enable :delete_metrics_dashboard_annotation enable :update_metrics_dashboard_annotation @@ -411,6 +419,7 @@ class ProjectPolicy < BasePolicy enable :update_freeze_period enable :destroy_freeze_period enable :admin_feature_flags_client + enable :update_runners_registration_token end rule { public_project & metrics_dashboard_allowed }.policy do @@ -509,6 +518,8 @@ class ProjectPolicy < BasePolicy enable :read_project_for_iids end + rule { ~project_allowed_for_job_token }.prevent_all + rule { can?(:public_access) }.policy do enable :read_package enable :read_project diff --git a/app/policies/timelog_policy.rb b/app/policies/timelog_policy.rb index 0598817d4e0..f71c4204639 100644 --- a/app/policies/timelog_policy.rb +++ b/app/policies/timelog_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class TimelogPolicy < BasePolicy - delegate { @subject.issuable.resource_parent } + delegate { @subject.issuable } end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 8ef6e2b7962..e62e2fac835 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -25,7 +25,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated reached_max_descendant_pipelines_depth: 'You reached the maximum depth of child pipelines', project_deleted: 'The job belongs to a deleted project', user_blocked: 'The user who created this job is blocked', - ci_quota_exceeded: 'No more CI minutes available' + ci_quota_exceeded: 'No more CI minutes available', + no_matching_runner: 'No matching runner available' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb index 4fa207b1205..59e50b96ab2 100644 --- a/app/presenters/packages/detail/package_presenter.rb +++ b/app/presenters/packages/detail/package_presenter.rb @@ -46,8 +46,8 @@ module Packages size: package_file.size, file_md5: package_file.file_md5, file_sha1: package_file.file_sha1, - file_sha256: package_file.file_sha256 - + file_sha256: package_file.file_sha256, + id: package_file.id } file_view[:pipelines] = build_pipeline_infos(package_file.pipelines) if package_file.pipelines.present? diff --git a/app/presenters/packages/pypi/package_presenter.rb b/app/presenters/packages/pypi/package_presenter.rb index 1cb11c7be1a..7997c1b9b79 100644 --- a/app/presenters/packages/pypi/package_presenter.rb +++ b/app/presenters/packages/pypi/package_presenter.rb @@ -7,9 +7,9 @@ module Packages class PackagePresenter include API::Helpers::RelatedResourcesHelpers - def initialize(packages, project) + def initialize(packages, project_or_group) @packages = packages - @project = project + @project_or_group = project_or_group end # Returns the HTML body for PyPI simple API. @@ -51,16 +51,27 @@ module Packages end def build_pypi_package_path(file) - expose_url( - api_v4_projects_packages_pypi_files_file_identifier_path( - { - id: @project.id, - sha256: file.file_sha256, - file_identifier: file.file_name - }, - true - ) - ) + "#sha256=#{file.file_sha256}" + params = { + id: @project_or_group.id, + sha256: file.file_sha256, + file_identifier: file.file_name + } + + if project? + expose_url( + api_v4_projects_packages_pypi_files_file_identifier_path( + params, true + ) + ) + "#sha256=#{file.file_sha256}" + elsif group? + expose_url( + api_v4_groups___packages_pypi_files_file_identifier_path( + params, true + ) + ) + "#sha256=#{file.file_sha256}" + else + '' + end end def name @@ -70,6 +81,14 @@ module Packages def escape(str) ERB::Util.html_escape(str) end + + def project? + @project_or_group.is_a?(::Project) + end + + def group? + @project_or_group.is_a?(::Group) + end end end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 4f803ba34f4..fcd3189296a 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -264,7 +264,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated 'original_branch' => default_branch_or_main, 'can_push_code' => 'true', 'path' => project_create_blob_path(project, default_branch_or_main), - 'project_path' => project.path + 'project_path' => project.full_path } ) end diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb index aa6429ab012..ac27e997b41 100644 --- a/app/presenters/release_presenter.rb +++ b/app/presenters/release_presenter.rb @@ -26,32 +26,22 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated end def opened_merge_requests_url - return unless release_mr_issue_urls_available? - project_merge_requests_url(project, params_for_issues_and_mrs) end def merged_merge_requests_url - return unless release_mr_issue_urls_available? - project_merge_requests_url(project, params_for_issues_and_mrs(state: 'merged')) end def closed_merge_requests_url - return unless release_mr_issue_urls_available? - project_merge_requests_url(project, params_for_issues_and_mrs(state: 'closed')) end def opened_issues_url - return unless release_mr_issue_urls_available? - project_issues_url(project, params_for_issues_and_mrs) end def closed_issues_url - return unless release_mr_issue_urls_available? - project_issues_url(project, params_for_issues_and_mrs(state: 'closed')) end @@ -89,10 +79,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated { scope: 'all', state: state, release_tag: release.tag } end - def release_mr_issue_urls_available? - ::Feature.enabled?(:release_mr_issue_urls, project, default_enabled: true) - end - def release_edit_page_available? can?(current_user, :update_release, release) end diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index eb38b90fb18..8a96eb83a3f 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -8,9 +8,5 @@ class AnalyticsStageEntity < Grape::Entity expose :legend expose :description - expose :project_median, as: :value do |stage| - # median returns a BatchLoader instance which we first have to unwrap by using to_f - # we use to_f to make sure results below 1 are presented to the end-user - stage.project_median.to_f.nonzero? ? distance_of_time_in_words(stage.project_median) : nil - end + expose :project_median, as: :value end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 6b9a3ce114b..f57ac4af113 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -10,15 +10,11 @@ class ClusterApplicationEntity < Grape::Entity expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } expose :stack, if: -> (e, _) { e.respond_to?(:stack) } - expose :modsecurity_enabled, if: -> (e, _) { e.respond_to?(:modsecurity_enabled) } expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } expose :can_uninstall?, as: :can_uninstall expose :available_domains, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:available_domains) } expose :pages_domain, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:pages_domain) } - expose :modsecurity_mode, if: -> (e, _) { e.respond_to?(:modsecurity_mode) } expose :host, if: -> (e, _) { e.respond_to?(:host) } expose :port, if: -> (e, _) { e.respond_to?(:port) } expose :protocol, if: -> (e, _) { e.respond_to?(:protocol) } - expose :waf_log_enabled, if: -> (e, _) { e.respond_to?(:waf_log_enabled) } - expose :cilium_log_enabled, if: -> (e, _) { e.respond_to?(:cilium_log_enabled) } end diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb index fbcdf91a1af..2be37d23a05 100644 --- a/app/serializers/fork_namespace_entity.rb +++ b/app/serializers/fork_namespace_entity.rb @@ -13,7 +13,7 @@ class ForkNamespaceEntity < Grape::Entity end expose :forked_project_path do |namespace, options| - if forked_project = namespace.find_fork_of(options[:project]) + if forked_project = options.dig(:forked_projects, namespace.id) project_path(forked_project) end end @@ -31,7 +31,11 @@ class ForkNamespaceEntity < Grape::Entity end expose :can_create_project do |namespace, options| - options[:current_user].can?(:create_projects, namespace) + if Feature.enabled?(:fork_project_form, options[:project], default_enabled: :yaml) + true + else + options[:current_user].can?(:create_projects, namespace) + end end private diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index 17a36f5fb07..4b9c48f3f7c 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -17,7 +17,7 @@ class IssueBoardEntity < Grape::Entity end expose :project do |issue| - API::Entities::Project.represent issue.project, only: [:id, :path] + API::Entities::Project.represent issue.project, only: [:id, :path, :path_with_namespace] end expose :milestone, if: -> (issue) { issue.milestone } do |issue| diff --git a/app/serializers/member_serializer.rb b/app/serializers/member_serializer.rb index 462f6be5d04..ad258b0ef1e 100644 --- a/app/serializers/member_serializer.rb +++ b/app/serializers/member_serializer.rb @@ -4,7 +4,7 @@ class MemberSerializer < BaseSerializer entity MemberEntity def represent(members, opts = {}) - Members::LastGroupOwnerAssigner.new(opts[:group], members).execute unless opts[:source].is_a?(Project) + LastGroupOwnerAssigner.new(opts[:group], members).execute unless opts[:source].is_a?(Project) super(members, opts) end diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb index aa0ac7d2a7e..e197c3d0fbb 100644 --- a/app/serializers/merge_request_diff_entity.rb +++ b/app/serializers/merge_request_diff_entity.rb @@ -6,13 +6,13 @@ class MergeRequestDiffEntity < Grape::Entity include MergeRequestsHelper expose :version_index do |merge_request_diff| - @merge_request_diffs = options[:merge_request_diffs] + merge_request_diffs = options[:merge_request_diffs] diff = options[:merge_request_diff] - next unless diff.present? - next unless @merge_request_diffs.size > 1 + next unless merge_request_diffs.include?(diff) + next unless merge_request_diffs.size > 1 - version_index(merge_request_diff) + merge_request_diffs.size - merge_request_diffs.index(merge_request_diff) end expose :created_at diff --git a/app/services/authorized_project_update/periodic_recalculate_service.rb b/app/services/authorized_project_update/periodic_recalculate_service.rb index 662d10c648b..16dc76eb4cf 100644 --- a/app/services/authorized_project_update/periodic_recalculate_service.rb +++ b/app/services/authorized_project_update/periodic_recalculate_service.rb @@ -9,7 +9,12 @@ module AuthorizedProjectUpdate # Using this approach (instead of eg. User.each_batch) keeps the arguments # the same for AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker # even if the user list changes, so we can deduplicate these jobs. - (1..User.maximum(:id)).each_slice(BATCH_SIZE).with_index do |batch, index| + + # Since UserRefreshOverUserRangeWorker has set data_consistency to delayed, + # a job enqueued without a delay could fail because the replica could not catch up with the primary. + # To prevent this, we start the index from `1` instead of `0` so as to ensure that + # no UserRefreshOverUserRangeWorker job is enqueued without a delay. + (1..User.maximum(:id)).each_slice(BATCH_SIZE).with_index(1) do |batch, index| delay = DELAY_INTERVAL * index AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker.perform_in(delay, *batch.minmax) end diff --git a/app/services/authorized_project_update/project_group_link_create_service.rb b/app/services/authorized_project_update/project_group_link_create_service.rb index 090b22a7820..e9e7c56d7c7 100644 --- a/app/services/authorized_project_update/project_group_link_create_service.rb +++ b/app/services/authorized_project_update/project_group_link_create_service.rb @@ -49,7 +49,7 @@ module AuthorizedProjectUpdate def access_level(membership_access_level) return membership_access_level unless group_access - # access level must not be higher than the max access level set when + # access level (role) must not be higher than the max access level (role) set when # creating the project share [membership_access_level, group_access].min end diff --git a/app/services/authorized_project_update/project_recalculate_service.rb b/app/services/authorized_project_update/project_recalculate_service.rb new file mode 100644 index 00000000000..cbb8efaf99f --- /dev/null +++ b/app/services/authorized_project_update/project_recalculate_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectRecalculateService + # Service for refreshing all the authorizations to a particular project. + include Gitlab::Utils::StrongMemoize + BATCH_SIZE = 1000 + + def initialize(project) + @project = project + end + + def execute + refresh_authorizations if needs_refresh? + ServiceResponse.success + end + + private + + attr_reader :project + + def needs_refresh? + user_ids_to_remove.any? || + authorizations_to_create.any? + end + + def current_authorizations + strong_memoize(:current_authorizations) do + project.project_authorizations + .pluck(:user_id, :access_level) # rubocop: disable CodeReuse/ActiveRecord + end + end + + def fresh_authorizations + strong_memoize(:fresh_authorizations) do + result = [] + + Projects::Members::EffectiveAccessLevelFinder.new(project) + .execute + .each_batch(of: BATCH_SIZE, column: :user_id) do |member_batch| + result += member_batch.pluck(:user_id, 'MAX(access_level)') # rubocop: disable CodeReuse/ActiveRecord + end + + result + end + end + + def user_ids_to_remove + strong_memoize(:user_ids_to_remove) do + (current_authorizations - fresh_authorizations) + .map {|user_id, _| user_id } + end + end + + def authorizations_to_create + strong_memoize(:authorizations_to_create) do + (fresh_authorizations - current_authorizations).map do |user_id, access_level| + { + user_id: user_id, + access_level: access_level, + project_id: project.id + } + end + end + end + + def refresh_authorizations + ProjectAuthorization.transaction do + if user_ids_to_remove.any? + ProjectAuthorization.where(project_id: project.id, user_id: user_ids_to_remove) # rubocop: disable CodeReuse/ActiveRecord + .delete_all + end + + if authorizations_to_create.any? + ProjectAuthorization.insert_all(authorizations_to_create) + end + end + end + end +end diff --git a/app/services/authorized_project_update/recalculate_for_user_range_service.rb b/app/services/authorized_project_update/recalculate_for_user_range_service.rb deleted file mode 100644 index f300c45f019..00000000000 --- a/app/services/authorized_project_update/recalculate_for_user_range_service.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module AuthorizedProjectUpdate - class RecalculateForUserRangeService - def initialize(start_user_id, end_user_id) - @start_user_id = start_user_id - @end_user_id = end_user_id - end - - def execute - User.where(id: start_user_id..end_user_id).select(:id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord - Users::RefreshAuthorizedProjectsService.new(user, source: self.class.name).execute - end - end - - private - - attr_reader :start_user_id, :end_user_id - end -end diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb index ee15763ce65..8492b3ce92c 100644 --- a/app/services/base_container_service.rb +++ b/app/services/base_container_service.rb @@ -18,4 +18,12 @@ class BaseContainerService @current_user = current_user @params = params.dup end + + def project_container? + container.is_a?(::Project) + end + + def group_container? + container.is_a?(::Group) + end end diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb index c316c488148..ff1949ce4dd 100644 --- a/app/services/base_count_service.rb +++ b/app/services/base_count_service.rb @@ -27,7 +27,7 @@ class BaseCountService end def delete_cache - Rails.cache.delete(cache_key) + ::Gitlab::Cache.delete(cache_key) end def raw? @@ -49,4 +49,4 @@ class BaseCountService end end -BaseCountService.prepend_mod_with('BaseCountService') +BaseCountService.prepend_mod diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb index 28fb1e43043..dfd0002cbc9 100644 --- a/app/services/boards/base_item_move_service.rb +++ b/app/services/boards/base_item_move_service.rb @@ -23,14 +23,15 @@ module Boards end reposition_ids = move_between_ids(params) - if reposition_ids - attrs[:move_between_ids] = reposition_ids - attrs.merge!(reposition_parent) - end + attrs.merge!(reposition_params(reposition_ids)) if reposition_ids attrs end + def reposition_params(reposition_ids) + reposition_parent.merge(move_between_ids: reposition_ids) + end + def move_single_issuable(issuable, issuable_modification_params) ability_name = :"admin_#{issuable.to_ability_name}" return unless can?(current_user, ability_name, issuable) diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb index cbc7a332cbe..a3e24844587 100644 --- a/app/services/boards/base_items_list_service.rb +++ b/app/services/boards/base_items_list_service.rb @@ -6,9 +6,9 @@ module Boards include ActiveRecord::ConnectionAdapters::Quoting def execute - return items.order_closed_date_desc if list&.closed? + items = init_collection - ordered_items + order(items) end # rubocop: disable CodeReuse/ActiveRecord @@ -17,7 +17,7 @@ module Boards keys = metadata_fields.keys # TODO: eliminate need for SQL literal fragment columns = Arel.sql(metadata_fields.values_at(*keys).join(', ')) - results = item_model.where(id: items.select(issuables[:id])).pluck(columns) + results = item_model.where(id: init_collection.select(issuables[:id])).pluck(columns) Hash[keys.zip(results.flatten)] end @@ -29,7 +29,7 @@ module Boards { size: 'COUNT(*)' } end - def ordered_items + def order(items) raise NotImplementedError end @@ -47,8 +47,8 @@ module Boards # We memoize the query here since the finder methods we use are quite complex. This does not memoize the result of the query. # rubocop: disable CodeReuse/ActiveRecord - def items - strong_memoize(:items) do + def init_collection + strong_memoize(:init_collection) do filter(finder.execute).reorder(nil) end end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 6284e454561..0e95bf7a434 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -11,7 +11,9 @@ module Boards private - def ordered_items + def order(items) + return items.order_closed_date_desc if list&.closed? + items.order_by_position_and_priority(with_cte: params[:search].present?) end diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb index 8684da701db..848e6aaa65a 100644 --- a/app/services/branches/create_service.rb +++ b/app/services/branches/create_service.rb @@ -2,8 +2,8 @@ module Branches class CreateService < BaseService - def execute(branch_name, ref, create_master_if_empty: true) - create_master_branch if create_master_if_empty && project.empty_repo? + def execute(branch_name, ref, create_default_branch_if_empty: true) + create_default_branch if create_default_branch_if_empty && project.empty_repo? result = ::Branches::ValidateNewService.new(project).execute(branch_name) @@ -27,13 +27,13 @@ module Branches private - def create_master_branch + def create_default_branch project.repository.create_file( current_user, '/README.md', '', message: 'Add README.md', - branch_name: 'master' + branch_name: project.default_branch_or_main ) end end diff --git a/app/services/bulk_imports/file_decompression_service.rb b/app/services/bulk_imports/file_decompression_service.rb new file mode 100644 index 00000000000..fe9017377ec --- /dev/null +++ b/app/services/bulk_imports/file_decompression_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module BulkImports + class FileDecompressionService + include Gitlab::ImportExport::CommandLineUtil + + ServiceError = Class.new(StandardError) + + def initialize(dir:, filename:) + @dir = dir + @filename = filename + @filepath = File.join(@dir, @filename) + @decompressed_filename = File.basename(@filename, '.gz') + @decompressed_filepath = File.join(@dir, @decompressed_filename) + end + + def execute + validate_dir + validate_decompressed_file_size if Feature.enabled?(:validate_import_decompressed_archive_size, default_enabled: :yaml) + validate_symlink(filepath) + + decompress_file + + validate_symlink(decompressed_filepath) + + filepath + rescue StandardError => e + File.delete(filepath) if File.exist?(filepath) + File.delete(decompressed_filepath) if File.exist?(decompressed_filepath) + + raise e + end + + private + + attr_reader :dir, :filename, :filepath, :decompressed_filename, :decompressed_filepath + + def validate_dir + raise(ServiceError, 'Invalid target directory') unless dir.start_with?(Dir.tmpdir) + end + + def validate_decompressed_file_size + raise(ServiceError, 'File decompression error') unless size_validator.valid? + end + + def validate_symlink(filepath) + raise(ServiceError, 'Invalid file') if File.lstat(filepath).symlink? + end + + def decompress_file + gunzip(dir: dir, filename: filename) + end + + def size_validator + @size_validator ||= Gitlab::ImportExport::DecompressedArchiveSizeValidator.new(archive_path: filepath) + end + end +end diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb new file mode 100644 index 00000000000..c5a1241e0a4 --- /dev/null +++ b/app/services/bulk_imports/file_download_service.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module BulkImports + class FileDownloadService + FILE_SIZE_LIMIT = 5.gigabytes + ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze + + ServiceError = Class.new(StandardError) + + def initialize(configuration:, relative_url:, dir:, filename:) + @configuration = configuration + @relative_url = relative_url + @filename = filename + @dir = dir + @filepath = File.join(@dir, @filename) + end + + def execute + validate_dir + validate_url + validate_content_type + validate_content_length + + download_file + + validate_symlink + + filepath + end + + private + + attr_reader :configuration, :relative_url, :dir, :filename, :filepath + + def download_file + File.open(filepath, 'wb') do |file| + bytes_downloaded = 0 + + http_client.stream(relative_url) do |chunk| + bytes_downloaded += chunk.size + + raise(ServiceError, 'Invalid downloaded file') if bytes_downloaded > FILE_SIZE_LIMIT + raise(ServiceError, "File download error #{chunk.code}") unless chunk.code == 200 + + file.write(chunk) + end + end + rescue StandardError => e + File.delete(filepath) if File.exist?(filepath) + + raise e + end + + def http_client + @http_client ||= BulkImports::Clients::HTTP.new( + uri: configuration.url, + token: configuration.access_token + ) + end + + def allow_local_requests? + ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end + + def headers + @headers ||= http_client.head(relative_url).headers + end + + def validate_dir + raise(ServiceError, 'Invalid target directory') unless dir.start_with?(Dir.tmpdir) + end + + def validate_symlink + if File.lstat(filepath).symlink? + File.delete(filepath) + + raise(ServiceError, 'Invalid downloaded file') + end + end + + def validate_url + ::Gitlab::UrlBlocker.validate!( + http_client.resource_url(relative_url), + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) + ) + end + + def validate_content_length + content_size = headers['content-length'] + + raise(ServiceError, 'Invalid content length') if content_size.blank? || content_size.to_i > FILE_SIZE_LIMIT + end + + def validate_content_type + content_type = headers['content-type'] + + raise(ServiceError, 'Invalid content type') if content_type.blank? || ALLOWED_CONTENT_TYPES.exclude?(content_type) + end + end +end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index 53952a33b5f..055f9cafd10 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -86,7 +86,7 @@ module BulkImports # rubocop: disable CodeReuse/Serializer def serializer - @serializer ||= ::Gitlab::ImportExport::JSON::StreamingSerializer.new( + @serializer ||= ::Gitlab::ImportExport::Json::StreamingSerializer.new( portable, portable_tree, json_writer, @@ -96,7 +96,7 @@ module BulkImports # rubocop: enable CodeReuse/Serializer def json_writer - @json_writer ||= ::Gitlab::ImportExport::JSON::NdjsonWriter.new(export_path) + @json_writer ||= ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path) end def ndjson_filename diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index 64a99e404c6..1eff76c2e5d 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -19,13 +19,14 @@ module Ci DuplicateDownstreamPipelineError.new, bridge_id: @bridge.id, project_id: @bridge.project_id ) - return + + return error('Already has a downstream pipeline') end pipeline_params = @bridge.downstream_pipeline_params target_ref = pipeline_params.dig(:target_revision, :ref) - return unless ensure_preconditions!(target_ref) + return error('Pre-conditions not met') unless ensure_preconditions!(target_ref) service = ::Ci::CreatePipelineService.new( pipeline_params.fetch(:project), @@ -119,8 +120,11 @@ module Ci return false if @bridge.triggers_child_pipeline? if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml) - checksums = @bridge.pipeline.base_and_ancestors.map { |pipeline| config_checksum(pipeline) } - checksums.uniq.length != checksums.length + pipeline_checksums = @bridge.pipeline.base_and_ancestors.filter_map do |pipeline| + config_checksum(pipeline) unless pipeline.child? + end + + pipeline_checksums.uniq.length != pipeline_checksums.length end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index fd333e24860..c039f31aafc 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -13,6 +13,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Validate::SecurityOrchestrationPolicy, Gitlab::Ci::Pipeline::Chain::Config::Content, Gitlab::Ci::Pipeline::Chain::Config::Process, + Gitlab::Ci::Pipeline::Chain::Validate::AfterConfig, Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Skip, Gitlab::Ci::Pipeline::Chain::SeedBlock, diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index a22ac87f660..9fc7c3b4d40 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -115,7 +115,6 @@ module Ci case artifact.file_type when 'dotenv' then parse_dotenv_artifact(artifact) - when 'cluster_applications' then parse_cluster_applications_artifact(artifact) else success end end @@ -165,10 +164,6 @@ module Ci def parse_dotenv_artifact(artifact) Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact) end - - def parse_cluster_applications_artifact(artifact) - Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact) - end end end end diff --git a/app/services/ci/pipeline_creation/start_pipeline_service.rb b/app/services/ci/pipeline_creation/start_pipeline_service.rb new file mode 100644 index 00000000000..27c12caaa0a --- /dev/null +++ b/app/services/ci/pipeline_creation/start_pipeline_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + module PipelineCreation + class StartPipelineService + attr_reader :pipeline + + def initialize(pipeline) + @pipeline = pipeline + end + + def execute + Ci::ProcessPipelineService.new(pipeline).execute + end + end + end +end + +::Ci::PipelineCreation::StartPipelineService.prepend_mod_with('Ci::PipelineCreation::StartPipelineService') diff --git a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb new file mode 100644 index 00000000000..9978b2d4775 --- /dev/null +++ b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Ci + module PipelineSchedules + class CalculateNextRunService < BaseService + include Gitlab::Utils::StrongMemoize + + def execute(schedule, fallback_method:) + @schedule = schedule + + return fallback_method.call unless ::Feature.enabled?(:ci_daily_limit_for_pipeline_schedules, project, default_enabled: :yaml) + return fallback_method.call unless plan_cron&.cron_valid? + + now = Time.zone.now + + schedule_next_run = schedule_cron.next_time_from(now) + return schedule_next_run if worker_cron.match?(schedule_next_run) && plan_cron.match?(schedule_next_run) + + plan_next_run = plan_cron.next_time_from(now) + return plan_next_run if worker_cron.match?(plan_next_run) + + worker_next_run = worker_cron.next_time_from(now) + return worker_next_run if plan_cron.match?(worker_next_run) + + worker_cron.next_time_from(plan_next_run) + end + + private + + def schedule_cron + strong_memoize(:schedule_cron) do + Gitlab::Ci::CronParser.new(@schedule.cron, @schedule.cron_timezone) + end + end + + def worker_cron + strong_memoize(:worker_cron) do + Gitlab::Ci::CronParser.new(worker_cron_expression, Time.zone.name) + end + end + + def plan_cron + strong_memoize(:plan_cron) do + daily_limit = @schedule.daily_limit + + next unless daily_limit + + every_x_minutes = (1.day.in_minutes / daily_limit).to_i + + Gitlab::Ci::CronParser.parse_natural("every #{every_x_minutes} minutes", Time.zone.name) + end + end + + def worker_cron_expression + Settings.cron_jobs['pipeline_schedule_worker']['cron'] + end + end + end +end diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb index c5b19a3963a..2f0bcece9e3 100644 --- a/app/services/ci/play_bridge_service.rb +++ b/app/services/ci/play_bridge_service.rb @@ -3,7 +3,7 @@ module Ci class PlayBridgeService < ::BaseService def execute(bridge) - raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, bridge) + check_access!(bridge) bridge.tap do |bridge| bridge.user = current_user @@ -14,5 +14,13 @@ module Ci AfterRequeueJobService.new(project, current_user).execute(bridge) end end + + private + + def check_access!(bridge) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, bridge) + end end end + +Ci::PlayBridgeService.prepend_mod_with('Ci::PlayBridgeService') diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index 4953b1ea5fc..073c1a2d0e0 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -3,11 +3,7 @@ module Ci class PlayBuildService < ::BaseService def execute(build, job_variables_attributes = nil) - raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build) - - if job_variables_attributes.present? && !can?(current_user, :set_pipeline_variables, project) - raise Gitlab::Access::AccessDeniedError - end + check_access!(build, job_variables_attributes) # Try to enqueue the build, otherwise create a duplicate. # @@ -23,5 +19,17 @@ module Ci Ci::Build.retry(build, current_user) end end + + private + + def check_access!(build, job_variables_attributes) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build) + + if job_variables_attributes.present? && !can?(current_user, :set_pipeline_variables, project) + raise Gitlab::Access::AccessDeniedError + end + end end end + +Ci::PlayBuildService.prepend_mod_with('Ci::PlayBuildService') diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 461647ffccc..6280bf4c986 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -22,11 +22,27 @@ module Ci end def execute(params = {}) + db_all_caught_up = ::Gitlab::Database::LoadBalancing::Sticking.all_caught_up?(:runner, runner.id) + @metrics.increment_queue_operation(:queue_attempt) - @metrics.observe_queue_time(:process, @runner.runner_type) do + result = @metrics.observe_queue_time(:process, @runner.runner_type) do process_queue(params) end + + # Since we execute this query against replica it might lead to false-positive + # We might receive the positive response: "hi, we don't have any more builds for you". + # This might not be true. If our DB replica is not up-to date with when runner event was generated + # we might still have some CI builds to be picked. Instead we should say to runner: + # "Hi, we don't have any more builds now, but not everything is right anyway, so try again". + # Runner will retry, but again, against replica, and again will check if replication lag did catch-up. + if !db_all_caught_up && !result.build + metrics.increment_queue_operation(:queue_replication_lag) + + ::Ci::RegisterJobService::Result.new(nil, false) # rubocop:disable Cop/AvoidReturnFromBlocks + else + result + end end private @@ -109,25 +125,23 @@ module Ci builds = builds.queued_before(params[:job_age].seconds.ago) end - if Feature.enabled?(:ci_register_job_service_one_by_one, runner, default_enabled: true) - build_ids = retrieve_queue(-> { builds.pluck(:id) }) - - @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type) + build_ids = retrieve_queue(-> { builds.pluck(:id) }) - build_ids.each do |build_id| - yield Ci::Build.find(build_id) - end - else - builds_array = retrieve_queue(-> { builds.to_a }) - - @metrics.observe_queue_size(-> { builds_array.size }, @runner.runner_type) + @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type) - builds_array.each(&blk) + build_ids.each do |build_id| + yield Ci::Build.find(build_id) end end # rubocop: enable CodeReuse/ActiveRecord def retrieve_queue(queue_query_proc) + ## + # We want to reset a load balancing session to discard the side + # effects of writes that could have happened prior to this moment. + # + ::Gitlab::Database::LoadBalancing::Session.clear_session + @metrics.observe_queue_time(:retrieve, @runner.runner_type) do queue_query_proc.call end @@ -182,13 +196,7 @@ module Ci end def max_queue_depth - @max_queue_depth ||= begin - if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: true) - MAX_QUEUE_DEPTH - else - ::Gitlab::Database::MAX_INT_VALUE - end - end + MAX_QUEUE_DEPTH end # Force variables evaluation to occur now @@ -271,15 +279,11 @@ module Ci .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') end end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def builds_for_project_runner new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC') end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def builds_for_group_runner # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL` groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces) @@ -291,17 +295,23 @@ module Ci .without_deleted new_builds.where(project: projects).order('id ASC') end - # rubocop: enable CodeReuse/ActiveRecord - # rubocop: disable CodeReuse/ActiveRecord def running_builds_for_shared_runners Ci::Build.running.where(runner: Ci::Runner.instance_type) .group(:project_id).select(:project_id, 'count(*) AS running_builds') end + + def all_builds + if Feature.enabled?(:ci_pending_builds_queue_join, runner, default_enabled: :yaml) + Ci::Build.joins(:queuing_entry) + else + Ci::Build.all + end + end # rubocop: enable CodeReuse/ActiveRecord def new_builds - builds = Ci::Build.pending.unstarted + builds = all_builds.pending.unstarted builds = builds.ref_protected if runner.ref_protected? builds end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index e03f2ae3d52..ea76771b80a 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -34,15 +34,9 @@ module Ci attributes[:user] = current_user Ci::Build.transaction do - # mark all other builds of that name as retried - build.pipeline.builds.latest - .where(name: build.name) - .update_all(retried: true, processed: true) - - create_build!(attributes).tap do - # mark existing object as retried/processed without a reload - build.retried = true - build.processed = true + create_build!(attributes).tap do |new_build| + new_build.update_older_statuses_retried! + build.reset # refresh the data to get new values of `retried` and `processed`. end end end @@ -59,7 +53,6 @@ module Ci def create_build!(attributes) build = project.builds.new(attributes) build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build)) - build.retried = false BulkInsertableAssociations.with_bulk_insert do build.save! end diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index cf629b879b3..eea09e9ac67 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -2,13 +2,103 @@ module Ci class UpdateBuildQueueService - def execute(build, metrics = ::Gitlab::Ci::Queue::Metrics) - tick_for(build, build.project.all_runners, metrics) + InvalidQueueTransition = Class.new(StandardError) + + attr_reader :metrics + + def initialize(metrics = ::Gitlab::Ci::Queue::Metrics) + @metrics = metrics + end + + ## + # Add a build to the pending builds queue + # + def push(build, transition) + return unless maintain_pending_builds_queue?(build) + + raise InvalidQueueTransition unless transition.to == 'pending' + + transition.within_transaction do + result = build.create_queuing_entry! + + unless result.empty? + metrics.increment_queue_operation(:build_queue_push) + + result.rows.dig(0, 0) + end + end + end + + ## + # Remove a build from the pending builds queue + # + def pop(build, transition) + return unless maintain_pending_builds_queue?(build) + + raise InvalidQueueTransition unless transition.from == 'pending' + + transition.within_transaction do + removed = build.all_queuing_entries.delete_all + + if removed > 0 + metrics.increment_queue_operation(:build_queue_pop) + + build.id + end + end + end + + ## + # Add shared runner build tracking entry (used for queuing). + # + def track(build, transition) + return unless Feature.enabled?(:ci_track_shared_runner_builds, build.project, default_enabled: :yaml) + return unless build.shared_runner_build? + + raise InvalidQueueTransition unless transition.to == 'running' + + transition.within_transaction do + result = ::Ci::RunningBuild.upsert_shared_runner_build!(build) + + unless result.empty? + metrics.increment_queue_operation(:shared_runner_build_new) + + result.rows.dig(0, 0) + end + end + end + + ## + # Remove a runtime build tracking entry for a shared runner build (used for + # queuing). + # + def untrack(build, transition) + return unless Feature.enabled?(:ci_untrack_shared_runner_builds, build.project, default_enabled: :yaml) + return unless build.shared_runner_build? + + raise InvalidQueueTransition unless transition.from == 'running' + + transition.within_transaction do + removed = build.all_runtime_metadata.delete_all + + if removed > 0 + metrics.increment_queue_operation(:shared_runner_build_done) + + build.id + end + end + end + + ## + # Unblock runner associated with given project / build + # + def tick(build) + tick_for(build, build.project.all_available_runners) end private - def tick_for(build, runners, metrics) + def tick_for(build, runners) runners = runners.with_recent_runner_queue runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml) @@ -20,5 +110,9 @@ module Ci runner.pick_build!(build) end end + + def maintain_pending_builds_queue?(build) + Feature.enabled?(:ci_pending_builds_queue_maintain, build.project, default_enabled: :yaml) + end end end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index 874f4bf459a..abd50d2f110 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -19,8 +19,6 @@ module Ci end def execute - overwrite_trace! if has_trace? - unless accept_available? return update_build_state! end @@ -34,12 +32,6 @@ module Ci private - def overwrite_trace! - metrics.increment_trace_operation(operation: :overwrite) - - build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite? - end - def ensure_pending_state! pending_state.created_at end @@ -151,10 +143,6 @@ module Ci params.dig(:state).to_s end - def has_trace? - params.dig(:trace).present? - end - def has_checksum? trace_checksum.present? end @@ -181,7 +169,7 @@ module Ci state: params.fetch(:state), trace_checksum: trace_checksum, trace_bytesize: trace_bytesize, - failure_reason: params.dig(:failure_reason) + failure_reason: failure_reason ) unless build_state.present? @@ -191,6 +179,14 @@ module Ci build_state || build.pending_state end + def failure_reason + reason = params.dig(:failure_reason) + + return unless reason + + Ci::BuildPendingState.failure_reasons.fetch(reason.to_s, 'unknown_failure') + end + ## # This method is releasing an exclusive lock on a build trace the moment we # conclude that build status has been written and the build state update diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index 39a2d6bf758..c6f22cfa04c 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -5,8 +5,6 @@ module Clusters class BaseService InvalidApplicationError = Class.new(StandardError) - FLUENTD_KNOWN_ATTRS = %i[host protocol port waf_log_enabled cilium_log_enabled].freeze - attr_reader :cluster, :current_user, :params def initialize(cluster, user, params = {}) @@ -29,16 +27,6 @@ module Clusters application.stack = params[:stack] end - if application.has_attribute?(:modsecurity_enabled) - application.modsecurity_enabled = params[:modsecurity_enabled] || false - end - - if application.has_attribute?(:modsecurity_mode) - application.modsecurity_mode = params[:modsecurity_mode] || 0 - end - - apply_fluentd_related_attributes(application) - if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) end @@ -103,12 +91,6 @@ module Clusters ::Applications::CreateService.new(current_user, oauth_application_params).execute(request) end - - def apply_fluentd_related_attributes(application) - FLUENTD_KNOWN_ATTRS.each do |attr| - application[attr] = params[attr] if application.has_attribute?(attr) - end - end end end end diff --git a/app/services/clusters/applications/schedule_update_service.rb b/app/services/clusters/applications/schedule_update_service.rb index 4f130f76b87..4fabf1d809e 100644 --- a/app/services/clusters/applications/schedule_update_service.rb +++ b/app/services/clusters/applications/schedule_update_service.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# DEPRECATED: To be removed as part of https://gitlab.com/groups/gitlab-org/-/epics/5877 module Clusters module Applications class ScheduleUpdateService @@ -7,14 +8,14 @@ module Clusters attr_accessor :application, :project - def initialize(application, project) - @application = application + def initialize(cluster_prometheus_adapter, project) + @application = cluster_prometheus_adapter&.cluster&.application_prometheus @project = project end def execute return unless application - return unless application.managed_prometheus? + return if application.externally_installed? if recently_scheduled? worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.current) diff --git a/app/services/clusters/cleanup/app_service.rb b/app/services/clusters/cleanup/app_service.rb deleted file mode 100644 index a7e29c78ea0..00000000000 --- a/app/services/clusters/cleanup/app_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Cleanup - class AppService < Clusters::Cleanup::BaseService - def execute - persisted_applications = @cluster.persisted_applications - - persisted_applications.each do |app| - next unless app.available? - next unless app.can_uninstall? - - log_event(:uninstalling_app, application: app.class.application_name) - uninstall_app_async(app) - end - - # Keep calling the worker untill all dependencies are uninstalled - return schedule_next_execution(Clusters::Cleanup::AppWorker) if persisted_applications.any? - - log_event(:schedule_remove_project_namespaces) - cluster.continue_cleanup! - end - - private - - def uninstall_app_async(application) - application.make_scheduled! - - Clusters::Applications::UninstallWorker.perform_async(application.name, application.id) - end - end - end -end diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb index 7621be565ff..16254041306 100644 --- a/app/services/clusters/cleanup/project_namespace_service.rb +++ b/app/services/clusters/cleanup/project_namespace_service.rb @@ -2,7 +2,7 @@ module Clusters module Cleanup - class ProjectNamespaceService < BaseService + class ProjectNamespaceService < ::Clusters::Cleanup::BaseService KUBERNETES_NAMESPACE_BATCH_SIZE = 100 def execute diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb index d60bd76d388..baac9e4a9e7 100644 --- a/app/services/clusters/cleanup/service_account_service.rb +++ b/app/services/clusters/cleanup/service_account_service.rb @@ -2,7 +2,7 @@ module Clusters module Cleanup - class ServiceAccountService < BaseService + class ServiceAccountService < ::Clusters::Cleanup::BaseService def execute delete_gitlab_service_account diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 0aff1bcc8b9..73d6fc4dc8f 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -43,8 +43,6 @@ module Clusters cluster.build_platform_kubernetes( api_url: 'https://' + gke_cluster.endpoint, ca_cert: Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate), - username: gke_cluster.master_auth.username, - password: gke_cluster.master_auth.password, authorization_type: authorization_type, token: request_kubernetes_token) end @@ -75,18 +73,16 @@ module Clusters def kube_client @kube_client ||= build_kube_client!( 'https://' + gke_cluster.endpoint, - Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate), - gke_cluster.master_auth.username, - gke_cluster.master_auth.password + Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate) ) end - def build_kube_client!(api_url, ca_pem, username, password) - raise "Incomplete settings" unless api_url && username && password + def build_kube_client!(api_url, ca_pem) + raise "Incomplete settings" unless api_url Gitlab::Kubernetes::KubeClient.new( api_url, - auth_options: { username: username, password: password }, + auth_options: { bearer_token: provider.access_token }, ssl_options: kubeclient_ssl_options(ca_pem), http_proxy_uri: ENV['http_proxy'] ) diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb deleted file mode 100644 index b9b2953b6bd..00000000000 --- a/app/services/clusters/parse_cluster_applications_artifact_service.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -module Clusters - class ParseClusterApplicationsArtifactService < ::BaseService - include Gitlab::Utils::StrongMemoize - - MAX_ACCEPTABLE_ARTIFACT_SIZE = 5.kilobytes - RELEASE_NAMES = %w[cilium].freeze - - def initialize(job, current_user) - @job = job - - super(job.project, current_user) - end - - def execute(artifact) - raise ArgumentError, 'Artifact is not cluster_applications file type' unless artifact&.cluster_applications? - - return error(too_big_error_message, :bad_request) unless artifact.file.size < MAX_ACCEPTABLE_ARTIFACT_SIZE - return error(no_deployment_message, :bad_request) unless job.deployment - return error(no_deployment_cluster_message, :bad_request) unless cluster - - parse!(artifact) - - success - rescue Gitlab::Kubernetes::Helm::Parsers::ListV2::ParserError, ActiveRecord::RecordInvalid => error - Gitlab::ErrorTracking.track_exception(error, job_id: artifact.job_id) - error(error.message, :bad_request) - end - - private - - attr_reader :job - - def cluster - strong_memoize(:cluster) do - deployment_cluster = job.deployment&.cluster - - deployment_cluster if Ability.allowed?(current_user, :admin_cluster, deployment_cluster) - end - end - - def parse!(artifact) - releases = [] - - artifact.each_blob do |blob| - next if blob.empty? - - releases.concat(Gitlab::Kubernetes::Helm::Parsers::ListV2.new(blob).releases) - end - - update_cluster_application_statuses!(releases) - end - - def update_cluster_application_statuses!(releases) - release_by_name = releases.index_by { |release| release['Name'] } - - Clusters::Cluster.transaction do - RELEASE_NAMES.each do |release_name| - application_class = Clusters::Cluster::APPLICATIONS[release_name] - application = cluster.find_or_build_application(application_class) - - release = release_by_name[release_name] - - if release - case release['Status'] - when 'DEPLOYED' - application.make_externally_installed! - when 'FAILED' - application.make_errored!(s_('ClusterIntegration|Helm release failed to install')) - end - else - # missing, so by definition, we consider this uninstalled - application.make_externally_uninstalled! if application.persisted? - end - end - end - end - - def too_big_error_message - human_size = ActiveSupport::NumberHelper.number_to_human_size(MAX_ACCEPTABLE_ARTIFACT_SIZE) - - s_('ClusterIntegration|Cluster_applications artifact too big. Maximum allowable size: %{human_size}') % { human_size: human_size } - end - - def no_deployment_message - s_('ClusterIntegration|No deployment found for this job') - end - - def no_deployment_cluster_message - s_('ClusterIntegration|No deployment cluster found for this job') - end - end -end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index edb9f04ccd7..dc7f84ab807 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -6,6 +6,7 @@ module Commits super @commit = params[:commit] + @message = params[:message] end private @@ -14,7 +15,9 @@ module Commits raise NotImplementedError unless repository.respond_to?(action) # rubocop:disable GitlabSecurity/PublicSend - message = @commit.public_send(:"#{action}_message", current_user) + message = + @message || @commit.public_send(:"#{action}_message", current_user) + repository.public_send( action, current_user, diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb index 38a3fc231c6..cd988cdc5fe 100644 --- a/app/services/container_expiration_policies/cleanup_service.rb +++ b/app/services/container_expiration_policies/cleanup_service.rb @@ -49,7 +49,6 @@ module ContainerExpirationPolicies private def schedule_next_run_if_needed - return unless Feature.enabled?(:container_registry_expiration_policies_loopless) return if policy.next_run_at.future? repos_before_next_run = ::ContainerRepository.for_project_id(policy.project_id) diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb index 9e862d6fa52..6f85779c285 100644 --- a/app/services/deployments/update_environment_service.rb +++ b/app/services/deployments/update_environment_service.rb @@ -9,6 +9,8 @@ module Deployments delegate :variables, to: :deployable delegate :options, to: :deployable, allow_nil: true + EnvironmentUpdateFailure = Class.new(StandardError) + def initialize(deployment) @deployment = deployment @deployable = deployment.deployable @@ -31,8 +33,18 @@ module Deployments renew_deployment_tier environment.fire_state_event(action) - if environment.save && !environment.stopped? - deployment.update_merge_request_metrics! + if environment.save + deployment.update_merge_request_metrics! unless environment.stopped? + else + # If there is a validation error on environment update, such as + # the external URL is malformed, the error message is recorded for debugging purpose. + # We should surface the error message to users for letting them to take an action. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/21182. + Gitlab::ErrorTracking.track_exception( + EnvironmentUpdateFailure.new, + project_id: deployment.project_id, + environment_id: environment.id, + reason: environment.errors.full_messages.to_sentence) end end end diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb index 496103f9e58..b40f6a81174 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -86,7 +86,7 @@ module DesignManagement def with_temporary_branch(&block) target_repository.create_if_not_exists - create_master_branch! if target_repository.empty? + create_default_branch! if target_repository.empty? create_temporary_branch! yield @@ -95,9 +95,9 @@ module DesignManagement end # A project that does not have any designs will have a blank design - # repository. To create a temporary branch from `master` we need - # create `master` first by adding a file to it. - def create_master_branch! + # repository. To create a temporary branch from default branch we need to + # create default branch first by adding a file to it. + def create_default_branch! target_repository.create_file( git_user, ".CopyDesignCollectionService_#{Time.now.to_i}", @@ -121,7 +121,7 @@ module DesignManagement target_repository.rm_branch(git_user, temporary_branch) end - # Merge the temporary branch containing the commits to `master` + # Merge the temporary branch containing the commits to default branch # and update the state of the target_design_collection. def finalize! source_sha = shas.last diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb index 5aa2a2f73bc..f337a9dc8e0 100644 --- a/app/services/design_management/design_service.rb +++ b/app/services/design_management/design_service.rb @@ -13,7 +13,7 @@ module DesignManagement attr_reader :issue def target_branch - repository.root_ref || "master" + repository.root_ref || Gitlab::DefaultBranch.value(object: project) end def collection diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 3b733023eae..baf14aa8a03 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -47,9 +47,16 @@ module Discussions MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request) end + resolve_user_todos_for(discussion) SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue end + def resolve_user_todos_for(discussion) + return unless discussion.for_design? + + TodoService.new.resolve_todos_for_target(discussion, current_user) + end + def first_discussion @first_discussion ||= discussions.first end diff --git a/app/services/feature_flags/disable_service.rb b/app/services/feature_flags/disable_service.rb deleted file mode 100644 index 8a443ac1795..00000000000 --- a/app/services/feature_flags/disable_service.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module FeatureFlags - class DisableService < BaseService - def execute - return error('Feature Flag not found', 404) unless feature_flag_by_name - return error('Feature Flag Scope not found', 404) unless feature_flag_scope_by_environment_scope - return error('Strategy not found', 404) unless strategy_exist_in_persisted_data? - - ::FeatureFlags::UpdateService - .new(project, current_user, update_params) - .execute(feature_flag_by_name) - end - - private - - def update_params - if remaining_strategies.empty? - params_to_destroy_scope - else - params_to_update_scope - end - end - - def remaining_strategies - strong_memoize(:remaining_strategies) do - feature_flag_scope_by_environment_scope.strategies.reject do |strategy| - strategy['name'] == params[:strategy]['name'] && - strategy['parameters'] == params[:strategy]['parameters'] - end - end - end - - def strategy_exist_in_persisted_data? - feature_flag_scope_by_environment_scope.strategies != remaining_strategies - end - - def params_to_destroy_scope - { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, _destroy: true }] } - end - - def params_to_update_scope - { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, strategies: remaining_strategies }] } - end - end -end diff --git a/app/services/feature_flags/enable_service.rb b/app/services/feature_flags/enable_service.rb deleted file mode 100644 index b4cbb32e003..00000000000 --- a/app/services/feature_flags/enable_service.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module FeatureFlags - class EnableService < BaseService - def execute - if feature_flag_by_name - update_feature_flag - else - create_feature_flag - end - end - - private - - def create_feature_flag - ::FeatureFlags::CreateService - .new(project, current_user, create_params) - .execute - end - - def update_feature_flag - ::FeatureFlags::UpdateService - .new(project, current_user, update_params) - .execute(feature_flag_by_name) - end - - def create_params - if params[:environment_scope] == '*' - params_to_create_flag_with_default_scope - else - params_to_create_flag_with_additional_scope - end - end - - def update_params - if feature_flag_scope_by_environment_scope - params_to_update_scope - else - params_to_create_scope - end - end - - def params_to_create_flag_with_default_scope - { - name: params[:name], - scopes_attributes: [ - { - active: true, - environment_scope: '*', - strategies: [params[:strategy]] - } - ] - } - end - - def params_to_create_flag_with_additional_scope - { - name: params[:name], - scopes_attributes: [ - { - active: false, - environment_scope: '*' - }, - { - active: true, - environment_scope: params[:environment_scope], - strategies: [params[:strategy]] - } - ] - } - end - - def params_to_create_scope - { - scopes_attributes: [{ - active: true, - environment_scope: params[:environment_scope], - strategies: [params[:strategy]] - }] - } - end - - def params_to_update_scope - { - scopes_attributes: [{ - id: feature_flag_scope_by_environment_scope.id, - active: true, - strategies: feature_flag_scope_by_environment_scope.strategies | [params[:strategy]] - }] - } - end - end -end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 8e8efe7d555..f900927793a 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -28,7 +28,7 @@ module Groups @group.name ||= @group.path.dup if create_chat_team? - response = Mattermost::CreateTeamService.new(@group, current_user).execute + response = ::Mattermost::CreateTeamService.new(@group, current_user).execute return @group if @group.errors.any? @group.build_chat_team(name: response['name'], team_id: response['id']) diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb index 0a60140d037..5f81e5972b0 100644 --- a/app/services/groups/group_links/create_service.rb +++ b/app/services/groups/group_links/create_service.rb @@ -3,27 +3,51 @@ module Groups module GroupLinks class CreateService < Groups::BaseService - def execute(shared_group) - unless group && shared_group && + def initialize(shared_group, shared_with_group, user, params) + @shared_group = shared_group + super(shared_with_group, user, params) + end + + def execute + unless shared_with_group && shared_group && can?(current_user, :admin_group_member, shared_group) && - can?(current_user, :read_group, group) + can?(current_user, :read_group, shared_with_group) && + sharing_allowed? return error('Not Found', 404) end link = GroupGroupLink.new( shared_group: shared_group, - shared_with_group: group, + shared_with_group: shared_with_group, group_access: params[:shared_group_access], expires_at: params[:expires_at] ) if link.save - group.refresh_members_authorized_projects(direct_members_only: true) + shared_with_group.refresh_members_authorized_projects(direct_members_only: true) success(link: link) else error(link.errors.full_messages.to_sentence, 409) end end + + private + + attr_reader :shared_group + + alias_method :shared_with_group, :group + + def sharing_allowed? + sharing_outside_hierarchy_allowed? || within_hierarchy? + end + + def sharing_outside_hierarchy_allowed? + !shared_group.root_ancestor.namespace_settings.prevent_sharing_groups_outside_hierarchy + end + + def within_hierarchy? + shared_group.root_ancestor.self_and_descendants_ids.include?(shared_with_group.id) + end end end end diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb index 0844c98dd6a..1de2b3c5a2e 100644 --- a/app/services/groups/participants_service.rb +++ b/app/services/groups/participants_service.rb @@ -23,9 +23,9 @@ module Groups end def group_members - return [] unless noteable + return [] unless group - @group_members ||= sorted(noteable.group.direct_and_indirect_users) + @group_members ||= sorted(group.direct_and_indirect_users) end end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 56ff1310def..518d061c654 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -108,10 +108,13 @@ module Groups @group.parent = @new_parent_group @group.clear_memoization(:self_and_ancestors_ids) + @group.clear_memoization(:root_ancestor) if different_root_ancestor? inherit_group_shared_runners_settings @group.save! + # #reload is called to make sure traversal_ids are reloaded + @group.reload # rubocop:disable Cop/ActiveRecordAssociationReload end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb index 66ac7dac4ca..567ac065cf7 100644 --- a/app/services/import_export_clean_up_service.rb +++ b/app/services/import_export_clean_up_service.rb @@ -2,6 +2,7 @@ class ImportExportCleanUpService LAST_MODIFIED_TIME_IN_MINUTES = 1440 + DIR_DEPTH = 5 attr_reader :mmin, :path @@ -27,15 +28,42 @@ class ImportExportCleanUpService end def clean_up_export_files - Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) + old_directories do |dir| + FileUtils.remove_entry(dir) + + logger.info( + message: 'Removed Import/Export tmp directory', + dir_path: dir + ) + end end - # rubocop: disable CodeReuse/ActiveRecord def clean_up_export_object_files - ImportExportUpload.where('updated_at < ?', mmin.minutes.ago).each do |upload| + ImportExportUpload.with_export_file.updated_before(mmin.minutes.ago).each do |upload| upload.remove_export_file! upload.save! + + logger.info( + message: 'Removed Import/Export export_file', + project_id: upload.project_id, + group_id: upload.group_id + ) + end + end + + def old_directories + IO.popen(directories_cmd) do |find| + find.each_line(chomp: true) do |directory| + yield directory + end end end - # rubocop: enable CodeReuse/ActiveRecord + + def directories_cmd + %W(find #{path} -mindepth #{DIR_DEPTH} -maxdepth #{DIR_DEPTH} -type d -not -path #{path} -mmin +#{mmin}) + end + + def logger + @logger ||= Gitlab::Import::Logger.build + end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 099e0d81bc9..02c1f078a40 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -27,8 +27,14 @@ class IssuableBaseService < ::BaseProjectService can?(current_user, ability_name, issuable) end + def can_set_issuable_metadata?(issuable) + ability_name = :"set_#{issuable.to_ability_name}_metadata" + + can?(current_user, ability_name, issuable) + end + def filter_params(issuable) - unless can_admin_issuable?(issuable) + unless can_set_issuable_metadata?(issuable) params.delete(:milestone) params.delete(:milestone_id) params.delete(:labels) @@ -45,6 +51,7 @@ class IssuableBaseService < ::BaseProjectService params.delete(:canonical_issue_id) params.delete(:project) params.delete(:discussion_locked) + params.delete(:confidential) end filter_assignees(issuable) @@ -184,7 +191,7 @@ class IssuableBaseService < ::BaseProjectService params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: issuable.assignee_ids.to_a) end - issuable.assign_attributes(params) + issuable.assign_attributes(allowed_create_params(params)) before_create(issuable) @@ -194,6 +201,7 @@ class IssuableBaseService < ::BaseProjectService if issuable_saved create_system_notes(issuable, is_update: false) unless skip_system_notes + handle_changes(issuable, { params: params }) after_create(issuable) execute_hooks(issuable) @@ -233,7 +241,7 @@ class IssuableBaseService < ::BaseProjectService assign_requested_assignees(issuable) if issuable.changed? || params.present? - issuable.assign_attributes(params) + issuable.assign_attributes(allowed_update_params(params)) if has_title_or_description_changed?(issuable) issuable.assign_attributes(last_edited_at: Time.current, last_edited_by: current_user) @@ -260,7 +268,7 @@ class IssuableBaseService < ::BaseProjectService issuable, old_labels: old_associations[:labels], old_milestone: old_associations[:milestone] ) - handle_changes(issuable, old_associations: old_associations) + handle_changes(issuable, old_associations: old_associations, params: params) new_assignees = issuable.assignees.to_a affected_assignees = (old_associations[:assignees] + new_assignees) - (old_associations[:assignees] & new_assignees) @@ -432,6 +440,7 @@ class IssuableBaseService < ::BaseProjectService milestone: issuable.try(:milestone) } associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent) + associations[:time_change] = issuable.time_change if issuable.respond_to?(:time_change) associations[:description] = issuable.description associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers? @@ -505,6 +514,14 @@ class IssuableBaseService < ::BaseProjectService def update_timestamp?(issuable) issuable.changes.keys != ["relative_position"] end + + def allowed_create_params(params) + params + end + + def allowed_update_params(params) + params + end end IssuableBaseService.prepend_mod_with('IssuableBaseService') diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb index 6a8d45b92b2..142d287370f 100644 --- a/app/services/issue_rebalancing_service.rb +++ b/app/services/issue_rebalancing_service.rb @@ -15,14 +15,13 @@ class IssueRebalancingService [5.seconds, 1.second] ].freeze - def initialize(issue) - @issue = issue - @base = Issue.relative_positioning_query_base(issue) + def initialize(projects_collection) + @root_namespace = projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord + @base = Issue.in_projects(projects_collection) end def execute - gates = [issue.project, issue.project.group].compact - return unless gates.any? { |gate| Feature.enabled?(:rebalance_issues, gate) } + return unless Feature.enabled?(:rebalance_issues, root_namespace) raise TooManyIssues, "#{issue_count} issues" if issue_count > MAX_ISSUE_COUNT @@ -57,7 +56,7 @@ class IssueRebalancingService private - attr_reader :issue, :base + attr_reader :root_namespace, :base # rubocop: disable CodeReuse/ActiveRecord def indexed_ids diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 72e906e20f1..1c50bb74176 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -29,7 +29,7 @@ module Issues gates = [issue.project, issue.project.group].compact return unless gates.any? { |gate| Feature.enabled?(:rebalance_issues, gate) } - IssueRebalancingWorker.perform_async(nil, issue.project_id) + IssueRebalancingWorker.perform_async(nil, *issue.project.self_or_root_group_ids) end private @@ -38,6 +38,7 @@ module Issues super params.delete(:issue_type) unless issue_type_allowed?(issue) + filter_incident_label(issue) if params[:issue_type] moved_issue = params.delete(:moved_issue) @@ -82,6 +83,37 @@ module Issues def issue_type_allowed?(object) can?(current_user, :"create_#{params[:issue_type]}", object) end + + # @param issue [Issue] + def filter_incident_label(issue) + return unless add_incident_label?(issue) || remove_incident_label?(issue) + + label = ::IncidentManagement::CreateIncidentLabelService + .new(project, current_user) + .execute + .payload[:label] + + # These(add_label_ids, remove_label_ids) are being added ahead of time + # to be consumed by #process_label_ids, this allows system notes + # to be applied correctly alongside the label updates. + if add_incident_label?(issue) + params[:add_label_ids] ||= [] + params[:add_label_ids] << label.id + else + params[:remove_label_ids] ||= [] + params[:remove_label_ids] << label.id + end + end + + # @param issue [Issue] + def add_incident_label?(issue) + issue.incident? + end + + # @param _issue [Issue, nil] + def remove_incident_label?(_issue) + false + end end end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 1700d1d8586..cc4ad1a9c85 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -25,7 +25,6 @@ module Issues end if project.issues_enabled? && issue.close(current_user) - remove_on_close_labels_from(issue) event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note @@ -52,18 +51,6 @@ module Issues private - def remove_on_close_labels_from(issue) - old_labels = issue.labels.to_a - - issue.label_links.with_remove_on_close_labels.delete_all - issue.labels.reset - - Issuable::CommonSystemNotesService.new(project: project, current_user: current_user).execute( - issue, - old_labels: old_labels - ) - end - def close_external_issue(issue, closed_via) return unless project.external_issue_tracker&.support_close_issue? diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 1f4efeb1a8a..53f3dc39ba3 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -34,13 +34,26 @@ module Issues # Add new items to Issues::AfterCreateService if they can be performed in Sidekiq def after_create(issue) - add_incident_label(issue) user_agent_detail_service.create resolve_discussions_with_issue(issue) super end + def handle_changes(issue, options) + super + old_associations = options.fetch(:old_associations, {}) + old_assignees = old_associations.fetch(:assignees, []) + + handle_assignee_changes(issue, old_assignees) + end + + def handle_assignee_changes(issue, old_assignees) + return if issue.assignees == old_assignees + + create_assignee_note(issue, old_assignees) + end + def resolve_discussions_with_issue(issue) return if discussions_to_resolve.empty? @@ -56,22 +69,6 @@ module Issues def user_agent_detail_service UserAgentDetailService.new(@issue, request) end - - # Applies label "incident" (creates it if missing) to incident issues. - # For use in "after" hooks only to ensure we are not appyling - # labels prematurely. - def add_incident_label(issue) - return unless issue.incident? - - label = ::IncidentManagement::CreateIncidentLabelService - .new(project, current_user) - .execute - .payload[:label] - - return if issue.label_ids.include?(label.id) - - issue.labels << label - end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index af5029f8364..cf2892bf413 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -20,17 +20,6 @@ module Issues super end - override :filter_params - def filter_params(issue) - super - - # filter confidential in `Issues::UpdateService` and not in `IssuableBaseService#filter_params` - # because we do allow users that cannot admin issues to set confidential flag when creating an issue - unless can_admin_issuable?(issue) - params.delete(:confidential) - end - end - def before_update(issue, skip_spam_check: false) return if skip_spam_check @@ -43,6 +32,7 @@ module Issues end def handle_changes(issue, options) + super old_associations = options.fetch(:old_associations, {}) old_labels = old_associations.fetch(:labels, []) old_mentioned_users = old_associations.fetch(:mentioned_users, []) @@ -204,6 +194,16 @@ module Issues def create_confidentiality_note(issue) SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user) end + + override :add_incident_label? + def add_incident_label?(issue) + issue.issue_type != params[:issue_type] && !issue.incident? + end + + override :remove_incident_label? + def remove_incident_label?(issue) + issue.issue_type != params[:issue_type] && issue.incident? + end end end diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index ef48134dec4..1ce459aa7e6 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -47,11 +47,11 @@ module Issues attr_reader :issue def track_meeting_added_event - ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id) + ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id, user: current_user, project: @project, namespace: @project.namespace) end def track_meeting_removed_event - ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) + ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id, user: current_user, project: @project, namespace: @project.namespace) end def add_zoom_meeting(link) diff --git a/app/services/jira_import/users_importer.rb b/app/services/jira_import/users_importer.rb index 3de165c1014..5b2f91efc38 100644 --- a/app/services/jira_import/users_importer.rb +++ b/app/services/jira_import/users_importer.rb @@ -31,21 +31,10 @@ module JiraImport @users_mapper_service ||= user_mapper_service_factory end - def deployment_type - # TODO: use project.jira_service.deployment_type value when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged - @deployment_type ||= client.ServerInfo.all.deploymentType - end - - def client - @client ||= project.jira_service.client - end - def user_mapper_service_factory - # TODO: use deployment_type enum from jira service when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged - case deployment_type.upcase - when JiraService::DEPLOYMENT_TYPES[:server] + if project.jira_service.data_fields.deployment_server? ServerUsersMapperService.new(user, project, start_at) - when JiraService::DEPLOYMENT_TYPES[:cloud] + elsif project.jira_service.data_fields.deployment_cloud? CloudUsersMapperService.new(user, project, start_at) else raise ArgumentError diff --git a/app/services/mattermost/create_team_service.rb b/app/services/mattermost/create_team_service.rb index 2cbcaaad5e1..9f6efab1e43 100644 --- a/app/services/mattermost/create_team_service.rb +++ b/app/services/mattermost/create_team_service.rb @@ -9,8 +9,8 @@ module Mattermost def execute # The user that creates the team will be Team Admin - Mattermost::Team.new(current_user).create(@group.mattermost_team_params) - rescue Mattermost::ClientError => e + ::Mattermost::Team.new(current_user).create(@group.mattermost_team_params) + rescue ::Mattermost::ClientError => e @group.errors.add(:mattermost_team, e.message) end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 7b81cc27635..5d3c4a5c54a 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -16,6 +16,7 @@ module Members end def execute + validate_invite_source! validate_invites! add_members @@ -33,6 +34,10 @@ module Members params[:user_ids] end + def validate_invite_source! + raise ArgumentError, s_('AddMember|No invite source provided.') unless invite_source.present? + end + def validate_invites! raise BlankInvitesError, blank_invites_message if invites.blank? @@ -72,6 +77,23 @@ module Members errors << "#{prefix}#{member.errors.full_messages.to_sentence}" end + def after_execute(member:) + super + + Gitlab::Tracking.event(self.class.name, 'create_member', label: invite_source, property: tracking_property(member), user: current_user) + end + + def invite_source + params[:invite_source] + end + + def tracking_property(member) + # ideally invites go down the invite service class instead, but there is nothing that limits an invite + # from being used in this class and if you send emails as a comma separated list to the api/members + # endpoint, it will support invites + member.invite? ? 'net_new_user' : 'existing_user' + end + def user_limit limit = params.fetch(:limit, DEFAULT_INVITE_LIMIT) diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index e94274aff9d..7ebdbf94932 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -24,9 +24,42 @@ module MergeRequests merge_request.project.execute_hooks(merge_data, :merge_request_hooks) merge_request.project.execute_services(merge_data, :merge_request_hooks) + execute_external_hooks(merge_request, merge_data) + enqueue_jira_connect_messages_for(merge_request) end + def execute_external_hooks(merge_request, merge_data) + # Implemented in EE + end + + def handle_changes(merge_request, options) + old_associations = options.fetch(:old_associations, {}) + old_assignees = old_associations.fetch(:assignees, []) + old_reviewers = old_associations.fetch(:reviewers, []) + + handle_assignees_change(merge_request, old_assignees) if merge_request.assignees != old_assignees + handle_reviewers_change(merge_request, old_reviewers) if merge_request.reviewers != old_reviewers + end + + def handle_assignees_change(merge_request, old_assignees) + MergeRequests::HandleAssigneesChangeService + .new(project: project, current_user: current_user) + .async_execute(merge_request, old_assignees) + end + + def handle_reviewers_change(merge_request, old_reviewers) + affected_reviewers = (old_reviewers + merge_request.reviewers) - (old_reviewers & merge_request.reviewers) + create_reviewer_note(merge_request, old_reviewers) + notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers) + todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers) + invalidate_cache_counts(merge_request, users: affected_reviewers.compact) + + new_reviewers = merge_request.reviewers - old_reviewers + merge_request_activity_counter.track_users_review_requested(users: new_reviewers) + merge_request_activity_counter.track_reviewers_changed_action(user: current_user) + end + def cleanup_environments(merge_request) Ci::StopEnvironmentsService.new(merge_request.source_project, current_user) .execute_for_merge_request(merge_request) diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 9ac386110f7..87cd6544406 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -8,7 +8,7 @@ module MergeRequests merge_request.id, current_user.id, old_assignees.map(&:id), - options + options.stringify_keys # see: https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1090 ) end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index d5e2595a9c6..3a4e3ba38fd 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -27,7 +27,6 @@ module MergeRequests merge_requests_for_source_branch.each do |mr| outdate_suggestions(mr) - refresh_pipelines_on_merge_requests(mr) abort_auto_merges(mr) mark_pending_todos_done(mr) end @@ -44,6 +43,8 @@ module MergeRequests notify_about_push(mr) mark_mr_as_draft_from_commits(mr) execute_mr_web_hooks(mr) + # Run at the end of the loop to avoid any potential contention on the MR object + refresh_pipelines_on_merge_requests(mr) merge_request_activity_counter.track_mr_including_ci_config(user: mr.author, merge_request: mr) end diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index f99db35fd49..d52c1bbbcda 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -9,9 +9,11 @@ module MergeRequests def execute(merge_request) return merge_request unless current_user&.can?(:update_merge_request, merge_request) - old_assignees = merge_request.assignees + old_assignees = merge_request.assignees.to_a old_ids = old_assignees.map(&:id) new_ids = new_assignee_ids(merge_request) + + return merge_request if merge_request.errors.any? return merge_request if new_ids.size != update_attrs[:assignee_ids].size return merge_request if old_ids.to_set == new_ids.to_set # no-change @@ -30,8 +32,11 @@ module MergeRequests def new_assignee_ids(merge_request) # prime the cache - prevent N+1 lookup during authorization loop. - merge_request.project.team.max_member_access_for_user_ids(update_attrs[:assignee_ids]) - User.id_in(update_attrs[:assignee_ids]).map do |user| + user_ids = update_attrs[:assignee_ids] + return [] if user_ids.empty? + + merge_request.project.team.max_member_access_for_user_ids(user_ids) + User.id_in(user_ids).map do |user| if user.can?(:read_merge_request, merge_request) user.id else diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index b613d88aee4..af041de5596 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -15,6 +15,7 @@ module MergeRequests end def handle_changes(merge_request, options) + super old_associations = options.fetch(:old_associations, {}) old_labels = old_associations.fetch(:labels, []) old_mentioned_users = old_associations.fetch(:mentioned_users, []) @@ -31,8 +32,6 @@ module MergeRequests end handle_target_branch_change(merge_request) - handle_assignees_change(merge_request, old_assignees) if merge_request.assignees != old_assignees - handle_reviewers_change(merge_request, old_reviewers) if merge_request.reviewers != old_reviewers handle_milestone_change(merge_request) handle_draft_status_change(merge_request, changed_fields) @@ -50,7 +49,7 @@ module MergeRequests # if merge_request.previous_changes.include?('target_branch') || merge_request.previous_changes.include?('source_branch') - merge_request.mark_as_unchecked + merge_request.mark_as_unchecked unless merge_request.unchecked? end end @@ -220,24 +219,6 @@ module MergeRequests end end - def handle_assignees_change(merge_request, old_assignees) - MergeRequests::HandleAssigneesChangeService - .new(project: project, current_user: current_user) - .async_execute(merge_request, old_assignees) - end - - def handle_reviewers_change(merge_request, old_reviewers) - affected_reviewers = (old_reviewers + merge_request.reviewers) - (old_reviewers & merge_request.reviewers) - create_reviewer_note(merge_request, old_reviewers) - notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers) - todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers) - invalidate_cache_counts(merge_request, users: affected_reviewers.compact) - - new_reviewers = merge_request.reviewers - old_reviewers - merge_request_activity_counter.track_users_review_requested(users: new_reviewers) - merge_request_activity_counter.track_reviewers_changed_action(user: current_user) - end - def create_branch_change_note(issuable, branch_type, event_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, event_type, @@ -293,7 +274,7 @@ module MergeRequests def attempt_specialized_update_services(merge_request, attribute) case attribute - when :assignee_ids + when :assignee_ids, :assignee_id assignees_service.execute(merge_request) when :spend_time add_time_spent_service.execute(merge_request) diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb index de54eb87cc0..80f15f7cc22 100644 --- a/app/services/namespace_settings/update_service.rb +++ b/app/services/namespace_settings/update_service.rb @@ -14,6 +14,7 @@ module NamespaceSettings def execute validate_resource_access_token_creation_allowed_param + validate_prevent_sharing_groups_outside_hierarchy_param if group.namespace_settings group.namespace_settings.attributes = settings_params @@ -32,6 +33,15 @@ module NamespaceSettings group.namespace_settings.errors.add(:resource_access_token_creation_allowed, _('can only be changed by a group admin.')) end end + + def validate_prevent_sharing_groups_outside_hierarchy_param + return if settings_params[:prevent_sharing_groups_outside_hierarchy].nil? + + unless can?(current_user, :change_prevent_sharing_groups_outside_hierarchy, group) + settings_params.delete(:prevent_sharing_groups_outside_hierarchy) + group.namespace_settings.errors.add(:prevent_sharing_groups_outside_hierarchy, _('can only be changed by a group admin.')) + end + end end end diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index 61d5ed3bdf4..3461362b48c 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -4,17 +4,37 @@ module Namespaces class InProductMarketingEmailsService include Gitlab::Experimentation::GroupTypes - INTERVAL_DAYS = [1, 5, 10].freeze TRACKS = { - create: :git_write, - verify: :pipeline_created, - trial: :trial_started, - team: :user_added + create: { + interval_days: [1, 5, 10], + completed_actions: [:created], + incomplete_actions: [:git_write] + }, + verify: { + interval_days: [1, 5, 10], + completed_actions: [:git_write], + incomplete_actions: [:pipeline_created] + }, + trial: { + interval_days: [1, 5, 10], + completed_actions: [:git_write, :pipeline_created], + incomplete_actions: [:trial_started] + }, + team: { + interval_days: [1, 5, 10], + completed_actions: [:git_write, :pipeline_created, :trial_started], + incomplete_actions: [:user_added] + }, + experience: { + interval_days: [30], + completed_actions: [:created, :git_write], + incomplete_actions: [] + } }.freeze def self.send_for_all_tracks_and_intervals TRACKS.each_key do |track| - INTERVAL_DAYS.each do |interval| + TRACKS[track][:interval_days].each do |interval| new(track, interval).execute end end @@ -69,7 +89,7 @@ module Namespaces def groups_for_track onboarding_progress_scope = OnboardingProgress .completed_actions_with_latest_in_range(completed_actions, range) - .incomplete_actions(incomplete_action) + .incomplete_actions(incomplete_actions) # Filtering out sub-groups is a temporary fix to prevent calling # `.root_ancestor` on groups that are not root groups. @@ -103,6 +123,8 @@ module Namespaces user.can?(:start_trial, group) when :team user.can?(:admin_group_member, group) + when :experience + true end end @@ -111,8 +133,7 @@ module Namespaces end def completed_actions - index = TRACKS.keys.index(track) - index == 0 ? [:created] : TRACKS.values[0..index - 1] + TRACKS[track][:completed_actions] end def range @@ -120,12 +141,12 @@ module Namespaces date.beginning_of_day..date.end_of_day end - def incomplete_action - TRACKS[track] + def incomplete_actions + TRACKS[track][:incomplete_actions] end def series - INTERVAL_DAYS.index(interval) + TRACKS[track][:interval_days].index(interval) end def save_tracked_emails! diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb index e8f783136cc..4f1bb0dc877 100644 --- a/app/services/notification_recipients/builder/base.rb +++ b/app/services/notification_recipients/builder/base.rb @@ -100,25 +100,6 @@ module NotificationRecipients # Get project/group users with CUSTOM notification level # rubocop: disable CodeReuse/ActiveRecord def add_custom_notifications - return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml) - - user_ids = [] - - # Users with a notification setting on group or project - user_ids += user_ids_notifiable_on(project, :custom) - user_ids += user_ids_notifiable_on(group, :custom) - - # Users with global level custom - user_ids_with_project_level_global = user_ids_notifiable_on(project, :global) - user_ids_with_group_level_global = user_ids_notifiable_on(group, :global) - - global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global) - user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action) - - add_recipients(user_scope.where(id: user_ids), :custom, nil) - end - - def new_add_custom_notifications notification_by_sources = related_notification_settings_sources(:custom) return if notification_by_sources.blank? @@ -172,22 +153,6 @@ module NotificationRecipients # Get project users with WATCH notification level # rubocop: disable CodeReuse/ActiveRecord def project_watchers - return new_project_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml) - - project_members_ids = user_ids_notifiable_on(project) - - user_ids_with_project_global = user_ids_notifiable_on(project, :global) - user_ids_with_group_global = user_ids_notifiable_on(project.group, :global) - - user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq) - - user_ids_with_project_setting = select_project_members_ids(user_ids_with_project_global, user_ids) - user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids) - - user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq) - end - - def new_project_watchers notification_by_sources = related_notification_settings_sources(:watch) return if notification_by_sources.blank? @@ -200,16 +165,6 @@ module NotificationRecipients # rubocop: disable CodeReuse/ActiveRecord def group_watchers - return new_group_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml) - - user_ids_with_group_global = user_ids_notifiable_on(group, :global) - user_ids = user_ids_with_global_level_watch(user_ids_with_group_global) - user_ids_with_group_setting = select_group_members_ids(group, [], user_ids_with_group_global, user_ids) - - user_scope.where(id: user_ids_with_group_setting) - end - - def new_group_watchers return [] unless group user_ids = group diff --git a/app/services/packages/debian/create_distribution_service.rb b/app/services/packages/debian/create_distribution_service.rb index f947d2e4293..b4b1ec952ef 100644 --- a/app/services/packages/debian/create_distribution_service.rb +++ b/app/services/packages/debian/create_distribution_service.rb @@ -38,14 +38,19 @@ module Packages append_errors(distribution) return error unless errors.empty? - distribution.transaction do - if distribution.save - create_components - create_architectures - - success - end - end || error + result = distribution.transaction do + next unless distribution.save + + create_components + create_architectures + success + end + + result ||= error + + ::Packages::Debian::GenerateDistributionWorker.perform_async(distribution.class.container_type, distribution.reset.id) if result.success? + + result end def create_components diff --git a/app/services/packages/debian/destroy_distribution_service.rb b/app/services/packages/debian/destroy_distribution_service.rb deleted file mode 100644 index bef1127fece..00000000000 --- a/app/services/packages/debian/destroy_distribution_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Packages - module Debian - class DestroyDistributionService - def initialize(distribution) - @distribution = distribution - end - - def execute - destroy_distribution - end - - private - - def destroy_distribution - if @distribution.destroy - success - else - error("Unable to destroy Debian #{@distribution.model_name.human.downcase}") - end - end - - def success - ServiceResponse.success - end - - def error(message) - ServiceResponse.error(message: message, payload: { distribution: @distribution }) - end - end - end -end diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb index 67348af1a49..651325c49a0 100644 --- a/app/services/packages/debian/generate_distribution_service.rb +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -6,6 +6,8 @@ module Packages include Gitlab::Utils::StrongMemoize include ExclusiveLeaseGuard + ONE_HOUR = 1.hour.freeze + # used by ExclusiveLeaseGuard DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze @@ -62,7 +64,7 @@ module Packages def initialize(distribution) @distribution = distribution - @last_generated_at = nil + @oldest_kept_generated_at = nil @md5sum = [] @sha256 = [] end @@ -70,7 +72,10 @@ module Packages def execute try_obtain_lease do @distribution.transaction do - @last_generated_at = @distribution.component_files.maximum(:created_at) + # We consider `apt-get update` can take at most one hour + # We keep all generations younger than one hour + # and the previous generation + @oldest_kept_generated_at = @distribution.component_files.updated_before(release_date - ONE_HOUR).maximum(:updated_at) generate_component_files generate_release destroy_old_component_files @@ -96,7 +101,7 @@ module Packages .with_debian_file_type(package_file_type) .find_each .map(&method(:package_stanza_from_fields)) - create_component_file(component, component_file_type, architecture, package_file_type, paragraphs.join("\n")) + reuse_or_create_component_file(component, component_file_type, architecture, paragraphs.join("\n")) end def package_stanza_from_fields(package_file) @@ -127,17 +132,32 @@ module Packages end end - def create_component_file(component, component_file_type, architecture, package_file_type, content) - component_file = component.files.create!( - file_type: component_file_type, - architecture: architecture, - compression_type: nil, - file: CarrierWaveStringFile.new(content), - file_md5: Digest::MD5.hexdigest(content), - file_sha256: Digest::SHA256.hexdigest(content) - ) - @md5sum.append(" #{component_file.file_md5} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") - @sha256.append(" #{component_file.file_sha256} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") + def reuse_or_create_component_file(component, component_file_type, architecture, content) + file_md5 = Digest::MD5.hexdigest(content) + file_sha256 = Digest::SHA256.hexdigest(content) + component_file = component.files + .with_file_type(component_file_type) + .with_architecture(architecture) + .with_compression_type(nil) + .with_file_sha256(file_sha256) + .last + + if component_file + component_file.touch(time: release_date) + else + component_file = component.files.create!( + updated_at: release_date, + file_type: component_file_type, + architecture: architecture, + compression_type: nil, + file: CarrierWaveStringFile.new(content), + file_md5: file_md5, + file_sha256: file_sha256 + ) + end + + @md5sum.append(" #{file_md5} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") + @sha256.append(" #{file_sha256} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") end def generate_release @@ -187,10 +207,9 @@ module Packages end def destroy_old_component_files - # Only keep the last generation and one hour before - return if @last_generated_at.nil? + return if @oldest_kept_generated_at.nil? - @distribution.component_files.created_before(@last_generated_at - 1.hour).destroy_all # rubocop:disable Cop/DestroyAll + @distribution.component_files.updated_before(@oldest_kept_generated_at).destroy_all # rubocop:disable Cop/DestroyAll end # used by ExclusiveLeaseGuard diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb index 881ad2c46f4..b6e81012656 100644 --- a/app/services/packages/debian/process_changes_service.rb +++ b/app/services/packages/debian/process_changes_service.rb @@ -25,6 +25,8 @@ module Packages update_files_metadata update_changes_metadata end + + ::Packages::Debian::GenerateDistributionWorker.perform_async(:project, package.debian_distribution.id) end end diff --git a/app/services/packages/debian/update_distribution_service.rb b/app/services/packages/debian/update_distribution_service.rb index 95face912d5..218167ecdc5 100644 --- a/app/services/packages/debian/update_distribution_service.rb +++ b/app/services/packages/debian/update_distribution_service.rb @@ -31,7 +31,7 @@ module Packages end def update_distribution - distribution.transaction do + result = distribution.transaction do if distribution.update(params) update_components if components update_architectures if architectures @@ -41,7 +41,13 @@ module Packages append_errors(distribution) error end - end || error + end + + result ||= error + + ::Packages::Debian::GenerateDistributionWorker.perform_async(distribution.class.container_type, distribution.id) if result.success? + + result end def update_components diff --git a/app/services/packages/helm/extract_file_metadata_service.rb b/app/services/packages/helm/extract_file_metadata_service.rb new file mode 100644 index 00000000000..e7373d8ea8f --- /dev/null +++ b/app/services/packages/helm/extract_file_metadata_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rubygems/package' + +module Packages + module Helm + class ExtractFileMetadataService + ExtractionError = Class.new(StandardError) + + def initialize(package_file) + @package_file = package_file + end + + def execute + raise ExtractionError, 'invalid package file' unless valid_package_file? + + metadata + end + + private + + def valid_package_file? + @package_file && + @package_file.package&.helm? && + @package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate + end + + def metadata + YAML.safe_load(chart_yaml_content) + rescue Psych::Exception => e + raise ExtractionError, "Error while parsing Chart.yaml: #{e.message}" + end + + def chart_yaml_content + @package_file.file.use_open_file do |file| + tar_reader = Gem::Package::TarReader.new(Zlib::GzipReader.new(file)) + + chart_yaml = tar_reader.find do |entry| + next unless entry.file? + + entry.full_name.end_with?('/Chart.yaml') + end + + raise ExtractionError, 'Chart.yaml not found within a directory' unless chart_yaml + + chart_yaml.read + ensure + tar_reader.close + end + end + end + end +end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index dd5f1bcc936..63da98dde43 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -28,7 +28,7 @@ module Packages def execute raise ExtractionError, 'invalid package file' unless valid_package_file? - extract_metadata(nuspec_file) + extract_metadata(nuspec_file_content) end private @@ -39,6 +39,10 @@ module Packages end end + def project + package_file.package.project + end + def valid_package_file? package_file && package_file.package&.nuget? && @@ -89,16 +93,21 @@ module Packages tags.split(::Packages::Tag::NUGET_TAGS_SEPARATOR) end - def nuspec_file - package_file.file.use_file do |file_path| - Zip::File.open(file_path) do |zip_file| - entry = zip_file.glob('*.nuspec').first + def nuspec_file_content + with_zip_file do |zip_file| + entry = zip_file.glob('*.nuspec').first - raise ExtractionError, 'nuspec file not found' unless entry - raise ExtractionError, 'nuspec file too big' if entry.size > MAX_FILE_SIZE + raise ExtractionError, 'nuspec file not found' unless entry + raise ExtractionError, 'nuspec file too big' if entry.size > MAX_FILE_SIZE - entry.get_input_stream.read - end + entry.get_input_stream.read + end + end + + def with_zip_file(&block) + package_file.file.use_open_file do |open_file| + zip_file = Zip::File.new(open_file, false, true) + yield(zip_file) end end end diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb index c4009dcc4ec..f7d3d70aad6 100644 --- a/app/services/pages/delete_service.rb +++ b/app/services/pages/delete_service.rb @@ -3,8 +3,13 @@ module Pages class DeleteService < BaseService def execute - project.mark_pages_as_not_deployed # prevents domain from updating config when deleted - project.pages_domains.delete_all + project.mark_pages_as_not_deployed + + # project.pages_domains.delete_all will just nullify project_id: + # > If no :dependent option is given, then it will follow the default + # > strategy for `has_many :through` associations. + # > The default strategy is :nullify which sets the foreign keys to NULL. + PagesDomain.for_project(project).delete_all DestroyPagesDeploymentsWorker.perform_async(project.id) diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 97ea7d87545..7dd9280e5b1 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -11,6 +11,9 @@ module Projects @initialize_with_readme = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_readme)) @import_data = @params.delete(:import_data) @relations_block = @params.delete(:relations_block) + @default_branch = @params.delete(:default_branch) + + build_topics end def execute @@ -128,20 +131,16 @@ module Projects access_level: group_access_level) end - if Feature.enabled?(:specialized_project_authorization_workers, default_enabled: :yaml) - AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id) - # AuthorizedProjectsWorker uses an exclusive lease per user but - # specialized workers might have synchronization issues. Until we - # compare the inconsistency rates of both approaches, we still run - # AuthorizedProjectsWorker but with some delay and lower urgency as a - # safety net. - @project.group.refresh_members_authorized_projects( - blocking: false, - priority: UserProjectAccessChangedService::LOW_PRIORITY - ) - else - @project.group.refresh_members_authorized_projects(blocking: false) - end + AuthorizedProjectUpdate::ProjectCreateWorker.perform_async(@project.id) + # AuthorizedProjectsWorker uses an exclusive lease per user but + # specialized workers might have synchronization issues. Until we + # compare the inconsistency rates of both approaches, we still run + # AuthorizedProjectsWorker but with some delay and lower urgency as a + # safety net. + @project.group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) else @project.add_maintainer(@project.namespace.owner, current_user: current_user) end @@ -149,7 +148,7 @@ module Projects def create_readme commit_attrs = { - branch_name: @project.default_branch_or_main, + branch_name: @default_branch.presence || @project.default_branch_or_main, commit_message: 'Initial commit', file_path: 'README.md', file_content: "# #{@project.name}\n\n#{@project.description}" @@ -261,6 +260,14 @@ module Projects .new(current_user, @project, project_params: { import_data: @import_data }) .level_restricted? end + + def build_topics + topics = params.delete(:topics) + tag_list = params.delete(:tag_list) + topic_list = topics || tag_list + + params[:topic_list] ||= topic_list if topic_list + end end end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 0682f3013d4..e27ea7c07e5 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -116,7 +116,7 @@ module Projects log_destroy_event trash_relation_repositories! trash_project_repositories! - destroy_web_hooks! if Feature.enabled?(:destroy_webhooks_before_the_project, project, default_enabled: :yaml) + destroy_web_hooks! # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index d8fa2f36fcc..fc5c936b378 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -23,22 +23,18 @@ module Projects private def setup_authorizations(group, group_access = nil) - if Feature.enabled?(:specialized_project_authorization_project_share_worker, default_enabled: :yaml) - AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker.perform_async( - project.id, group.id, group_access) + AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker.perform_async( + project.id, group.id, group_access) - # AuthorizedProjectsWorker uses an exclusive lease per user but - # specialized workers might have synchronization issues. Until we - # compare the inconsistency rates of both approaches, we still run - # AuthorizedProjectsWorker but with some delay and lower urgency as a - # safety net. - group.refresh_members_authorized_projects( - blocking: false, - priority: UserProjectAccessChangedService::LOW_PRIORITY - ) - else - group.refresh_members_authorized_projects(blocking: false) - end + # AuthorizedProjectsWorker uses an exclusive lease per user but + # specialized workers might have synchronization issues. Until we + # compare the inconsistency rates of both approaches, we still run + # AuthorizedProjectsWorker but with some delay and lower urgency as a + # safety net. + group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) end end end diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb index bfe704cd780..01a5b617b46 100644 --- a/app/services/projects/group_links/destroy_service.rb +++ b/app/services/projects/group_links/destroy_service.rb @@ -13,9 +13,27 @@ module Projects end group_link.destroy.tap do |link| - link.group.refresh_members_authorized_projects + if Feature.enabled?(:use_specialized_worker_for_project_auth_recalculation) + refresh_project_authorizations_asynchronously(link.project) + + # Until we compare the inconsistency rates of the new specialized worker and + # the old approach, we still run AuthorizedProjectsWorker + # but with some delay and lower urgency as a safety net. + link.group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) + else + link.group.refresh_members_authorized_projects + end end end + + private + + def refresh_project_authorizations_asynchronously(project) + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) + end end end end diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index db640a54745..e1eb1374d14 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -105,9 +105,9 @@ module Projects cluster = alert.environment.deployment_platform&.cluster return unless cluster&.enabled? - return unless cluster.application_prometheus_available? + return unless cluster.integration_prometheus_available? - cluster.application_prometheus || cluster.integration_prometheus + cluster.integration_prometheus end def find_alert(project, metric) diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 541b333aae3..4351a66351d 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -8,6 +8,7 @@ module Projects ValidationError = Class.new(StandardError) def execute + build_topics remove_unallowed_params validate! @@ -167,6 +168,14 @@ module Projects project.repository_storage != new_repository_storage && can?(current_user, :change_repository_storage, project) end + + def build_topics + topics = params.delete(:topics) + tag_list = params.delete(:tag_list) + topic_list = topics || tag_list + + params[:topic_list] ||= topic_list if topic_list + end end end diff --git a/app/services/prometheus/create_default_alerts_service.rb b/app/services/prometheus/create_default_alerts_service.rb index e59b0a8e8e3..eb8a9d45658 100644 --- a/app/services/prometheus/create_default_alerts_service.rb +++ b/app/services/prometheus/create_default_alerts_service.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# DEPRECATED: To be removed as part of https://gitlab.com/groups/gitlab-org/-/epics/5877 module Prometheus class CreateDefaultAlertsService < BaseService include Gitlab::Utils::StrongMemoize @@ -53,12 +54,12 @@ module Prometheus end def schedule_prometheus_update - return unless prometheus_application + return unless prometheus_adapter - ::Clusters::Applications::ScheduleUpdateService.new(prometheus_application, project).execute + ::Clusters::Applications::ScheduleUpdateService.new(prometheus_adapter, project).execute end - def prometheus_application + def prometheus_adapter environment.cluster_prometheus_adapter end diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 84f4478f20f..6ff8767a525 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -48,7 +48,7 @@ module ResourceAccessTokens # since someone like a project maintainer does not inherently have the ability # to create a new user in the system. - Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true) + ::Users::AuthorizedCreateService.new(current_user, default_user_params).execute end def delete_failed_user(user) diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 3181c0098cc..3e93346bfdf 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -8,8 +8,8 @@ module Search attr_accessor :project, :current_user, :params - def initialize(project, user, params) - @project = project + def initialize(project_or_projects, user, params) + @project = project_or_projects @current_user = user @params = params.dup end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 389cf17e115..cce7821a226 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -41,6 +41,10 @@ class SearchService end # rubocop: enable CodeReuse/ActiveRecord + def projects + # overridden in EE + end + def show_snippets? return @show_snippets if defined?(@show_snippets) diff --git a/app/services/security/ci_configuration/sast_parser_service.rb b/app/services/security/ci_configuration/sast_parser_service.rb index 5220525d552..cae9a90f0a0 100644 --- a/app/services/security/ci_configuration/sast_parser_service.rb +++ b/app/services/security/ci_configuration/sast_parser_service.rb @@ -74,19 +74,11 @@ module Security def sast_excluded_analyzers strong_memoize(:sast_excluded_analyzers) do - all_analyzers = Security::CiConfiguration::SastBuildAction::SAST_DEFAULT_ANALYZERS.split(', ') rescue [] - enabled_analyzers = sast_default_analyzers.split(',').map(&:strip) rescue [] - excluded_analyzers = gitlab_ci_yml_attributes["SAST_EXCLUDED_ANALYZERS"] || sast_template_attributes["SAST_EXCLUDED_ANALYZERS"] - excluded_analyzers = excluded_analyzers.split(',').map(&:strip) rescue [] - ((all_analyzers - enabled_analyzers) + excluded_analyzers).uniq + excluded_analyzers.split(',').map(&:strip) rescue [] end end - def sast_default_analyzers - @sast_default_analyzers ||= gitlab_ci_yml_attributes["SAST_DEFAULT_ANALYZERS"] || sast_template_attributes["SAST_DEFAULT_ANALYZERS"] - end - def sast_template_attributes @sast_template_attributes ||= build_sast_attributes(sast_template_content) end @@ -109,17 +101,17 @@ module Security yaml_result = Gitlab::Ci::YamlProcessor.new(content, options).execute return {} unless yaml_result.valid? - sast_attributes = yaml_result.build_attributes(:sast) - extract_required_attributes(sast_attributes) + extract_required_attributes(yaml_result) end - def extract_required_attributes(attributes) + def extract_required_attributes(yaml_result) result = {} - attributes[:yaml_variables].each do |variable| + + yaml_result.yaml_variables_for(:sast).each do |variable| result[variable[:key]] = variable[:value] end - result[:stage] = attributes[:stage] + result[:stage] = yaml_result.stage_for(:sast) result.with_indifferent_access end end diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb index 415cfcb7d8f..1a04c4fcedd 100644 --- a/app/services/snippets/base_service.rb +++ b/app/services/snippets/base_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Snippets - class BaseService < ::BaseService + class BaseService < ::BaseProjectService UPDATE_COMMIT_MSG = 'Update snippet' INITIAL_COMMIT_MSG = 'Initial commit' @@ -9,7 +9,7 @@ module Snippets attr_reader :uploaded_assets, :snippet_actions - def initialize(project, user = nil, params = {}) + def initialize(project: nil, current_user: nil, params: {}) super @uploaded_assets = Array(@params.delete(:files).presence) @@ -20,7 +20,7 @@ module Snippets private - def visibility_allowed?(snippet, visibility_level) + def visibility_allowed?(visibility_level) Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level) end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index aadf9b865b8..8f1b481d307 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -12,7 +12,7 @@ module Snippets return invalid_params_error(@snippet) unless valid_params? - unless visibility_allowed?(snippet, snippet.visibility_level) + unless visibility_allowed?(snippet.visibility_level) return forbidden_visibility_error(snippet) end diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 4088a08272d..8571bc9c869 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -14,7 +14,7 @@ module Snippets return invalid_params_error(snippet) unless valid_params? - if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level) + if visibility_changed?(snippet) && !visibility_allowed?(visibility_level) return forbidden_visibility_error(snippet) end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index fc6543a8efc..71bb813f384 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -316,6 +316,8 @@ class TodoService attributes.merge!(target_id: nil, commit_id: target.id) elsif target.is_a?(Issue) attributes[:issue_type] = target.issue_type + elsif target.is_a?(Discussion) + attributes.merge!(target_type: nil, target_id: nil, discussion: target) end attributes diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb index 80490bd4c9a..f52502e0379 100644 --- a/app/services/user_project_access_changed_service.rb +++ b/app/services/user_project_access_changed_service.rb @@ -13,17 +13,20 @@ class UserProjectAccessChangedService def execute(blocking: true, priority: HIGH_PRIORITY) bulk_args = @user_ids.map { |id| [id] } - if blocking - AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args) - else - if priority == HIGH_PRIORITY - AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + result = + if blocking + AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args) else - AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.bulk_perform_in( # rubocop:disable Scalability/BulkPerformWithContext - DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds) + if priority == HIGH_PRIORITY + AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext + else + AuthorizedProjectUpdate::UserRefreshFromReplicaWorker.bulk_perform_in( # rubocop:disable Scalability/BulkPerformWithContext + DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds) + end end - end + + ::Gitlab::Database::LoadBalancing::Sticking.bulk_stick(:user, @user_ids) + + result end end - -UserProjectAccessChangedService.prepend_mod_with('UserProjectAccessChangedService') diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index c89a286cc8b..20594bec28d 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -17,7 +17,7 @@ module Users def execute return unless @user - record_activity + ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes { record_activity } end private @@ -37,5 +37,3 @@ module Users end end end - -Users::ActivityService.prepend_mod diff --git a/app/services/users/authorized_build_service.rb b/app/services/users/authorized_build_service.rb new file mode 100644 index 00000000000..eb2386198d3 --- /dev/null +++ b/app/services/users/authorized_build_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Users + class AuthorizedBuildService < BuildService + extend ::Gitlab::Utils::Override + + private + + override :validate_access! + def validate_access! + # no-op + end + + def signup_params + super + [:skip_confirmation] + end + end +end diff --git a/app/services/users/authorized_create_service.rb b/app/services/users/authorized_create_service.rb new file mode 100644 index 00000000000..b6109f0c191 --- /dev/null +++ b/app/services/users/authorized_create_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Users + class AuthorizedCreateService < CreateService + extend ::Gitlab::Utils::Override + + private + + override :build_class + def build_class + Users::AuthorizedBuildService + end + end +end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 649cf281ab0..ddb20a835e1 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -5,7 +5,6 @@ module Users delegate :user_default_internal_regex_enabled?, :user_default_internal_regex_instance, to: :'Gitlab::CurrentSettings.current_application_settings' - attr_reader :identity_params def initialize(current_user, params = {}) @current_user = current_user @@ -13,46 +12,128 @@ module Users @identity_params = params.slice(*identity_attributes) end - def execute(skip_authorization: false) - @skip_authorization = skip_authorization + def execute + build_user + build_identity + update_canonical_email - raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user? + user + end - user_params = build_user_params - user = User.new(user_params) + private - if current_user&.admin? - @reset_token = user.generate_reset_token if params[:reset_password] + attr_reader :identity_params, :user_params, :user - if user_params[:force_random_password] - random_password = User.random_password - user.password = user.password_confirmation = random_password - end + def identity_attributes + [:extern_uid, :provider] + end + + def build_user + if admin? + admin_build_user + else + standard_build_user end + end - build_identity(user) + def admin? + return false unless current_user - Users::UpdateCanonicalEmailService.new(user: user).execute + current_user.admin? + end - user + def admin_build_user + build_user_params_for_admin + init_user + password_reset end - private + def standard_build_user + # current_user non admin or nil + validate_access! + build_user_params_for_non_admin + init_user + end - attr_reader :skip_authorization + def build_user_params_for_admin + @user_params = params.slice(*admin_create_params) + @user_params.merge!(force_random_password: true, password_expires_at: nil) if params[:reset_password] + end - def identity_attributes - [:extern_uid, :provider] + def init_user + assign_common_user_params + + @user = User.new(user_params) + end + + def assign_common_user_params + @user_params[:created_by_id] = current_user&.id + @user_params[:external] = user_external? if set_external_param? + + @user_params.delete(:user_type) unless project_bot? + end + + def set_external_param? + user_default_internal_regex_enabled? && !user_params.key?(:external) + end + + def user_external? + user_default_internal_regex_instance.match(params[:email]).nil? + end + + def project_bot? + user_params[:user_type]&.to_sym == :project_bot + end + + def password_reset + @reset_token = user.generate_reset_token if params[:reset_password] + + if user_params[:force_random_password] + random_password = User.random_password + @user.password = user.password_confirmation = random_password + end + end + + def validate_access! + return if can_create_user? + + raise Gitlab::Access::AccessDeniedError + end + + def can_create_user? + current_user.nil? && Gitlab::CurrentSettings.allow_signup? + end + + def build_user_params_for_non_admin + @user_params = params.slice(*signup_params) + @user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting if assign_skip_confirmation_from_settings? + @user_params[:name] = fallback_name if use_fallback_name? + end + + def assign_skip_confirmation_from_settings? + user_params[:skip_confirmation].nil? end - def build_identity(user) + def skip_user_confirmation_email_from_setting + !Gitlab::CurrentSettings.send_user_confirmation_email + end + + def use_fallback_name? + user_params[:name].blank? && fallback_name.present? + end + + def fallback_name + "#{user_params[:first_name]} #{user_params[:last_name]}" + end + + def build_identity return if identity_params.empty? user.identities.build(identity_params) end - def can_create_user? - (current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin? + def update_canonical_email + Users::UpdateCanonicalEmailService.new(user: user).execute end # Allowed params for creating a user (admins only) @@ -96,69 +177,15 @@ module Users def signup_params [ :email, - :password_automatically_set, :name, - :first_name, - :last_name, :password, + :password_automatically_set, :username, - :user_type + :user_type, + :first_name, + :last_name ] end - - def build_user_params - if current_user&.admin? - user_params = params.slice(*admin_create_params) - - if params[:reset_password] - user_params.merge!(force_random_password: true, password_expires_at: nil) - end - else - allowed_signup_params = signup_params - allowed_signup_params << :skip_confirmation if allow_caller_to_request_skip_confirmation? - - user_params = params.slice(*allowed_signup_params) - if assign_skip_confirmation_from_settings?(user_params) - user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting - end - - fallback_name = "#{user_params[:first_name]} #{user_params[:last_name]}" - - if user_params[:name].blank? && fallback_name.present? - user_params = user_params.merge(name: fallback_name) - end - end - - user_params[:created_by_id] = current_user&.id - - if user_default_internal_regex_enabled? && !user_params.key?(:external) - user_params[:external] = user_external? - end - - user_params.delete(:user_type) unless project_bot?(user_params[:user_type]) - - user_params - end - - def allow_caller_to_request_skip_confirmation? - skip_authorization - end - - def assign_skip_confirmation_from_settings?(user_params) - user_params[:skip_confirmation].nil? - end - - def skip_user_confirmation_email_from_setting - !Gitlab::CurrentSettings.send_user_confirmation_email - end - - def user_external? - user_default_internal_regex_instance.match(params[:email]).nil? - end - - def project_bot?(user_type) - user_type&.to_sym == :project_bot - end end end diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index 757ebd783ee..591d88b275e 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -9,8 +9,8 @@ module Users @params = params.dup end - def execute(skip_authorization: false) - user = Users::BuildService.new(current_user, params).execute(skip_authorization: skip_authorization) + def execute + user = build_class.new(current_user, params).execute reset_token = user.generate_reset_token if user.recently_sent_password_reset? after_create_hook(user, reset_token) if user.save @@ -23,6 +23,11 @@ module Users def after_create_hook(user, reset_token) notify_new_user(user, reset_token) end + + def build_class + # overridden by inheriting classes + Users::BuildService + end end end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index d28ff45bfdf..1850fa9747d 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -24,11 +24,6 @@ module Users @source = source @incorrect_auth_found_callback = incorrect_auth_found_callback @missing_auth_found_callback = missing_auth_found_callback - - # We need an up to date User object that has access to all relations that - # may have been created earlier. The only way to ensure this is to reload - # the User object. - user.reset end def execute @@ -43,6 +38,10 @@ module Users end begin + # We need an up to date User object that has access to all relations that + # may have been created earlier. The only way to ensure this is to reload + # the User object. + user.reset execute_without_lease ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) diff --git a/app/services/users/registrations_build_service.rb b/app/services/users/registrations_build_service.rb index 9d7bf0a7e18..2d367e7b185 100644 --- a/app/services/users/registrations_build_service.rb +++ b/app/services/users/registrations_build_service.rb @@ -6,13 +6,12 @@ module Users private - override :allow_caller_to_request_skip_confirmation? - def allow_caller_to_request_skip_confirmation? - true + def signup_params + super + [:skip_confirmation] end override :assign_skip_confirmation_from_settings? - def assign_skip_confirmation_from_settings?(user_params) + def assign_skip_confirmation_from_settings? user_params[:skip_confirmation].blank? end end diff --git a/app/services/users/update_assigned_open_issue_count_service.rb b/app/services/users/update_assigned_open_issue_count_service.rb deleted file mode 100644 index 2ed05853b2f..00000000000 --- a/app/services/users/update_assigned_open_issue_count_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Users - # Service class for calculating and caching the number of assigned open issues for a user. - class UpdateAssignedOpenIssueCountService - attr_accessor :target_user - - def initialize(target_user:) - @target_user = target_user - - raise ArgumentError, "Please provide a target user" unless target_user.is_a?(User) - end - - def execute - value = calculate_count - Rails.cache.write(cache_key, value, expires_in: User::COUNT_CACHE_VALIDITY_PERIOD) - - ServiceResponse.success(payload: { count: value }) - rescue StandardError => e - ServiceResponse.error(message: e.message) - end - - private - - def cache_key - ['users', target_user.id, 'assigned_open_issues_count'] - end - - def calculate_count - IssuesFinder.new(target_user, assignee_id: target_user.id, state: 'opened', non_archived: true).execute.count - end - end -end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 654d9356739..77d2139b3d1 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -27,18 +27,19 @@ class WebHookService REQUEST_BODY_SIZE_LIMIT = 25.megabytes GITLAB_EVENT_HEADER = 'X-Gitlab-Event' - MAX_FAILURES = 100 attr_accessor :hook, :data, :hook_name, :request_options + attr_reader :uniqueness_token def self.hook_to_event(hook_name) hook_name.to_s.singularize.titleize end - def initialize(hook, data, hook_name) + def initialize(hook, data, hook_name, uniqueness_token = nil) @hook = hook @data = data @hook_name = hook_name.to_s + @uniqueness_token = uniqueness_token @request_options = { timeout: Gitlab.config.gitlab.webhook_timeout, allow_local_requests: hook.allow_local_requests? @@ -69,8 +70,7 @@ class WebHookService http_status: response.code, message: response.to_s } - rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, - Net::OpenTimeout, Net::ReadTimeout, Gitlab::HTTP::BlockedUrlError, Gitlab::HTTP::RedirectionTooDeep, + rescue *Gitlab::HTTP::HTTP_ERRORS, Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError => e execution_duration = Gitlab::Metrics::System.monotonic_time - start_time log_execution( @@ -91,9 +91,9 @@ class WebHookService end def async_execute - if rate_limited?(hook) - log_rate_limit(hook) - else + Gitlab::ApplicationContext.with_context(hook.application_context) do + break log_rate_limit if rate_limited? + WebHookWorker.perform_async(hook.id, data, hook_name) end end @@ -123,10 +123,8 @@ class WebHookService end def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil) - handle_failure(response, hook) - - WebHookLog.create( - web_hook: hook, + category = response_category(response) + log_data = { trigger: trigger, url: url, execution_duration: execution_duration, @@ -136,17 +134,19 @@ class WebHookService response_body: safe_response_body(response), response_status: response.code, internal_error_message: error_message - ) + } + + ::WebHooks::LogExecutionWorker + .perform_async(hook.id, log_data, category, uniqueness_token) end - def handle_failure(response, hook) + def response_category(response) if response.success? || response.redirection? - hook.enable! + :ok elsif response.internal_server_error? - next_backoff = hook.next_backoff - hook.update!(disabled_until: next_backoff.from_now, backoff_count: hook.backoff_count + 1) + :error else - hook.update!(recent_failures: hook.recent_failures + 1) if hook.recent_failures < MAX_FAILURES + :failed end end @@ -175,7 +175,7 @@ class WebHookService response.body.encode('UTF-8', invalid: :replace, undef: :replace, replace: '') end - def rate_limited?(hook) + def rate_limited? return false unless Feature.enabled?(:web_hooks_rate_limit, default_enabled: :yaml) return false if rate_limit.nil? @@ -190,18 +190,13 @@ class WebHookService @rate_limit ||= hook.rate_limit end - def log_rate_limit(hook) - payload = { + def log_rate_limit + Gitlab::AuthLogger.error( message: 'Webhook rate limit exceeded', hook_id: hook.id, hook_type: hook.type, - hook_name: hook_name - } - - Gitlab::AuthLogger.error(payload) - - # Also log into application log for now, so we can use this information - # to determine suitable limits for gitlab.com - Gitlab::AppLogger.error(payload) + hook_name: hook_name, + **Gitlab::ApplicationContext.current + ) end end diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb new file mode 100644 index 00000000000..6e58e15f093 --- /dev/null +++ b/app/services/web_hooks/log_execution_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module WebHooks + class LogExecutionService + attr_reader :hook, :log_data, :response_category + + def initialize(hook:, log_data:, response_category:) + @hook = hook + @log_data = log_data + @response_category = response_category + end + + def execute + update_hook_executability + log_execution + end + + private + + def log_execution + WebHookLog.create!(web_hook: hook, **log_data.transform_keys(&:to_sym)) + end + + def update_hook_executability + case response_category + when :ok + hook.enable! + when :error + hook.backoff! + when :failed + hook.failed! + end + end + end +end diff --git a/app/validators/json_schemas/ci_runner_config.json b/app/validators/json_schemas/ci_runner_config.json new file mode 100644 index 00000000000..af1bcfcb183 --- /dev/null +++ b/app/validators/json_schemas/ci_runner_config.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "CI Runner config values", + "type": "object", + "properties": { + "gpus": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json index 7c3720dd2e6..20be49f9eae 100644 --- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json +++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json @@ -18,15 +18,6 @@ "value": "", "size": "MEDIUM", "description": "Comma-separated list of paths to be excluded from analyzer output. Patterns can be globs, file paths, or folder paths." - }, - { - "field" : "SAST_ANALYZER_IMAGE_TAG", - "label" : "Image tag", - "type": "string", - "default_value": "", - "value": "", - "size": "SMALL", - "description": "Analyzer image's tag" } ], "pipeline": [ @@ -109,20 +100,6 @@ ] }, { - "name": "kubesec", - "label": "Kubesec", - "enabled" : true, - "description": "Kubernetes manifests, Helm Charts", - "variables": [] - }, - { - "name": "nodejs-scan", - "label": "Node.js Scan", - "enabled" : true, - "description": "Node.js", - "variables": [] - }, - { "name": "gosec", "label": "Gosec", "enabled" : true, @@ -140,6 +117,20 @@ ] }, { + "name": "kubesec", + "label": "Kubesec", + "enabled" : true, + "description": "Kubernetes manifests, Helm Charts", + "variables": [] + }, + { + "name": "nodejs-scan", + "label": "Node.js Scan", + "enabled" : true, + "description": "Node.js", + "variables": [] + }, + { "name": "phpcs-security-audit", "label": "PHP Security Audit", "enabled" : true, diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 1e2c9f821d2..eb30efabb98 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -53,6 +53,14 @@ = _('Specify an e-mail address regex pattern to identify default internal users.') = link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'), target: '_blank' + - unless Gitlab.com? + .form-group + = f.label :deactivate_dormant_users, _('Dormant users'), class: 'label-bold' + .form-check + = f.check_box :deactivate_dormant_users, class: 'form-check-input' + = f.label :deactivate_dormant_users, class: 'form-check-label' do + = _('Deactivate dormant users after 90 days of inactivity. Users can return to active status by signing in to their account. While inactive, a user is not counted as an active user in the instance.') + = link_to _('More information'), help_page_path('user/admin_area/moderate_users', anchor: 'automatically-deactivate-dormant-users'), target: '_blank' .form-group = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light' = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control gl-form-input' diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 0af244d54f3..5ae45d5a9da 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -3,15 +3,13 @@ %fieldset .form-group - .card.auto-devops-card - .card-body - .form-check - = f.check_box :auto_devops_enabled, class: 'form-check-input' - = f.label :auto_devops_enabled, class: 'form-check-label' do - = s_('CICD|Default to Auto DevOps pipeline for all projects') - .form-text.text-muted - = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') - = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' + .form-check + = f.check_box :auto_devops_enabled, class: 'form-check-input' + = f.label :auto_devops_enabled, class: 'form-check-label' do + %strong= s_('CICD|Default to Auto DevOps pipeline for all projects') + .form-text.text-muted + = s_('CICD|The Auto DevOps pipeline runs by default in all projects with no CI/CD configuration file.') + = link_to _('What is Auto DevOps?'), help_page_path('topics/autodevops/index.md'), target: '_blank' .form-group = f.label :auto_devops_domain, s_('AdminSettings|Auto DevOps domain'), class: 'label-bold' = f.text_field :auto_devops_domain, class: 'form-control gl-form-input', placeholder: 'domain.com' @@ -26,9 +24,9 @@ = render_if_exists 'admin/application_settings/shared_runners_minutes_setting', form: f .form-group - = f.label :shared_runners_text, class: 'label-bold' + = f.label :shared_runners_text, _('Shared runners details'), class: 'label-bold' = f.text_area :shared_runners_text, class: 'form-control gl-form-input', rows: 4 - .form-text.text-muted= _("Markdown enabled") + .form-text.text-muted= _("Add a custom message with details about the instance's shared runners. The message is visible in group and project CI/CD settings, in the Runners section. Markdown is supported.") .form-group = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold' = f.number_field :max_artifacts_size, class: 'form-control gl-form-input' @@ -58,14 +56,14 @@ .form-check = f.check_box :protected_ci_variables, class: 'form-check-input' = f.label :protected_ci_variables, class: 'form-check-label' do - = s_('AdminSettings|Environment variables are protected by default') + %strong= s_('AdminSettings|Protect CI/CD variables by default') .form-text.text-muted - = s_('AdminSettings|When creating a new environment variable it will be protected by default.') + = s_('AdminSettings|New CI/CD variables in projects and groups default to protected.') .form-group - = f.label :ci_config_path, _('Default CI configuration path'), class: 'label-bold' + = f.label :ci_config_path, _('Default CI/CD configuration file'), class: 'label-bold' = f.text_field :default_ci_config_path, class: 'form-control gl-form-input', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted - = _("The default CI configuration path for new projects.").html_safe - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-path'), target: '_blank' + = _("The default CI/CD configuration file and path for new projects.").html_safe + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-file'), target: '_blank' = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 7286fffcaf6..6a51d2e39d4 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -3,13 +3,30 @@ %fieldset .form-group - = f.label :diff_max_patch_bytes, 'Maximum diff patch size (Bytes)', class: 'label-light' + = f.label :diff_max_patch_bytes, _('Maximum diff patch size (Bytes)'), class: 'label-light' = f.number_field :diff_max_patch_bytes, class: 'form-control gl-form-input' %span.form-text.text-muted - Diff files surpassing this limit will be presented as 'too large' - and won't be expandable. + = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") = link_to sprite_icon('question-o'), help_page_path('user/admin_area/diff_limits', - anchor: 'maximum-diff-patch-size') + anchor: 'diff-limits-administration') + + = f.label :diff_max_files, _('Maximum files in a diff'), class: 'label-light' + = f.number_field :diff_max_files, class: 'form-control gl-form-input' + %span.form-text.text-muted + = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") + + = link_to sprite_icon('question-o'), + help_page_path('user/admin_area/diff_limits', + anchor: 'diff-limits-administration') + + = f.label :diff_max_lines, _('Maximum lines in a diff'), class: 'label-light' + = f.number_field :diff_max_lines, class: 'form-control gl-form-input' + %span.form-text.text-muted + = _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.") + + = link_to sprite_icon('question-o'), + help_page_path('user/admin_area/diff_limits', + anchor: 'diff-limits-administration') = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index 6d335e2db16..c08b41e8c55 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -22,7 +22,9 @@ = f.label :gitpod_enabled, s_('Gitpod|Enable Gitpod integration'), class: 'form-check-label' .form-group = f.label :gitpod_url, s_('Gitpod|Gitpod URL'), class: 'label-bold' - = f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|e.g. https://gitpod.example.com') + = f.text_field :gitpod_url, class: 'form-control gl-form-input', placeholder: s_('Gitpod|https://gitpod.example.com') .form-text.text-muted - = s_('Gitpod|Add the URL to your Gitpod instance configured to read your GitLab projects.') + = s_('Gitpod|The URL to your Gitpod instance configured to read your GitLab projects, such as https://gitpod.example.com.') + - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings') } + = s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{link_start}How do I enable it?%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } = f.submit s_('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/application_settings/appearances/_form.html.haml index 872a6bef18b..a48b57bffd9 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/application_settings/appearances/_form.html.haml @@ -1,6 +1,6 @@ - parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe -= form_for @appearance, url: admin_appearances_path, html: { class: 'gl-mt-3' } do |f| += form_for @appearance, url: admin_application_settings_appearances_path, html: { class: 'gl-mt-3' } do |f| = form_errors(@appearance) @@ -16,7 +16,7 @@ = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to _('Remove header logo'), header_logos_admin_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" + = link_to _('Remove header logo'), header_logos_admin_application_settings_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :header_logo_cache = f.file_field :header_logo, class: "", accept: 'image/*' @@ -35,7 +35,7 @@ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to _('Remove favicon'), favicon_admin_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" + = link_to _('Remove favicon'), favicon_admin_application_settings_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :favicon_cache = f.file_field :favicon, class: '', accept: 'image/*' @@ -44,7 +44,7 @@ %br = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") - = render partial: 'admin/appearances/system_header_footer_form', locals: { form: f } + = render partial: 'admin/application_settings/appearances/system_header_footer_form', locals: { form: f } %hr .row @@ -67,7 +67,7 @@ = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - if @appearance.persisted? %br - = link_to _('Remove logo'), logo_admin_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" + = link_to _('Remove logo'), logo_admin_application_settings_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" %hr = f.hidden_field :logo_cache = f.file_field :logo, class: "", accept: 'image/*' @@ -106,7 +106,7 @@ .mt-4 - if @appearance.persisted? Preview last save: - = link_to _('Sign-in page'), preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to _('Sign-in page'), preview_sign_in_admin_application_settings_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' = link_to _('New project page'), new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - if @appearance.updated_at diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml index 4571d34a497..4571d34a497 100644 --- a/app/views/admin/appearances/_system_header_footer_form.html.haml +++ b/app/views/admin/application_settings/appearances/_system_header_footer_form.html.haml diff --git a/app/views/admin/appearances/preview_sign_in.html.haml b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml index a317611862c..77c37abbeef 100644 --- a/app/views/admin/appearances/preview_sign_in.html.haml +++ b/app/views/admin/application_settings/appearances/preview_sign_in.html.haml @@ -8,5 +8,5 @@ = label_tag :password = password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: _('This field is required.') .form-group - = button_tag _("Sign in"), class: "btn gl-button btn-confirm" + = button_tag _("Sign in"), class: "btn gl-button btn-confirm", type: "button" diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/application_settings/appearances/show.html.haml index 77a08913666..77a08913666 100644 --- a/app/views/admin/appearances/show.html.haml +++ b/app/views/admin/application_settings/appearances/show.html.haml diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 919f501d2ee..40486e9a9e6 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -8,7 +8,7 @@ %p = _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.') - = link_to s_('Learn more.'), help_page_path('ci/variables/README', anchor: 'instance-cicd-variables'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('Learn more.'), help_page_path('ci/variables/README', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' %p = _('Variables can be:') %ul diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index d38b4cba40a..127ab8ea1f4 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -19,7 +19,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Auto DevOps, runners and job artifacts') + = _('Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts.') .settings-content = render 'ci_cd' diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 217225e6186..0fbbef02613 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -31,7 +31,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Diff content limits') + = _('Set size limits for displaying diffs in the browser.') .settings-content = render 'diff_limits' @@ -88,7 +88,7 @@ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _('Manage Web IDE features') + = _('Manage Web IDE features.') .settings-content = form_for @application_setting, url: general_admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f| = form_errors(@application_setting) @@ -100,7 +100,8 @@ = f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do = s_('IDE|Live Preview') %span.form-text.text-muted - = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.') + - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/project/web_ide/index', anchor: 'enable-live-preview') } + = s_('Preview JavaScript projects in the Web IDE with CodeSandbox Live Preview. %{link_start}Learn more.%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } = f.submit _('Save changes'), class: "gl-button btn btn-confirm" = render_if_exists 'admin/application_settings/maintenance_mode_settings_form' diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index 93bc054754e..7a81d53c085 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -2,19 +2,8 @@ - page_title _('Integrations') - @content_class = 'limit-container-width' unless fluid_layout -- if show_admin_integrations_moved? - .gl-alert.gl-alert-info.js-admin-integrations-moved.mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::ADMIN_INTEGRATIONS_MOVED, dismiss_endpoint: user_callouts_path } } - = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', css_class: 'gl-icon') - .gl-alert-body - %h4.gl-alert-title= s_('AdminSettings|Some settings have moved') - = html_escape_once(s_('AdminSettings|PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.')).html_safe - .gl-alert-actions - = link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info gl-button' - %h3= s_('Integrations|Project integration management') - integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path } -%p= s_('Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}.').html_safe % { integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe } +%p= s_("Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe } = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/admin/background_migrations/_migration.html.haml b/app/views/admin/background_migrations/_migration.html.haml new file mode 100644 index 00000000000..40860ea9400 --- /dev/null +++ b/app/views/admin/background_migrations/_migration.html.haml @@ -0,0 +1,10 @@ +%tr{ role: 'row' } + %td{ role: 'cell', data: { label: _('Migration') } }= migration.job_class_name + ': ' + migration.table_name + %td{ role: 'cell', data: { label: _('Progress') } } + - progress = batched_migration_progress(migration, @successful_rows_counts[migration.id]) + - if progress + = number_to_percentage(progress, precision: 2) + - else + = _('Unknown') + %td{ role: 'cell', data: { label: _('Status') } } + %span.badge.badge-pill.gl-badge.sm{ class: batched_migration_status_badge_class_name(migration) }= migration.status.humanize diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml new file mode 100644 index 00000000000..2a372c89912 --- /dev/null +++ b/app/views/admin/background_migrations/index.html.haml @@ -0,0 +1,35 @@ +- page_title _('Background Migrations') + +.tabs.gl-tabs + %div + %ul.nav.gl-tabs-nav{ role: 'tablist' } + - active_tab_classes = ['gl-tab-nav-item-active', 'gl-tab-nav-item-active-indigo'] + + %li.nav-item{ role: 'presentation' } + %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path, class: (active_tab_classes if @current_tab == 'queued'), role: 'tab' } + = _('Queued') + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm + = limited_counter_with_delimiter(@relations_by_tab['queued']) + %li.nav-item{ role: 'presentation' } + %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'failed'), class: (active_tab_classes if @current_tab == 'failed'), role: 'tab' } + = _('Failed') + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm + = limited_counter_with_delimiter(@relations_by_tab['failed']) + %li.nav-item{ role: 'presentation' } + %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'finished'), class: (active_tab_classes if @current_tab == 'finished'), role: 'tab' } + = _('Finished') + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm + = limited_counter_with_delimiter(@relations_by_tab['finished']) + + .tab-content.gl-tab-content + .tab-pane.active{ role: 'tabpanel' } + %table.table.b-table.gl-table.b-table-stacked-md{ role: 'table' } + %thead{ role: 'rowgroup' } + %tr{ role: 'row' } + %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Migration') + %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Progress') + %th.table-th-transparent.border-bottom{ role: 'cell' }= _('Status') + %tbody{ role: 'rowgroup' } + = render partial: 'migration', collection: @migrations + + = paginate_collection @migrations diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index fe5759ecdbf..b68c22b6942 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,10 +1,12 @@ .broadcast-message.broadcast-banner-message.gl-alert-warning.js-broadcast-banner-message-preview.gl-mt-3{ style: broadcast_message_style(@broadcast_message), class: ('gl-display-none' unless @broadcast_message.banner? ) } - = sprite_icon('bullhorn', css_class: 'vertical-align-text-top') - .js-broadcast-message-preview - - if @broadcast_message.message.present? - = render_broadcast_message(@broadcast_message) - - else - = _('Your message here') + .gl-alert-container + = sprite_icon('bullhorn', css_class: 'vertical-align-text-top') + .js-broadcast-message-preview + .gl-alert-content + - if @broadcast_message.message.present? + = render_broadcast_message(@broadcast_message) + - else + = _('Your message here') .d-flex.justify-content-center .broadcast-message.broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } = sprite_icon('bullhorn', css_class: 'vertical-align-text-top') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 2dbb804d537..58c65bdc8c7 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -9,6 +9,9 @@ dismissible: true.to_s } } = notice[:message].html_safe +- if Gitlab.ee? && display_upcoming_reconciliation_alert? + #js-qrtly-reconciliation-alert{ data: upcoming_reconciliation_hash } + - if @license.present? .license-panel.gl-mt-5 = render_if_exists 'admin/licenses/summary' diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 84a9b988d22..e7e0e58f6fb 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -27,10 +27,12 @@ - if @group.new_record? .form-group.row .offset-sm-2.col-sm-10 - .gl-alert.gl-alert-info - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - = render 'shared/group_tips' + .gl-alert.gl-alert- + .gl-alert-container + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + = render 'shared/group_tips' .form-actions = f.submit _('Create group'), class: "gl-button btn btn-confirm" = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel" diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index c3e4626c14e..da2fcd5a4a6 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -1,7 +1,7 @@ -# Note: This file should stay aligned with: -# `app/views/groups/runners/_runner.html.haml` -.gl-responsive-table-row{ id: dom_id(runner) } +.gl-responsive-table-row{ data: { testid: "runner-row-#{runner.id}" } } .table-section.section-10.section-wrap .table-mobile-header{ role: 'rowheader' }= _('Type') .table-mobile-content diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 359e5b411b1..07fbc3e5398 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -1,87 +1,97 @@ - breadcrumb_title _('Runners') - page_title _('Runners') -.row - .col-sm-6 - .bs-callout - %p - = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") - %br - = _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.') - %br +- if Feature.enabled?(:runner_list_view_vue_ui, current_user, default_enabled: :yaml) + #js-runner-list{ data: { registration_token: Gitlab::CurrentSettings.runners_registration_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', active_runners_count: @active_runners_count } } +- else + .row + .col-sm-6 + .bs-callout + %p + = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") + %br + = _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.') + %br - %div - %span= _('Runners can be:') - %ul - %li - %span.badge.badge-pill.gl-badge.sm.badge-success shared - \- - = _('Runs jobs from all unassigned projects.') - %li - %span.badge.badge-pill.gl-badge.sm.badge-success group - \- - = _('Runs jobs from all unassigned projects in its group.') - %li - %span.badge.badge-pill.gl-badge.sm.badge-info specific - \- - = _('Runs jobs from assigned projects.') - %li - %span.badge.badge-pill.gl-badge.sm.badge-warning locked - \- - = _('Cannot be assigned to other projects.') - %li - %span.badge.badge-pill.gl-badge.sm.badge-danger paused - \- - = _('Not available to run jobs.') + %div + %span= _('Runners can be:') + %ul + %li + %span.badge.badge-pill.gl-badge.sm.badge-success shared + \- + = _('Runs jobs from all unassigned projects.') + %li + %span.badge.badge-pill.gl-badge.sm.badge-success group + \- + = _('Runs jobs from all unassigned projects in its group.') + %li + %span.badge.badge-pill.gl-badge.sm.badge-info specific + \- + = _('Runs jobs from assigned projects.') + %li + %span.badge.badge-pill.gl-badge.sm.badge-warning locked + \- + = _('Cannot be assigned to other projects.') + %li + %span.badge.badge-pill.gl-badge.sm.badge-danger paused + \- + = _('Not available to run jobs.') - .col-sm-6 - .bs-callout - = render partial: 'ci/runner/how_to_setup_runner', - locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token, - type: 'shared', - reset_token_url: reset_registration_token_admin_application_settings_path, - project_path: '', - group_path: '' } + .col-sm-6 + .bs-callout + = render partial: 'ci/runner/how_to_setup_runner', + locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token, + type: 'shared', + reset_token_url: reset_registration_token_admin_application_settings_path, + project_path: '', + group_path: '' } -.row - .col-sm-9 - = form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do - .filtered-search-wrapper.d-flex - .filtered-search-box - = dropdown_tag(_('Recent searches'), - options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', - toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button', - dropdown_class: 'filtered-search-history-dropdown', - content_class: 'filtered-search-history-dropdown-content' }) do - .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } - .filtered-search-box-input-container.droplab-dropdown - .scroll-container - %ul.tokens-container.list-unstyled - %li.input-token - %input.form-control.filtered-search{ search_filter_input_options('runners') } - #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } - = button_tag class: %w[gl-button btn btn-link] do - -# Encapsulate static class name `{{icon}}` inside #{} to bypass - -# haml lint's ClassAttributeWithStaticValue - %svg - %use{ 'xlink:href': "#{'{{icon}}'}" } - %span.js-filter-hint - {{formattedKey}} - #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } - %li.filter-dropdown-item{ data: { value: "{{ title }}" } } - %button.gl-button.btn.btn-link{ type: 'button' } - {{ title }} - %span.btn-helptext - {{ help }} - #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - - Ci::Runner::AVAILABLE_STATUSES.each do |status| - %li.filter-dropdown-item{ data: { value: status } } + .row + .col-sm-9 + = form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do + .filtered-search-wrapper.d-flex + .filtered-search-box + = dropdown_tag(_('Recent searches'), + options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', + toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button', + dropdown_class: 'filtered-search-history-dropdown', + content_class: 'filtered-search-history-dropdown-content' }) do + .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } + .filtered-search-box-input-container.droplab-dropdown + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ search_filter_input_options('runners') } + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } = button_tag class: %w[gl-button btn btn-link] do - = status.titleize + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %svg + %use{ 'xlink:href': "#{'{{icon}}'}" } + %span.js-filter-hint + {{formattedKey}} + #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } + %li.filter-dropdown-item{ data: { value: "{{ title }}" } } + %button.gl-button.btn.btn-link{ type: 'button' } + {{ title }} + %span.btn-helptext + {{ help }} + #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + - Ci::Runner::AVAILABLE_STATUSES.each do |status| + %li.filter-dropdown-item{ data: { value: status } } + = button_tag class: %w[gl-button btn btn-link] do + = status.titleize + + #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| + %li.filter-dropdown-item{ data: { value: runner_type } } + = button_tag class: %w[gl-button btn btn-link] do + = runner_type.titleize #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } @@ -90,49 +100,41 @@ = button_tag class: %w[gl-button btn btn-link] do = runner_type.titleize - #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| - %li.filter-dropdown-item{ data: { value: runner_type } } - = button_tag class: %w[gl-button btn btn-link] do - = runner_type.titleize - - #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.gl-button.btn.btn-link - = _('No Tag') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.gl-button.btn.btn-link.js-data-value - %span.dropdown-light-content - {{name}} - - = button_tag class: %w[clear-search hidden] do - = sprite_icon('close', size: 16, css_class: 'clear-search-icon') - .filter-dropdown-container - = render 'sort_dropdown' + #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.gl-button.btn.btn-link + = _('No Tag') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.gl-button.btn.btn-link.js-data-value + %span.dropdown-light-content + {{name}} - .col-sm-3.text-right-lg - = _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count } + = button_tag class: %w[clear-search hidden] do + = sprite_icon('close', size: 16, css_class: 'clear-search-icon') + .filter-dropdown-container + = render 'sort_dropdown' -- if @runners.any? - .content-list{ data: { testid: 'runners-table' } } - .table-holder - .gl-responsive-table-row.table-row-header{ role: 'row' } - .table-section.section-10{ role: 'rowheader' }= _('Type/State') - .table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner') - .table-section.section-10{ role: 'rowheader' }= _('Version') - .table-section.section-10{ role: 'rowheader' }= _('IP Address') - .table-section.section-5{ role: 'rowheader' }= _('Projects') - .table-section.section-5{ role: 'rowheader' }= _('Jobs') - .table-section.section-10{ role: 'rowheader' }= _('Tags') - .table-section.section-10{ role: 'rowheader' }= _('Last contact') - .table-section.section-10{ role: 'rowheader' } + .col-sm-3.text-right-lg + = _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count } + - if @runners.any? + .content-list{ data: { testid: 'runners-table' } } + .table-holder + .gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-10{ role: 'rowheader' }= _('Type/State') + .table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner') + .table-section.section-10{ role: 'rowheader' }= _('Version') + .table-section.section-10{ role: 'rowheader' }= _('IP Address') + .table-section.section-5{ role: 'rowheader' }= _('Projects') + .table-section.section-5{ role: 'rowheader' }= _('Jobs') + .table-section.section-10{ role: 'rowheader' }= _('Tags') + .table-section.section-10{ role: 'rowheader' }= _('Last contact') + .table-section.section-10{ role: 'rowheader' } - - @runners.each do |runner| - = render 'admin/runners/runner', runner: runner - = paginate @runners, theme: 'gitlab' -- else - .nothing-here-block= _('No runners found') + - @runners.each do |runner| + = render 'admin/runners/runner', runner: runner + = paginate @runners, theme: 'gitlab' + - else + .nothing-here-block= _('No runners found') diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index d911f35d946..d03a782756b 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -10,11 +10,9 @@ %h2.page-title = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) = render 'shared/runners/runner_type_badge', runner: @runner - -= render 'shared/runners/runner_type_alert', runner: @runner - -.gl-mb-6 - = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com? + = render 'shared/runners/runner_type_alert', runner: @runner + .gl-mb-6 + = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com? .row .col-md-6 diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml index 85f2260163a..a3e1ccc5d4a 100644 --- a/app/views/admin/serverless/domains/_form.html.haml +++ b/app/views/admin/serverless/domains/_form.html.haml @@ -79,7 +79,7 @@ .modal-header %h3.page-title= _('Delete serverless domain?') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body - if domain_attached diff --git a/app/views/admin/services/_service_templates_deprecated_alert.html.haml b/app/views/admin/services/_service_templates_deprecated_alert.html.haml index 0cc44099049..eac2f9c7f4e 100644 --- a/app/views/admin/services/_service_templates_deprecated_alert.html.haml +++ b/app/views/admin/services/_service_templates_deprecated_alert.html.haml @@ -2,7 +2,9 @@ - settings_link_start = "<a href=\"#{integrations_admin_application_settings_path}\">".html_safe .gl-alert.gl-alert-danger.gl-mt-5{ role: 'alert' } - = sprite_icon('error', css_class: 'gl-alert-icon gl-alert-icon-no-title') - %h4.gl-alert-title= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.') - .gl-alert-body - = html_escape_once(s_("AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings > Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}.")).html_safe % { settings_link_start: settings_link_start, doc_link_start: doc_link_start, link_end: '</a>'.html_safe } + .gl-alert-container + = sprite_icon('error', css_class: 'gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + %h4.gl-alert-title= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.') + .gl-alert-body + = html_escape_once(s_("AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings > Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}.")).html_safe % { settings_link_start: settings_link_start, doc_link_start: doc_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 573580bc5c5..aeb274fe2cb 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -48,3 +48,17 @@ %row.hidden#warning_external_automatically_set.hidden .badge.badge-warning.text-white = s_('AdminUsers|Automatically marked as default internal user') + + .form-group.row + - @user.credit_card_validation || @user.build_credit_card_validation + = f.fields_for :credit_card_validation do |ff| + .col-sm-2.col-form-label.gl-pt-0 + = ff.label s_("AdminUsers|Validate user account") + .col-sm-10.gl-display-flex.gl-align-items-baseline + = ff.check_box :credit_card_validated_at, checked: @user.credit_card_validated_at.present? + .gl-pl-2 + .light + = s_('AdminUsers|User is validated and can use free CI minutes on shared runners.') + .gl-text-gray-600 + = s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user.') + diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml index f6e7cefafe7..0890990f476 100644 --- a/app/views/admin/users/_modals.html.haml +++ b/app/views/admin/users/_modals.html.haml @@ -1,5 +1,5 @@ #js-delete-user-modal -#js-modal-texts.hidden{ "hidden": true, "aria-hidden": true } +#js-modal-texts.hidden{ "hidden": true, "aria-hidden": "true" } %div{ data: { modal: "delete", title: s_("AdminUsers|Delete User %{username}?"), action: s_('AdminUsers|Delete user'), diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml deleted file mode 100644 index 2816a1061b9..00000000000 --- a/app/views/admin/users/_user.html.haml +++ /dev/null @@ -1,72 +0,0 @@ -.gl-responsive-table-row{ role: 'row', data: { qa_selector: 'user_row_content' } } - .table-section.section-40 - .table-mobile-header{ role: 'rowheader' } - = _('Name') - .table-mobile-content - = render 'user_detail', user: user - .table-section.section-10 - .table-mobile-header{ role: 'rowheader' } - = _('Projects') - .table-mobile-content.gl-str-truncated{ data: { testid: "user-project-count-#{user.id}" } } - = user.authorized_projects.length - .table-section.section-15 - .table-mobile-header{ role: 'rowheader' } - = _('Created on') - .table-mobile-content - = l(user.created_at.to_date, format: :admin) - .table-section.section-15 - .table-mobile-header{ role: 'rowheader' } - = _('Last activity') - .table-mobile-content - = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin) - - unless user.internal? - .table-section.section-20.table-button-footer - .table-action-buttons{ data: { testid: "user-actions-#{user.id}" } } - = link_to _('Edit'), edit_admin_user_path(user), class: 'btn gl-button btn-default' - - unless user == current_user - %button.dropdown-new.btn.gl-button.btn-default{ type: 'button', data: { testid: "dropdown-toggle", toggle: 'dropdown' } } - = sprite_icon('settings') - = sprite_icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right - %li.dropdown-header - = _('Settings') - %li - - if user.ldap_blocked? - %span.small - = s_('AdminUsers|Cannot unblock LDAP blocked users') - - elsif user.blocked? - - if user.blocked_pending_approval? - = link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put - = link_to s_('AdminUsers|Reject'), reject_admin_user_path(user), method: :delete - - else - %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) } - = s_('AdminUsers|Unblock') - - else - %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) } - = s_('AdminUsers|Block') - - if user.can_be_deactivated? - %li - %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_deactivation_data(user, user_deactivation_effects) } - = s_('AdminUsers|Deactivate') - - elsif user.deactivated? - %li - %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_activation_data(user) } - = s_('AdminUsers|Activate') - - if user.access_locked? - %li - = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } - - if can?(current_user, :destroy_user, user) && !user.blocked_pending_approval? - %li.divider - - if user.can_be_removed? - %li - %button.js-delete-user-modal-button.gl-button.btn.btn-danger-tertiary{ data: { 'gl-modal-action': 'delete', - delete_user_url: admin_user_path(user), - block_user_url: block_admin_user_path(user), - username: sanitize_name(user.name) } } - = s_('AdminUsers|Delete user') - %li - %button.js-delete-user-modal-button.gl-button.btn.btn-danger-tertiary{ data: { 'gl-modal-action': 'delete-with-contributions', - delete_user_url: admin_user_path(user, hard_delete: true), - block_user_url: block_admin_user_path(user), - username: sanitize_name(user.name) } } - = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index e4438f38a47..1a43d91b800 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -73,20 +73,9 @@ = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do = title -- if Feature.enabled?(:vue_admin_users, default_enabled: :yaml) - #js-admin-users-app{ data: admin_users_data_attributes(@users) } - .gl-spinner-container.gl-my-7 - %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } -- elsif @users.empty? - .nothing-here-block.border-top-0 - = s_('AdminUsers|No users found') -- else - .table-holder - .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } - - user_table_headers.each do |header| - .table-section{ class: header[:section_class_name], role: 'rowheader' }= header[:header_text] - - = render partial: 'admin/users/user', collection: @users +#js-admin-users-app{ data: admin_users_data_attributes(@users) } + .gl-spinner-container.gl-my-7 + %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } = paginate_collection @users diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 19cc29668f5..08c1e089f21 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -59,6 +59,7 @@ = _('Disabled') = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace + = render_if_exists 'admin/users/credit_card_info', user: @user %li %span.light= _('External User:') diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index 03a3c9b0de8..cddea17efbf 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -14,7 +14,7 @@ %br = _("And this registration token:") %br - %code#registration_token= registration_token + %code#registration_token{ data: {testid: 'registration_token' } }= registration_token = clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard") .gl-mt-3.gl-mb-3 diff --git a/app/views/ci/runner/_setup_runner_in_aws.html.haml b/app/views/ci/runner/_setup_runner_in_aws.html.haml new file mode 100644 index 00000000000..b0a5b40f2ad --- /dev/null +++ b/app/views/ci/runner/_setup_runner_in_aws.html.haml @@ -0,0 +1,16 @@ +%h5= _('Use GitLab Runner in AWS') + +%p + = _('Use an AWS CloudFormation Template (CFT) to install and configure GitLab Runner in AWS.') + +%ol + %li + = _('Copy this registration token.') + %br + %code#registration_token{ data: { testid: 'registration_token' } }= registration_token + = clipboard_button(target: '#registration_token', title: _('Copy token'), class: 'btn-transparent btn-clipboard') + %li + = _('Choose the preferred Runner and populate the AWS CFT.') + = link_to _('Learn more.'), 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg', target: '_blank', rel: 'noopener noreferrer' + +#js-runner-aws-deployments diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 7f508fd0a59..c84b3a923ca 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -16,7 +16,7 @@ .sub-section.form-group = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| %h4 - = s_('ClusterIntegration|Cluster management project (alpha)') + = s_('ClusterIntegration|Cluster management project') %p = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml index 025f52d8771..75609465eb3 100644 --- a/app/views/clusters/clusters/_health.html.haml +++ b/app/views/clusters/clusters/_health.html.haml @@ -1,5 +1,5 @@ %section.settings.no-animate.expanded.cluster-health-graphs.qa-cluster-health-section#cluster-health - - if @cluster&.application_prometheus_available? + - if @cluster&.integration_prometheus_available? #prometheus-graphs{ data: @cluster.health_data(clusterable) } - else diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index ee2817879be..73a09f00fd6 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -62,13 +62,12 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } - - if Feature.enabled?(:create_cloud_run_clusters, clusterable, default_enabled: true) - .form-group - = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run for Anthos'), - label_class: 'label-bold' } - .form-text.text-muted - = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank' + .form-group + = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run for Anthos'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank' .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 001ca80dbd6..7336b9fe86b 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -17,7 +17,6 @@ install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative), update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative), install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack), - install_fluentd_path: clusterable.install_applications_cluster_path(@cluster, :fluentd), cluster_environments_path: cluster_environments_path, toggle_status: @cluster.enabled? ? 'true': 'false', has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false', @@ -26,11 +25,7 @@ cluster_status_reason: @cluster.status_reason, provider_type: @cluster.provider_type, pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false', - help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), - helm_help_path: help_page_path('user/clusters/applications.md', anchor: 'helm'), - ingress_help_path: help_page_path('user/clusters/applications.md', anchor: 'determining-the-external-endpoint-automatically'), - ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'), - ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), + help_path: help_page_path('user/project/clusters/index.md'), environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), @@ -52,13 +47,20 @@ = render 'banner' + .gl-alert.gl-alert-warning{ role: 'alert' } + = sprite_icon('warning', css_class: "gl-alert-icon gl-alert-icon-no-title gl-icon") + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss'), data: { testid: 'dismiss-one-click-application-removal' } } + = sprite_icon('close', css_class: 'gl-icon') + .gl-alert-body + = s_('ClusterApplicationsRemoved|One-click application management was removed in GitLab 14.0. Your applications are still installed in your cluster, and integrations continue working.') + = link_to _('More information.'), help_page_path("user/clusters/applications"), target: '_blank' + - if cluster_created?(@cluster) .js-toggle-container %ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' } = render 'details_tab' = render_if_exists 'clusters/clusters/environments_tab' = render 'clusters/clusters/health_tab' - = render 'applications_tab' = render 'integrations_tab' = render 'advanced_settings_tab' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index e7d8171d276..2c6c721a51c 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -3,7 +3,7 @@ .todo-avatar.gl-display-none.gl-sm-display-inline-block = author_avatar(todo, size: 40) - .todo-item.gl-w-full.gl-align-self-center{ data: { qa_selector: "todo_item_container" } } + .todo-item.flex-fill.gl-overflow-hidden.gl-overflow-x-auto.gl-align-self-center{ data: { qa_selector: "todo_item_container" } } .todo-title.gl-mb-3.gl-md-mb-0 - if todo_author_display?(todo) = todo_target_state_pill(todo) diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 52e41946ed1..ca10861115b 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -37,7 +37,7 @@ .todos-filters .issues-details-filters.row-content-block.second-block = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row' do - .filter-categories.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-flex-fill-1.gl-flex-wrap.gl-mx-n2 + .filter-categories.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-flex-grow-1.gl-flex-wrap.gl-mx-n2 .filter-item.gl-m-2 - if params[:group_id].present? = hidden_field_tag(:group_id, params[:group_id]) diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index 684af933f3a..037b2f247c1 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -1,8 +1,10 @@ +- user_email = "(#{params[:email]})" if params[:email].present? + .well-confirmation.gl-text-center.gl-mb-6 %h1.gl-mt-0 = _("Almost there...") %p.lead.gl-mb-6 - = _("Please check your email to confirm your account") + = _('Please check your email %{email} to confirm your account') % { email: user_email } %hr - if Gitlab::CurrentSettings.after_sign_up_text.present? .well-confirmation.gl-text-center @@ -10,5 +12,5 @@ %p.text-center = _("No confirmation email received? Please check your spam folder or") .gl-mb-6.prepend-top-20.gl-text-center - %a.btn.gl-button.btn-confirm{ href: new_user_confirmation_path } + %a.gl-link{ href: new_user_confirmation_path } = _("Request new confirmation email") diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml index ef876779ad6..7f6ce712af2 100644 --- a/app/views/devise/passwords/new.html.haml +++ b/app/views/devise/passwords/new.html.haml @@ -6,6 +6,8 @@ .form-group = f.label :email = f.email_field :email, class: "form-control gl-form-input", required: true, value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.') + .form-text.text-muted + = _('Requires your primary GitLab email address.') .clearfix = f.submit _("Reset password"), class: "gl-button btn-confirm btn" diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 00429f1acbc..4ec3fcde337 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -8,6 +8,5 @@ = render 'devise/shared/signup_box', url: registration_path(resource_name), button_text: _('Register'), - show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled?, - suggestion_path: nil + show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 1b410f0b671..09b7f247450 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,6 +1,4 @@ - max_first_name_length = max_last_name_length = 127 -- max_username_length = 255 -- min_username_length = 2 - omniauth_providers_placement ||= :bottom .gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base @@ -11,26 +9,53 @@ .devise-errors = render 'devise/shared/error_messages', resource: resource - if Gitlab::CurrentSettings.invisible_captcha_enabled - = invisible_captcha nonce: true + = invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12) .name.form-row .col.form-group = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold' - = f.text_field :first_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, :qa_selector => 'new_user_first_name_field' }, required: true, title: _('This field is required.') + = f.text_field :first_name, + class: 'form-control gl-form-input top js-block-emoji js-validate-length', + data: { max_length: max_first_name_length, + max_length_message: s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, + qa_selector: 'new_user_first_name_field' }, + required: true, + title: _('This field is required.') .col.form-group = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold' - = f.text_field :last_name, class: 'form-control gl-form-input top js-block-emoji js-validate-length', :data => { :max_length => max_last_name_length, :max_length_message => s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, :qa_selector => 'new_user_last_name_field' }, required: true, title: _('This field is required.') + = f.text_field :last_name, + class: 'form-control gl-form-input top js-block-emoji js-validate-length', + data: { max_length: max_last_name_length, + max_length_message: s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, + qa_selector: 'new_user_last_name_field' }, + required: true, + title: _('This field is required.') .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', :data => { :api_path => suggestion_path, :min_length => min_username_length, :min_length_message => s_('SignUp|Username is too short (minimum is %{min_length} characters).') % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_('SignUp|Username is too long (maximum is %{max_length} characters).') % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _('Please create a username with only alphanumeric characters.') + = f.text_field :username, + class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', + data: signup_username_data_attributes, + pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, + required: true, + title: _('Please create a username with only alphanumeric characters.') %p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.') %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') .form-group = f.label :email, class: 'label-bold' - = f.email_field :email, value: @invite_email, class: 'form-control gl-form-input middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.') + = f.email_field :email, + value: @invite_email, + class: 'form-control gl-form-input middle', + data: { qa_selector: 'new_user_email_field' }, + required: true, + title: _('Please provide a valid email address.') .form-group.gl-mb-5#password-strength = f.label :password, class: 'label-bold' - = f.password_field :password, class: 'form-control gl-form-input bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } + = f.password_field :password, + class: 'form-control gl-form-input bottom', + data: { qa_selector: 'new_user_password_field' }, + required: true, + pattern: ".{#{@minimum_password_length},}", + title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } %p.gl-field-hint.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } %div - if show_recaptcha_sign_up? diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index 17bf43a4590..c429bbbb610 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -3,10 +3,11 @@ return unless event.visible_to_user?(current_user) event = event.present +event_url = event_feed_url(event) xml.entry do xml.id "tag:#{request.host},#{event.created_at.strftime("%Y-%m-%d")}:#{event.id}" - xml.link href: event_feed_url(event) + xml.link href: event_url if event_url xml.title truncate(event_feed_title(event), length: 80) xml.updated event.updated_at.xmlschema diff --git a/app/views/events/_event_push.atom.haml b/app/views/events/_event_push.atom.haml index c5b033b1185..c3786d7c16d 100644 --- a/app/views/events/_event_push.atom.haml +++ b/app/views/events/_event_push.atom.haml @@ -1,7 +1,9 @@ +- event_url = event_feed_url(event) + %div{ xmlns: "http://www.w3.org/1999/xhtml" } %p %strong= event.author_name - = link_to "(#{truncate_sha(event.commit_id)})", event_feed_url(event) + = link_to "(#{truncate_sha(event.commit_id)})", event_url if event_url %i at = event.created_at.to_s(:short) diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index 69ed94e99cc..f4f3c8ce8f7 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -4,4 +4,4 @@ is_project: 'false', access_levels: GroupMember.access_level_roles.to_json, default_access_level: Gitlab::Access::GUEST, - help_link: help_page_url('user/permissions') } } + help_link: help_page_url('user/permissions') }.merge(group_select_data(group)) } diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index fbf9438718e..49c8c2700ce 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -22,6 +22,6 @@ .col-sm-4 = recaptcha_tags nonce: content_security_policy_nonce .row - .form-actions.col-sm-12 + .col-sm-12 = f.submit _('Create group'), class: "btn gl-button btn-confirm" = link_to _('Cancel'), dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel' diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index 2ecf92e0769..5cf4ff4bc26 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -24,5 +24,7 @@ = render 'groups/dependency_proxies/url' - else .gl-alert.gl-alert-info - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - = _('Dependency proxy feature is limited to public groups for now.') + .gl-alert-container + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + = _('Dependency proxy feature is limited to public groups for now.') diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 45488791272..c5b8c5e25a3 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,8 +1,6 @@ - add_page_specific_style 'page_bundles/members' - page_title _('Group members') -- show_invited_members = can_manage_members? && @invited_members.load.any? -- show_access_requests = can_manage_members? && @requesters.load.any? -- invited_active = params[:search_invited].present? || params[:invited_members_page].present? +- groups_select_tag_data = group_select_data(@group).merge({ skip_groups: @skip_groups }) .js-remove-member-modal .row.gl-mt-3 @@ -18,7 +16,10 @@ .gl-w-half.gl-xs-w-full .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3 .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } - .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } + .js-invite-members-trigger{ data: { variant: 'success', + classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', + trigger_source: 'group-members-page', + display_text: _('Invite members') } } = render 'groups/invite_members_modal', group: @group - if can_manage_members? && Feature.disabled?(:invite_members_group_modal, @group) %hr.gl-mt-4 @@ -31,51 +32,13 @@ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } = render_invite_member_for_group(@group, @group_member.access_level) .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } - = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access' + = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access', groups_select_tag_data: groups_select_tag_data = render_if_exists 'groups/group_members/ldap_sync' - %ul.nav-links.mobile-separator.nav.nav-tabs - %li.nav-item - = link_to '#tab-members', class: ['nav-link', ('active' unless invited_active)], data: { toggle: 'tab' } do - %span - = _('Members') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @members.total_count - - if @group.shared_with_group_links.present? - %li.nav-item - = link_to '#tab-groups', class: ['nav-link'] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do - %span - = _('Groups') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @group.shared_with_group_links.count - - if show_invited_members - %li.nav-item - = link_to '#tab-invited-members', class: ['nav-link', ('active' if invited_active)], data: { toggle: 'tab' } do - %span - = _('Invited') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @invited_members.total_count - - if show_access_requests - %li.nav-item - = link_to '#tab-access-requests', class: 'nav-link', data: { toggle: 'tab' } do - %span - = _('Access requests') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count - .tab-content - #tab-members.tab-pane{ class: ('active' unless invited_active) } - .js-group-members-list{ data: { members_data: group_members_list_data_json(@group, @members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }) } } - .loading - .gl-spinner.gl-spinner-md - - if @group.shared_with_group_links.present? - #tab-groups.tab-pane - .js-group-group-links-list{ data: { members_data: group_group_links_list_data_json(@group) } } - .loading - .gl-spinner.gl-spinner-md - - if show_invited_members - #tab-invited-members.tab-pane{ class: ('active' if invited_active) } - .js-group-invited-members-list{ data: { members_data: group_members_list_data_json(@group, @invited_members, { param_name: :invited_members_page, params: { page: nil } }) } } - .loading - .gl-spinner.gl-spinner-md - - if show_access_requests - #tab-access-requests.tab-pane - .js-group-access-requests-list{ data: { members_data: group_members_list_data_json(@group, @requesters) } } - .loading - .gl-spinner.gl-spinner-md + .js-group-members-list-app{ data: { members_data: group_members_app_data_json(@group, + members: @members, + invited: @invited_members, + access_requests: @requesters) } } + .loading + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 920a6ccd9ec..11927142ea6 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -2,47 +2,24 @@ - @hide_top_links = true - page_title _('New Group') - header_title _("Groups"), dashboard_groups_path -- active_tab = local_assigns.fetch(:active_tab, 'create') +- add_page_specific_style 'page_bundles/new_namespace' -.group-edit-container.gl-mt-3 - .row - .col-lg-3.group-settings-sidebar - %h4.prepend-top-0 - = _('New group') - %p - - group_docs_path = help_page_path('user/group/index') - - group_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_docs_path } - = s_('%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.').html_safe % { group_docs_link_start: group_docs_link_start, group_docs_link_end: '</a>'.html_safe } - %p - - subgroup_docs_path = help_page_path('user/group/subgroups/index') - - subgroup_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: subgroup_docs_path } - = s_('Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}.').html_safe % { subgroup_docs_link_start: subgroup_docs_link_start, subgroup_docs_link_end: '</a>'.html_safe } - %p - = _('Projects that belong to a group are prefixed with the group namespace. Existing projects may be moved into a group.') +.group-edit-container.gl-mt-5 - .col-lg-9.js-toggle-container - %ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' } - %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: '#create-group-pane', id: 'create-group-tab', role: 'tab', data: { toggle: 'tab', track_label: 'create_group', track_event: 'click_tab', track_value: '' } } - %span.d-none.d-sm-block= s_('GroupsNew|Create group') - %span.d-block.d-sm-none= s_('GroupsNew|Create') - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: '#import-group-pane', id: 'import-group-tab', role: 'tab', data: { toggle: 'tab', track_label: 'import_group', track_event: 'click_tab', track_value: '' } } - %span.d-none.d-sm-block= s_('GroupsNew|Import group') - %span.d-block.d-sm-none= s_('GroupsNew|Import') + .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s } } - .tab-content.gitlab-tab-content.gl-border-none - .tab-pane.js-toggle-container{ id: 'create-group-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| - = render 'new_group_fields', f: f, group_name_id: 'create-group-name' + .row{ 'v-cloak': true } + #create-group-pane.tab-pane + = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| + = render 'new_group_fields', f: f, group_name_id: 'create-group-name' - .tab-pane.no-padding.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' } - - if import_sources_enabled? - - if Feature.enabled?(:bulk_import) - = render 'import_group_from_another_instance_panel' - .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1 - = render 'import_group_from_file_panel' - - else - .nothing-here-block - %h4= s_('GroupsNew|No import options available') - %p= s_('GroupsNew|Contact an administrator to enable options for importing your group.') + #import-group-pane.tab-pane + - if import_sources_enabled? + - if Feature.enabled?(:bulk_import) + = render 'import_group_from_another_instance_panel' + .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1 + = render 'import_group_from_file_panel' + - else + .nothing-here-block + %h4= s_('GroupsNew|No import options available') + %p= s_('GroupsNew|Contact an administrator to enable options for importing your group.') diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml index 910b36770f1..823d908c5e2 100644 --- a/app/views/groups/runners/_group_runners.html.haml +++ b/app/views/groups/runners/_group_runners.html.haml @@ -13,6 +13,10 @@ = render partial: 'ci/runner/how_to_setup_runner_automatically', locals: { type: 'group', clusters_path: group_clusters_path(@group) } + - if params[:ci_runner_templates] + %hr + = render partial: 'ci/runner/setup_runner_in_aws', + locals: { registration_token: @group.runners_token } %hr = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: @group.runners_token, diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index fcfe70bd694..d1f356ed665 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -7,13 +7,21 @@ .form-group = render 'shared/allow_request_access', form: f + - if @group.root? + .form-group.gl-mb-3 + .gl-form-checkbox.custom-control.custom-checkbox + = f.check_box :prevent_sharing_groups_outside_hierarchy, disabled: !can_change_prevent_sharing_groups_outside_hierarchy?(@group), class: 'custom-control-input' + = f.label :prevent_sharing_groups_outside_hierarchy, class: 'custom-control-label' do + %span + = s_('GroupSettings|Prevent members from sending invitations to groups outside of %{group} and its subgroups.').html_safe % { group: link_to_group(@group) } + %p.js-descr.help-text= prevent_sharing_groups_outside_hierarchy_help_text(@group) + .form-group.gl-mb-3 .gl-form-checkbox.custom-control.custom-checkbox = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'custom-control-input' = f.label :share_with_group_lock, class: 'custom-control-label' do %span - - group_link = link_to @group.name, group_path(@group) - = s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link } + = s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: link_to_group(@group) } %p.js-descr.help-text= share_with_group_lock_help_text(@group) .form-group.gl-mb-3 diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index c5cc3eb693c..b6f70879d17 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -8,6 +8,5 @@ = f.number_field :max_artifacts_size, class: 'form-control' %p.form-text.text-muted = _("The maximum file size in megabytes for individual job artifacts.") - = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank' - + = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' = f.submit _('Save changes'), class: "btn gl-button btn-confirm" diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml index 92b545cad0a..7a81d53c085 100644 --- a/app/views/groups/settings/integrations/index.html.haml +++ b/app/views/groups/settings/integrations/index.html.haml @@ -5,5 +5,5 @@ %h3= s_('Integrations|Project integration management') - integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path } -%p= s_('Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}.').html_safe % { integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe } +%p= s_("Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe } = render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 9f7f0a08df5..628425bf463 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,6 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout - page_itemtype 'https://schema.org/Organization' - @skip_current_level_breadcrumb = true +- add_page_specific_style 'page_bundles/group' - if show_thanks_for_purchase_banner? = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml index 7e0ee032aeb..e0158e4bf22 100644 --- a/app/views/groups/sidebar/_packages.html.haml +++ b/app/views/groups/sidebar/_packages.html.haml @@ -2,7 +2,7 @@ - if group_packages_nav? = nav_link(controller: ['groups/packages', 'groups/registry/repositories', 'groups/dependency_proxies']) do - = link_to packages_link, title: _('Packages') do + = link_to packages_link, title: _('Packages'), class: 'has-sub-items' do .nav-icon-container = sprite_icon('package') %span.nav-item-name diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index 4cf08b1d2be..02a8f3142c6 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -1,10 +1,13 @@ - add_page_specific_style 'page_bundles/import' -- provider = local_assigns.fetch(:provider) +- provider = local_assigns.fetch(:provider).to_sym - extra_data = local_assigns.fetch(:extra_data, {}) - filterable = local_assigns.fetch(:filterable, true) - paginatable = local_assigns.fetch(:paginatable, false) - provider_title = Gitlab::ImportSources.title(provider) +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') + #import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, can_select_namespace: current_user.can_select_namespace?.to_s, ci_cd_only: has_ci_cd_only_params?.to_s, diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml index 8a3fe1a816c..ce6bdd7a2fb 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -1,7 +1,6 @@ -- title = _('Bitbucket Server Import') -- page_title title -- breadcrumb_title title -- header_title _("Projects"), root_path +- page_title _('Bitbucket Server Import') +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 7c4e6913c53..79b2810e06d 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -1,5 +1,4 @@ - page_title _('Bitbucket Server import') -- header_title _('Projects'), root_path %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml index ab836174024..51156797270 100644 --- a/app/views/import/fogbugz/new.html.haml +++ b/app/views/import/fogbugz/new.html.haml @@ -1,5 +1,7 @@ - page_title _("FogBugz Import") -- header_title _("Projects"), root_path +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') + %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center = sprite_icon('bug', css_class: 'gl-mr-2') diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 832289c3166..4281d77e833 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -1,5 +1,7 @@ - page_title _('User map'), _('FogBugz import') -- header_title _("Projects"), root_path +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') + %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center = sprite_icon('bug', css_class: 'gl-mr-2') diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index e04a412e3bc..dcc0e94441c 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -1,5 +1,4 @@ - page_title _("FogBugz import") -- header_title _("Projects"), root_path %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center = sprite_icon('bug', css_class: 'gl-mr-2') diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index 27786806d17..288ae5f1cec 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -1,5 +1,6 @@ - page_title _("Gitea Import") -- header_title _("Projects"), root_path +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') %h3.page-title = custom_icon('gitea_logo') diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml index ef0693e73c3..1bdcec0c574 100644 --- a/app/views/import/gitea/status.html.haml +++ b/app/views/import/gitea/status.html.haml @@ -1,5 +1,4 @@ - page_title _("Gitea Import") -- header_title _("Projects"), root_path %h3.page-title = custom_icon('gitea_logo') = _('Import Projects from Gitea') diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 32143f823d7..3407f9202bf 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -1,9 +1,9 @@ - title = _('Authenticate with GitHub') - page_title title -- breadcrumb_title title -- header_title _("Projects"), root_path +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') -%h2.page-title +%h3.page-title = title %p diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index b62f98f5ded..820c2f06c8f 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -1,7 +1,5 @@ - title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import') - page_title title -- breadcrumb_title title -- header_title _("Projects"), root_path %h3.page-title.mb-0.gl-display-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center = sprite_icon('github', css_class: 'gl-mr-2') diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index ef803a36e79..b7b1fae1b73 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -1,5 +1,4 @@ - page_title _("GitLab.com import") -- header_title _("Projects"), root_path %h3.page-title = sprite_icon('heart', css_class: 'gl-vertical-align-middle') = _('Import projects from GitLab.com') diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 8ba62c91e6a..8daddbb0042 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -1,5 +1,6 @@ - page_title _("GitLab Import") -- header_title _("Projects"), root_path +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml index 852f269f2ed..bfaff3bb300 100644 --- a/app/views/import/manifest/new.html.haml +++ b/app/views/import/manifest/new.html.haml @@ -1,5 +1,7 @@ - page_title _("Manifest file import") -- header_title _("Projects"), root_path +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') + %h3.page-title = _('Manifest file import') diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml index c3e77554b09..45d03575713 100644 --- a/app/views/import/manifest/status.html.haml +++ b/app/views/import/manifest/status.html.haml @@ -1,5 +1,4 @@ - page_title _("Manifest import") -- header_title _("Projects"), root_path %h3.page-title = _('Manifest file import') diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml index 960d3df2c42..9596fdb615a 100644 --- a/app/views/import/phabricator/new.html.haml +++ b/app/views/import/phabricator/new.html.haml @@ -1,7 +1,6 @@ -- title = _('Phabricator Server Import') -- page_title title -- breadcrumb_title title -- header_title _("Projects"), root_path +- page_title _('Phabricator Server Import') +- header_title _("New project"), new_project_path +- add_to_breadcrumbs s_('ProjectsNew|Import project'), new_projects_path(anchor: 'import_project') %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml index 32b4a39924b..badd8c1278f 100644 --- a/app/views/import/shared/_errors.html.haml +++ b/app/views/import/shared/_errors.html.haml @@ -1,6 +1,8 @@ - if @errors.present? .gl-alert.gl-alert-danger.gl-mb-5 - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - - @errors.each do |error| - = error + .gl-alert-container + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + - @errors.each do |error| + = error diff --git a/app/views/kaminari/gitlab/_keyset_paginator.html.haml b/app/views/kaminari/gitlab/_keyset_paginator.html.haml new file mode 100644 index 00000000000..f64c70dadfc --- /dev/null +++ b/app/views/kaminari/gitlab/_keyset_paginator.html.haml @@ -0,0 +1,30 @@ +- previous_path = url_for(page_params.merge(cursor: paginator.cursor_for_previous_page)) +- next_path = url_for(page_params.merge(cursor: paginator.cursor_for_next_page)) + +.gl-pagination.gl-mt-3 + %ul.pagination.justify-content-center + + - if paginator.has_previous_page? + - unless without_first_and_last_pages + %li.page-item + - first_page_path = url_for(page_params.merge(cursor: paginator.cursor_for_first_page)) + = link_to first_page_path, rel: 'first', class: 'page-link' do + = sprite_icon('angle-double-left', size: 8) + = s_('Pagination|First') + + %li.page-item.prev + = link_to previous_path, rel: 'prev', class: 'page-link' do + = sprite_icon('angle-left', size: 8) + = s_('Pagination|Prev') + + - if paginator.has_next_page? + %li.page-item.next + = link_to next_path, rel: 'next', class: 'page-link' do + = s_('Pagination|Next') + = sprite_icon('angle-right', size: 8) + - unless without_first_and_last_pages + %li.page-item + - last_page_path = url_for(page_params.merge(cursor: paginator.cursor_for_last_page)) + = link_to last_page_path, rel: 'last', class: 'page-link' do + = s_('Pagination|Last') + = sprite_icon('angle-double-right', size: 8) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index b28cd47efcc..683d3a6ad1b 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -36,7 +36,7 @@ = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' - = render 'layouts/startup_css' + = render 'layouts/startup_css', { startup_filename: local_assigns.fetch(:startup_filename, nil) } - if user_application_theme == 'gl-dark' = stylesheet_link_tag_defer "application_dark" = yield :page_specific_styles @@ -54,8 +54,6 @@ = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? - -# Rendering this above Gon, to use in JS later - = render 'layouts/header/new_repo_experiment' = Gon::Base.render_data(nonce: content_security_policy_nonce) = javascript_include_tag locale_path unless I18n.locale == :en @@ -73,7 +71,7 @@ = action_cable_meta_tag %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' } - %meta{ name: 'theme-color', content: '#474D57' } + %meta{ name: 'theme-color', content: user_theme_primary_color } -# Apple Safari/iOS home screen icons = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon' diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index c91d27e3ed1..2b63e2c647c 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,7 +1,7 @@ -.layout-page{ class: page_with_sidebar_class } +.layout-page.hide-when-top-nav-responsive-open{ class: page_with_sidebar_class } - if defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" - .content-wrapper{ class: "#{@content_wrapper_class}" } + .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } .mobile-overlay = yield :group_invite_members_banner .alert-wrapper.gl-force-block-formatting-context @@ -27,3 +27,5 @@ = render "layouts/flash", extra_flash_class: 'limit-container-width' = yield :before_content = yield + += render "layouts/nav/top_nav_responsive", class: 'layout-page content-wrapper-margin' diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 2032d1e95a6..e617b4358e3 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -1,5 +1,5 @@ .search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input", track_value: "" } } - = form_tag search_path, method: :get, class: 'form-inline' do |_f| + = form_tag search_path, method: :get, class: 'form-inline form-control' do |_f| .search-input-container .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml index 7d3cfe28007..67c871b95f5 100644 --- a/app/views/layouts/_startup_css.haml +++ b/app/views/layouts/_startup_css.haml @@ -1,4 +1,5 @@ -- startup_filename = current_path?("sessions#new") ? 'signin' : user_application_theme == 'gl-dark' ? 'dark' : 'general' +- startup_filename_default = user_application_theme == 'gl-dark' ? 'dark' : 'general' +- startup_filename = local_assigns.fetch(:startup_filename, nil) || startup_filename_default %style = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 58408ec822c..47c092e199a 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,10 +1,12 @@ - page_classes = page_class << @html_class - page_classes = page_classes.flatten.compact +- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list] +- body_classes << 'sidebar-refactoring' if sidebar_refactor_enabled? !!! 5 %html{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} #{client_class_list}", data: body_data } + %body{ class: body_classes, data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render 'peek/bar' diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index ef61a04c288..ae7c160c060 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,6 +1,6 @@ !!! 5 %html.devise-layout-html{ class: system_message_class } - = render "layouts/head" + = render "layouts/head", { startup_filename: 'signin' } %body.ui-indigo.login-page.application.navless{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } = header_message = render "layouts/init_client_detection_flags" diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index 63bb9f8fff5..2a865aeda40 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -6,11 +6,12 @@ = header_message = render partial: "layouts/header/default", locals: { project: @project, group: @group } .mobile-overlay - .alert-wrapper + .alert-wrapper.hide-when-top-nav-responsive-open = render 'shared/outdated_browser' = render "layouts/broadcast" = yield :flash_message = render "layouts/flash" - .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch mt-0" } + .content-wrapper.hide-when-top-nav-responsive-open{ id: "content-body", class: "d-flex flex-column align-items-stretch" } = yield + = render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto" = footer_message diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ae333cffb84..87580e57e75 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,11 +1,12 @@ - has_impersonation_link = header_link?(:admin_impersonation) - user_status_data = user_status_properties(current_user) +- use_top_nav_redesign = Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml) %header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } } %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content .container-fluid .header-content - .title-container + .title-container.hide-when-menu-expanded %h1.title %span.gl-sr-only GitLab = link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do @@ -19,8 +20,9 @@ %span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1 = _('Next') - - if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml) - = render "layouts/nav/top_nav" + - if use_top_nav_redesign + .gl-display-none.gl-sm-display-block + = render "layouts/nav/top_nav" - else - if current_user = render "layouts/nav/dashboard" @@ -30,13 +32,14 @@ .navbar-collapse.collapse %ul.nav.navbar-nav - if current_user - = render 'layouts/header/new_dropdown' - - if header_link?(:search) + = render 'layouts/header/new_dropdown', class: ('gl-display-none gl-sm-display-block' if use_top_nav_redesign) + - if top_nav_show_search + - search_menu_item = top_nav_search_menu_item_attrs %li.nav-item.d-none.d-lg-block.m-auto = render 'layouts/search' unless current_controller?(:search) - %li.nav-item.d-inline-block.d-lg-none - = link_to search_context.search_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('search') + %li.nav-item{ class: use_top_nav_redesign ? 'd-none d-sm-inline-block d-lg-none' : 'd-inline-block d-lg-none' } + = link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = sprite_icon(search_menu_item.fetch(:icon)) - if header_link?(:issues) = nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, @@ -115,10 +118,15 @@ - sign_in_text = allow_signup? ? _('Sign in / Register') : _('Sign in') = link_to sign_in_text, new_session_path(:user, redirect_to_referer: 'yes'), class: 'gl-button btn btn-default btn-sign-in' - %button.navbar-toggler.d-block.d-sm-none{ type: 'button' } + %button.navbar-toggler.d-block.d-sm-none{ type: 'button', class: ('gl-border-none!' if use_top_nav_redesign) } %span.sr-only= _('Toggle navigation') - = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right') - = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') + - if use_top_nav_redesign + %span.more-icon.gl-px-3.gl-font-sm.gl-font-weight-bold + %span.gl-pr-2= _('Menu') + = sprite_icon('hamburger', size: 16) + - else + = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon') + = sprite_icon('close', size: 12, css_class: 'close-icon') - if display_whats_new? #whats-new-app{ data: { version_digest: whats_new_version_digest } } diff --git a/app/views/layouts/header/_group_invite_members_new_dropdown_item.html.haml b/app/views/layouts/header/_group_invite_members_new_dropdown_item.html.haml deleted file mode 100644 index cb74c77dff8..00000000000 --- a/app/views/layouts/header/_group_invite_members_new_dropdown_item.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- return unless Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can?(current_user, :admin_group_member, @group) - -%li= dropdown_invite_members_link(@group) diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index c3769dd2993..01e59b8e2ef 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -10,7 +10,7 @@ %li %button.js-shortcuts-modal-trigger{ type: "button" } = _("Keyboard shortcuts") - %span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe + %span.text-secondary.float-right{ "aria-hidden": "true" }= '?'.html_safe %li.divider %li = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml index ca90d2e02fa..c5f43fb2c16 100644 --- a/app/views/layouts/header/_new_dropdown.html.haml +++ b/app/views/layouts/header/_new_dropdown.html.haml @@ -1,47 +1,27 @@ -- new_repo_experiment_text = content_for(:new_repo_experiment) -%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } } - = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do +- view_model = new_dropdown_view_model(project: @project, group: @group) +- menu_sections = view_model.fetch(:menu_sections) +- title = view_model.fetch(:title) +- show_headers = menu_sections.length > 1 +- top_class = local_assigns.fetch(:class, nil) + +- return if menu_sections.empty? + +%li.header-new.dropdown{ class: top_class, data: { track_label: "new_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } } + = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", id: "js-onboarding-new-project-link", title: title, ref: 'tooltip', aria: { label: title }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static', qa_selector: 'new_menu_toggle' } do = sprite_icon('plus-square') = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right.dropdown-extended-height %ul - - if @group&.persisted? - - create_group_project = can?(current_user, :create_projects, @group) - - create_group_subgroup = can?(current_user, :create_subgroup, @group) - - - if create_group_project || create_group_subgroup - %li.dropdown-bold-header - = _('This group') - - if create_group_project - %li= link_to new_repo_experiment_text, new_project_path(namespace_id: @group.id), data: { track_experiment: 'new_repo', track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } - - if create_group_subgroup - %li= link_to _('New subgroup'), new_group_path(parent_id: @group.id), data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } - = render_if_exists 'layouts/header/create_epic_new_dropdown_item' - = render 'layouts/header/group_invite_members_new_dropdown_item' + - menu_sections.each_with_index do |section, index| + - if index > 0 %li.divider - %li.dropdown-bold-header GitLab - - - if @project&.persisted? - - create_project_issue = show_new_issue_link?(@project) - - merge_project = merge_request_source_project_for_project(@project) - - create_project_snippet = can?(current_user, :create_snippet, @project) - - - if create_project_issue || merge_project || create_project_snippet + - if show_headers %li.dropdown-bold-header - = _('This project') - - if create_project_issue - %li= link_to _('New issue'), new_project_issue_path(@project), data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown' } - - if merge_project - %li= link_to _('New merge request'), project_new_merge_request_path(merge_project), data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } - - - if create_project_snippet - %li= link_to _('New snippet'), new_project_snippet_path(@project), data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } - = render 'layouts/header/project_invite_members_new_dropdown_item' - %li.divider - %li.dropdown-bold-header GitLab - - if current_user.can_create_project? - %li= link_to new_repo_experiment_text, new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown' } - - if current_user.can_create_group? - %li= link_to _('New group'), new_group_path, data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' } - - if current_user.can?(:create_snippet) - %li= link_to _('New snippet'), new_snippet_path, data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown' }, class: 'qa-global-new-snippet-link' + = section.fetch(:title) + - section.fetch(:menu_items).each do |menu_item| + %li< + = link_to menu_item.fetch(:href), class: menu_item.fetch(:css_class), data: menu_item.fetch(:data) do + = menu_item.fetch(:title) + - if menu_item.fetch(:emoji) + -# We need to insert a space between the title and emoji + = " #{emoji_icon(menu_item.fetch(:emoji), 'aria-hidden': true, class: "gl-font-base gl-vertical-align-baseline")}".html_safe diff --git a/app/views/layouts/header/_new_repo_experiment.html.haml b/app/views/layouts/header/_new_repo_experiment.html.haml deleted file mode 100644 index aaa13d593cd..00000000000 --- a/app/views/layouts/header/_new_repo_experiment.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- content_for :new_repo_experiment do - - experiment(:new_repo, user: current_user) do |e| - - e.use do - = _('New project') - - e.try do - = _('New project/repository') diff --git a/app/views/layouts/header/_project_invite_members_new_dropdown_item.html.haml b/app/views/layouts/header/_project_invite_members_new_dropdown_item.html.haml deleted file mode 100644 index 2cb67e857e3..00000000000 --- a/app/views/layouts/header/_project_invite_members_new_dropdown_item.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -- return unless Gitlab::Experimentation.active?(:invite_members_new_dropdown) && can_import_members? - -%li= dropdown_invite_members_link(@project) diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml index 1b1804edcc7..9266702e44e 100644 --- a/app/views/layouts/header/_registration_enabled_callout.html.haml +++ b/app/views/layouts/header/_registration_enabled_callout.html.haml @@ -1,15 +1,14 @@ - return unless show_registration_enabled_user_callout? -%div{ class: [container_class, @content_class, 'gl-pt-5!'] } - .gl-alert.gl-alert-warning.js-registration-enabled-callout{ role: 'alert', data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path } } - = sprite_icon('warning', size: 16, css_class: 'gl-alert-icon') - %button.gl-alert-dismiss.js-close{ type: 'button', aria: { label: _('Close') }, data: { testid: 'close-registration-enabled-callout' } } - = sprite_icon('close', size: 16) - .gl-alert-title - = _('Open registration is enabled on your instance.') - .gl-alert-body - = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\">".html_safe, anchorClose: '</a>'.html_safe } - .gl-alert-actions - = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-info btn-md gl-button' do - %span.gl-button-text - = _('View setting') += render 'shared/global_alert', + title: _('Open registration is enabled on your instance.'), + variant: :warning, + alert_class: 'js-registration-enabled-callout', + alert_data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path }, + close_button_data: { testid: 'close-registration-enabled-callout' } do + .gl-alert-body + = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\">".html_safe, anchorClose: '</a>'.html_safe } + .gl-alert-actions + = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-info btn-md gl-button' do + %span.gl-button-text + = _('View setting') diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 718b2002422..117382d87b5 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -4,7 +4,7 @@ -# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) - = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }) do + = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }) do %button{ type: 'button', data: { toggle: "dropdown" } } = _('Projects') = sprite_icon('chevron-down', css_class: 'caret-down') @@ -12,7 +12,7 @@ = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do %button{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') = sprite_icon('chevron-down', css_class: 'caret-down') @@ -21,28 +21,28 @@ - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets]) = nav_link(html_options: { id: 'nav-more-dropdown', class: "header-more dropdown", data: { track_label: "more_dropdown", track_event: "click_more_link" } }) do - %a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } } + %a{ href: "#", data: { toggle: "dropdown" } } = _('More') = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu %ul - if dashboard_nav_link?(:groups) %li.d-md-none - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', data: { qa_selector: 'groups_link' } do + = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups' do = _('Groups') - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do - = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', data: { qa_selector: 'activity_link' } do + = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity' do = _('Activity') - if dashboard_nav_link?(:milestones) = nav_link(controller: 'dashboard/milestones') do - = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', data: { qa_selector: 'milestones_link' } do + = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do = _('Milestones') - if dashboard_nav_link?(:snippets) = nav_link(controller: 'dashboard/snippets') do - = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', data: { qa_selector: 'snippets_link' } do + = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets' do = _('Snippets') %li.dropdown @@ -50,7 +50,7 @@ - if current_user.admin? = nav_link(controller: 'admin/dashboard') do - = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do + = link_to admin_root_path, class: 'admin-icon d-xl-none' do = _('Admin Area') - if Gitlab::CurrentSettings.admin_mode - if header_link?(:admin_mode) @@ -68,7 +68,7 @@ - if current_user.admin? = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-xl-block"}) do - = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to admin_root_path, class: 'admin-icon', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('admin', size: 18) - if Gitlab::CurrentSettings.admin_mode diff --git a/app/views/layouts/nav/_top_nav.html.haml b/app/views/layouts/nav/_top_nav.html.haml index 50c003f8e13..42119ddb291 100644 --- a/app/views/layouts/nav/_top_nav.html.haml +++ b/app/views/layouts/nav/_top_nav.html.haml @@ -2,6 +2,10 @@ %ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } } %li %a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } } - = sprite_icon('dot-grid', css_class: "dropdown-icon") + = sprite_icon('hamburger', css_class: "dropdown-icon") = view_model[:activeTitle] - = sprite_icon('chevron-down') + +.hidden + - view_model[:shortcuts].each do |shortcut| + = link_to shortcut[:href], class: shortcut[:css_class] do + = shortcut[:title] diff --git a/app/views/layouts/nav/_top_nav_responsive.html.haml b/app/views/layouts/nav/_top_nav_responsive.html.haml new file mode 100644 index 00000000000..0d122f1adff --- /dev/null +++ b/app/views/layouts/nav/_top_nav_responsive.html.haml @@ -0,0 +1,7 @@ +- return unless Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml) + +- top_class = local_assigns.fetch(:class, nil) +- view_model = top_nav_responsive_view_model(project: @project, group: @group) + +.top-nav-responsive{ class: top_class } + #js-top-nav-responsive{ data: { view_model: view_model.to_json } } diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml index 036647e2be1..d7b0c7150d4 100644 --- a/app/views/layouts/nav/groups_dropdown/_show.html.haml +++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml @@ -4,19 +4,20 @@ -# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 - group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted? .frequent-items-dropdown-container.with-deprecated-styles - .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar + .frequent-items-dropdown-sidebar %ul = nav_link(path: 'dashboard/groups#index') do - = link_to dashboard_groups_path, class: 'qa-your-groups-link', data: { track_label: "groups_dropdown_your_groups", track_event: "click_link" } do + = link_to dashboard_groups_path, data: { track_label: "groups_dropdown_your_groups", track_event: "click_link" } do = _('Your groups') = nav_link(path: 'groups#explore') do = link_to explore_groups_path, data: { track_label: "groups_dropdown_explore_groups", track_event: "click_link" } do = _('Explore groups') - = nav_link(path: 'groups/new#create-group-pane', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do - = link_to new_group_path(anchor: 'create-group-pane'), data: { track_label: "groups_dropdown_create_group", track_event: "click_link", qa_selector: 'create_group_link' } do - = _('Create group') - = nav_link(path: 'groups/new#import-group-pane') do - = link_to new_group_path(anchor: 'import-group-pane'), data: { track_label: "groups_dropdown_import_group", track_event: "click_link", qa_selector: 'import_group_link' } do - = _('Import group') + - if current_user.can_create_group? + = nav_link(path: 'groups/new#create-group-pane', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do + = link_to new_group_path(anchor: 'create-group-pane'), data: { track_label: "groups_dropdown_create_group", track_event: "click_link" } do + = _('Create group') + = nav_link(path: 'groups/new#import-group-pane') do + = link_to new_group_path(anchor: 'import-group-pane'), data: { track_label: "groups_dropdown_import_group", track_event: "click_link" } do + = _('Import group') .frequent-items-dropdown-content #js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index 2517508ba6c..46070975566 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -4,10 +4,10 @@ -# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 - project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted? .frequent-items-dropdown-container.with-deprecated-styles - .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar + .frequent-items-dropdown-sidebar %ul = nav_link(path: 'dashboard/projects#index') do - = link_to dashboard_projects_path, class: 'qa-your-projects-link', data: { track_label: "projects_dropdown_your_projects", track_event: "click_link" } do + = link_to dashboard_projects_path, data: { track_label: "projects_dropdown_your_projects", track_event: "click_link" } do = _('Your projects') = nav_link(path: 'projects#starred') do = link_to starred_dashboard_projects_path, data: { track_label: "projects_dropdown_starred_projects", track_event: "click_link" } do @@ -18,10 +18,10 @@ - experiment(:new_repo, user: current_user) do |e| - e.use do = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do - = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do + = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo", qa_selector: "create_project_link" } do = _('Create blank project') = nav_link(path: 'projects/new#import_project') do - = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do + = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo", qa_selector: "import_project_link" } do = _('Import project') - e.try do = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index b71866c9138..7a80c4e0ba9 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,14 +1,17 @@ +- avatar_size = sidebar_refactor_disabled? ? 24 : 18 +- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32' + %aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') } .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: _('Admin Overview') do - %span.avatar-container.s40.settings-avatar - = sprite_icon('admin', size: 24) + %span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', avatar_size_class] } + = sprite_icon('admin', size: avatar_size) %span.sidebar-context-title = _('Admin Area') %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do - = link_to admin_root_path do + = link_to admin_root_path, class: 'has-sub-items' do .nav-icon-container = sprite_icon('overview') %span.nav-item-name @@ -49,7 +52,7 @@ = _('Gitaly Servers') = nav_link(controller: admin_analytics_nav_links) do - = link_to admin_dev_ops_report_path, data: { qa_selector: 'admin_analytics_link' } do + = link_to admin_dev_ops_report_path, data: { qa_selector: 'admin_analytics_link' }, class: 'has-sub-items' do .nav-icon-container = sprite_icon('chart') %span.nav-item-name @@ -71,14 +74,14 @@ = _('Usage Trends') = nav_link(controller: admin_monitoring_nav_links) do - = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' } do + = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' }, class: 'has-sub-items' do .nav-icon-container = sprite_icon('monitor') %span.nav-item-name = _('Monitoring') %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } } - = nav_link(controller: %w(system_info background_jobs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" } ) do = link_to admin_system_info_path do %strong.fly-out-top-item-name = _('Monitoring') @@ -87,6 +90,10 @@ = link_to admin_system_info_path, title: _('System Info') do %span = _('System Info') + = nav_link(controller: :background_migrations) do + = link_to admin_background_migrations_path, title: _('Background Migrations') do + %span + = _('Background Migrations') = nav_link(controller: :background_jobs) do = link_to admin_background_jobs_path, title: _('Background Jobs') do %span @@ -227,20 +234,8 @@ %strong.fly-out-top-item-name = _('Labels') - = nav_link(controller: :appearances) do - = link_to admin_appearances_path do - .nav-icon-container - = sprite_icon('appearance') - %span.nav-item-name - = _('Appearance') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :appearances, html_options: { class: "fly-out-top-item" } ) do - = link_to admin_appearances_path do - %strong.fly-out-top-item-name - = _('Appearance') - - = nav_link(controller: [:application_settings, :integrations]) do - = link_to general_admin_application_settings_path do + = nav_link(controller: [:application_settings, :integrations, :appearances]) do + = link_to general_admin_application_settings_path, class: 'has-sub-items' do .nav-icon-container = sprite_icon('settings') %span.nav-item-name.qa-admin-settings-item @@ -248,7 +243,7 @@ %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_settings_submenu_content' } } -# This active_nav_link check is also used in `app/views/layouts/admin.html.haml` - = nav_link(controller: [:application_settings, :integrations], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" } ) do = link_to general_admin_application_settings_path do %strong.fly-out-top-item-name = _('Settings') @@ -295,6 +290,10 @@ = link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do %span = _('Network') + = nav_link(controller: :appearances ) do + = link_to admin_application_settings_appearances_path do + %span + = _('Appearance') = nav_link(path: 'application_settings#preferences') do = link_to preferences_admin_application_settings_path, title: _('Preferences'), data: { qa_selector: 'admin_settings_preferences_link' } do %span diff --git a/app/views/layouts/nav/sidebar/_analytics_links.html.haml b/app/views/layouts/nav/sidebar/_analytics_links.html.haml index 970a1d5f2c7..58989d6afc4 100644 --- a/app/views/layouts/nav/sidebar/_analytics_links.html.haml +++ b/app/views/layouts/nav/sidebar/_analytics_links.html.haml @@ -4,7 +4,7 @@ - if navbar_links.any? = nav_link(path: all_paths) do - = link_to analytics_link.link, {class: 'shortcuts-analytics', data: { qa_selector: 'analytics_anchor' } } do + = link_to analytics_link.link, {class: 'shortcuts-analytics has-sub-items', data: { qa_selector: 'analytics_anchor' } } do .nav-icon-container = sprite_icon('chart') %span.nav-item-name{ data: { qa_selector: 'analytics_link' } } diff --git a/app/views/layouts/nav/sidebar/_context_menu_body.html.haml b/app/views/layouts/nav/sidebar/_context_menu_body.html.haml new file mode 100644 index 00000000000..321bcda5702 --- /dev/null +++ b/app/views/layouts/nav/sidebar/_context_menu_body.html.haml @@ -0,0 +1,9 @@ +- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32' +- avatar_classes = ['avatar-container', 'rect-avatar', 'group-avatar'] +- avatar_classes << avatar_size_class + += link_to group_path(@group), title: @group.name, data: { qa_selector: 'group_scope_link' } do + %span{ class: avatar_classes } + = group_icon(@group, class: ['avatar', 'avatar-tile', avatar_size_class]) + %span.sidebar-context-title + = @group.name diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 757f95f864a..0ce1d48a2de 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,36 +1,39 @@ - issues_count = cached_issuables_count(@group, type: :issues) -- merge_requests_count = group_open_merge_requests_count(@group) +- merge_requests_count = cached_issuables_count(@group, type: :merge_requests) - aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation') %aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(@group), 'aria-label': aside_title } .nav-sidebar-inner-scroll - .context-header - = link_to group_path(@group), title: @group.name do - %span.avatar-container.rect-avatar.s40.group-avatar - = group_icon(@group, class: "avatar s40 avatar-tile") - %span.sidebar-context-title - = @group.name + - if sidebar_refactor_disabled? + .context-header + = render 'layouts/nav/sidebar/context_menu_body' + %ul.sidebar-top-level-items.qa-group-sidebar + - if sidebar_refactor_enabled? + = nav_link(path: ['groups#show', 'groups#details'], html_options: { class: 'context-header' }) do + = render 'layouts/nav/sidebar/context_menu_body' + = render_if_exists 'layouts/nav/sidebar/group_trial_status_widget', group: @group - if group_sidebar_link?(:overview) - paths = group_overview_nav_link_paths = nav_link(path: paths, unless: -> { current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do - = link_to group_path(@group) do + - information_link = sidebar_refactor_enabled? ? activity_group_path(@group) : group_path(@group) + = link_to information_link, class: 'has-sub-items', data: { qa_selector: 'group_information_link' } do .nav-icon-container - - sprite = Feature.enabled?(:sidebar_refactor, current_user) ? 'group' : 'home' + - sprite = sidebar_refactor_enabled? ? 'group' : 'home' = sprite_icon(sprite) %span.nav-item-name = group_information_title(@group) - %ul.sidebar-sub-level-items + %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_information_submenu'} } = nav_link(path: paths, html_options: { class: "fly-out-top-item" } ) do - = link_to group_path(@group) do + = link_to information_link do %strong.fly-out-top-item-name = group_information_title(@group) %li.divider.fly-out-top-item - - if Feature.disabled?(:sidebar_refactor, current_user) + - if sidebar_refactor_disabled? = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do = link_to details_group_path(@group), title: _('Group details') do %span @@ -42,13 +45,13 @@ %span = _('Activity') - - if group_sidebar_link?(:labels) && Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + - if group_sidebar_link?(:labels) && sidebar_refactor_enabled? = nav_link(path: 'labels#index') do = link_to group_labels_path(@group), title: _('Labels') do %span = _('Labels') - - if Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + - if sidebar_refactor_enabled? - if group_sidebar_link?(:group_members) = nav_link(path: 'group_members#index') do = link_to group_group_members_path(@group), title: _('Members'), data: { qa_selector: 'group_members_item' } do @@ -59,7 +62,7 @@ - if group_sidebar_link?(:issues) = nav_link(path: group_issues_sub_menu_items, unless: -> { current_path?('issues_analytics#show') }) do - = link_to issues_group_path(@group), data: { qa_selector: 'group_issues_item' } do + = link_to issues_group_path(@group), data: { qa_selector: 'group_issues_item' }, class: 'has-sub-items' do .nav-icon-container = sprite_icon('issues') %span.nav-item-name @@ -85,7 +88,7 @@ %span = boards_link_text - - if group_sidebar_link?(:labels) && Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + - if group_sidebar_link?(:labels) && sidebar_refactor_disabled? = nav_link(path: 'labels#index') do = link_to group_labels_path(@group), title: _('Labels') do %span @@ -138,7 +141,7 @@ - if group_sidebar_link?(:wiki) = render 'layouts/nav/sidebar/wiki_link', wiki_url: @group.wiki.web_url - - if Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + - if sidebar_refactor_disabled? - if group_sidebar_link?(:group_members) = nav_link(path: 'group_members#index') do = link_to group_group_members_path(@group) do @@ -154,7 +157,7 @@ - if group_sidebar_link?(:settings) = nav_link(path: group_settings_nav_link_paths) do - = link_to edit_group_path(@group) do + = link_to edit_group_path(@group), class: 'has-sub-items' do .nav-icon-container = sprite_icon('settings') %span.nav-item-name{ data: { qa_selector: 'group_settings' } } diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 63b97e3133c..daafabdb799 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -1,9 +1,12 @@ +- avatar_size = sidebar_refactor_disabled? ? 40 : 32 +- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32' + %aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(current_user), 'aria-label': _('User settings') } .nav-sidebar-inner-scroll .context-header = link_to profile_path, title: _('Profile Settings') do - %span.avatar-container.s40.settings-avatar - = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile js-sidebar-user-avatar", alt: current_user.name, data: { testid: 'sidebar-user-avatar' } + %span{ class: ['avatar-container', 'settings-avatar', avatar_size_class] } + = image_tag avatar_icon_for_user(current_user, avatar_size), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', avatar_size_class], alt: current_user.name, data: { testid: 'sidebar-user-avatar' } %span.sidebar-context-title= _('User Settings') %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index e39cb5ee0a2..4d5c354388f 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -5,7 +5,7 @@ %body{ data: { page: body_data_page } } .layout-page.terms{ class: page_class } - .content-wrapper.gl-mt-0 + .content-wrapper .mobile-overlay .alert-wrapper = render "layouts/broadcast" diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml index 11cbd700258..afed3c95130 100644 --- a/app/views/notify/_failed_builds.html.haml +++ b/app/views/notify/_failed_builds.html.haml @@ -3,10 +3,10 @@ had = failed.size failed - #{'build'.pluralize(failed.size)}. + #{'job'.pluralize(failed.size)}. %tr.table-warning %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" } - Failed builds + Failed jobs %tr.section %td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" } %table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" } diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml index a1c3ecfb87e..45b002757e3 100644 --- a/app/views/notify/in_product_marketing_email.html.haml +++ b/app/views/notify/in_product_marketing_email.html.haml @@ -184,9 +184,32 @@ - @message.body_line2&.tap do |line| %p{ style: "margin: 0 0 20px 0;" } = line.html_safe - %tr - %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .cta_link= @message.cta_link + - if @message.cta_text + %tr + %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } + .cta_link= @message.cta_link + - else + %tr + %td{ style: "padding: 10px 20px 10px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 16px; line-height: 20px;" } + %table{ border: "0", cellpadding: "0", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%;" } + %tr + %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: left;", align: "left" } + = @message.feedback_ratings(1) + %td{ width: "50%", style: "width: 50%; min-width: 50%; color: #000000; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px; line-height: 100%; padding-bottom: 16px; text-align: right;", align: "right" } + = @message.feedback_ratings(5) + %tr + %td{ align: "center", style: "padding: 10px 1px 30px 1px;" } + %table{ align: "center", cellpadding: "5", cellspacing: "0", width: "100%", style: "width: 100%; min-width: 100%; border: 1px solid #dae0ea; border-radius: 0; min-width: 100%; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; font-size: 16px;" } + %tr + - (1..5).each do |rating| + %td{ height: "54", style: "border-left: 1px solid #dae0ea; padding-bottom: 0; width: 9% !important;", width: "9%" } + %a{ href: @message.feedback_link(rating), style: "color: #424242; display: block; text-decoration: none;" } + %span{ height: "54", style: "display: block; font-size: 18px; height: 22px; line-height: 22px; padding: 16px 0; width: 100%; text-decoration: none;" } + = rating + %tr + %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } + %p{ style: "margin: 0 0 50px 0;" } + = @message.feedback_thanks %tr{ style: "background-color: #ffffff;" } %td{ align: "center", style: "padding:75px 20px 25px;" } = about_link('gitlab_logo.png', 80) diff --git a/app/views/notify/in_product_marketing_email.text.erb b/app/views/notify/in_product_marketing_email.text.erb index 7d0fe7aec6d..6f0a2efa410 100644 --- a/app/views/notify/in_product_marketing_email.text.erb +++ b/app/views/notify/in_product_marketing_email.text.erb @@ -8,10 +8,19 @@ <%= @message.body_line2 %> +<% if @message.cta_text %> <%= @message.cta_link %> +<% else %> +<% (1..5).each do |rating| %> +<%= "#{rating} - #{@message.feedback_ratings(rating).upcase} - #{@message.feedback_link(rating)}" %> +<% end %> + + +<%= @message.feedback_thanks %> +<% end %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index f7dc1fa662c..a4ea63e3d53 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -13,28 +13,48 @@ = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders %p.invite-actions = link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join' - - experiment_instance.try(:avatar) do - %tr - %td.text-content - %img.mail-avatar{ height: "60", src: avatar_icon_for_user(member.created_by, 60, only_path: false), width: "60", alt: "" } - %p - = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }) - %p.invite-actions - = link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join' - - experiment_instance.try(:permission_info) do + - experiment_instance.try(:activity) do %tr %td.text-content{ colspan: 2 } %img.mail-avatar{ height: "60", src: avatar_icon_for_user(member.created_by, 60, only_path: false), width: "60", alt: "" } %p - = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} with the %{role} permission level.")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }) + = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }) %p.invite-actions = link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Members::InviteEmailExperiment::INVITE_TYPE), class: 'invite-btn-join' %tr.border-top - %td.text-content.half-width + %td.text-content.mailer-align-left.half-width %h4 - = s_('InviteEmail|What is a GitLab %{project_or_group}?') % { project_or_group: member_source.model_name.singular } - %p= invited_to_description(member_source.model_name.singular) - %td.text-content.half-width + = s_('InviteEmail|%{project_or_group} details') % { project_or_group: member_source.model_name.singular.capitalize } + %ul + %li + %div + %img.mailer-icon{ alt: '', src: image_url("mailers/members/users.png") } + %span + - member_count = member_source.members.size + = n_('%{bold_start}%{count}%{bold_end} member', '%{bold_start}%{count}%{bold_end} members', + member_count).html_safe % { count: number_with_delimiter(member_count), + bold_start: '<b>'.html_safe, + bold_end: '</b>'.html_safe } + %li + %div + %img.mailer-icon{ alt: '', src: image_url("mailers/members/issues.png") } + %span + - issue_count = member_source.open_issues_count(member.created_by) + = n_('%{bold_start}%{count}%{bold_end} issue', '%{bold_start}%{count}%{bold_end} issues', + issue_count).html_safe % { count: number_with_delimiter(issue_count), + bold_start: '<b>'.html_safe, + bold_end: '</b>'.html_safe } + %li + %div + %img.mailer-icon{ alt: '', src: image_url("mailers/members/merge-request-open.png") } + %span + - mr_count = member_source.open_merge_requests_count(member.created_by) + = n_('%{bold_start}%{count}%{bold_end} opened merge request', '%{bold_start}%{count}%{bold_end} opened merge requests', + mr_count).html_safe % { count: number_with_delimiter(mr_count), + bold_start: '<b>'.html_safe, + bold_end: '</b>'.html_safe } + %td.text-content.mailer-align-left.half-width %h4 - = s_('InviteEmail|What can I do with the %{role} permission level?') % { role: member.human_access.downcase } - %p= invited_role_description(member.human_access) + = s_("InviteEmail|What's it about?") + %p + = invited_to_description(member_source) diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 1fe7d554bc3..6ab74bcfb1a 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -28,7 +28,7 @@ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <% Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> <% failed = @pipeline.latest_statuses.failed -%> -had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. +had <%= failed.size %> failed <%= 'job'.pluralize(failed.size) %>. <% failed.each do |build| -%> <%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %> diff --git a/app/views/notify/ssh_key_expired_email.html.haml b/app/views/notify/ssh_key_expired_email.html.haml index 651bdac7acb..79a09b74683 100644 --- a/app/views/notify/ssh_key_expired_email.html.haml +++ b/app/views/notify/ssh_key_expired_email.html.haml @@ -1,7 +1,7 @@ %p = _('Hi %{username}!') % { username: sanitize_name(@user.name) } %p - = _('Your SSH keys with the following fingerprints have expired. Expired SSH keys will not be usable in future versions of GitLab:') + = _('SSH keys with the following fingerprints have expired and can no longer be used:') %table %tbody - @fingerprints.each do |fingerprint| diff --git a/app/views/notify/ssh_key_expired_email.text.erb b/app/views/notify/ssh_key_expired_email.text.erb index aa6e79d59b8..0eb0859e4b9 100644 --- a/app/views/notify/ssh_key_expired_email.text.erb +++ b/app/views/notify/ssh_key_expired_email.text.erb @@ -1,6 +1,6 @@ <%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> -<%= _('Your SSH keys with the following fingerprints have expired. Expired SSH keys will not be usable in future versions of GitLab:') %> +<%= _('SSH keys with the following fingerprints have expired and can no longer be used:') %> <% @fingerprints.each do |fingerprint| %> - <%= fingerprint %> diff --git a/app/views/notify/ssh_key_expiring_soon.text.erb b/app/views/notify/ssh_key_expiring_soon.text.erb index ff6feb87662..372c55ecb87 100644 --- a/app/views/notify/ssh_key_expiring_soon.text.erb +++ b/app/views/notify/ssh_key_expiring_soon.text.erb @@ -1,6 +1,6 @@ <%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> -<%= _('Your SSH keys with the following fingerprints are scheduled to expire soon. Expired SSH keys will not be usable in future versions of GitLab:') %> +<%= _('SSH keys with the following fingerprints are scheduled to expire soon. Expired SSH keys can not be used:') %> <% @fingerprints.each do |fingerprint| %> - <%= fingerprint %> diff --git a/app/views/notify/ssh_key_expiring_soon_email.html.haml b/app/views/notify/ssh_key_expiring_soon_email.html.haml index 924165ecf3d..cd4ee23e3db 100644 --- a/app/views/notify/ssh_key_expiring_soon_email.html.haml +++ b/app/views/notify/ssh_key_expiring_soon_email.html.haml @@ -1,7 +1,7 @@ %p = _('Hi %{username}!') % { username: sanitize_name(@user.name) } %p - = _('Your SSH keys with the following fingerprints are scheduled to expire soon. Expired SSH keys will not be usable in future versions of GitLab:') + = _('SSH keys with the following fingerprints are scheduled to expire soon. Expired SSH keys can not be used:') %table %tbody - @fingerprints.each do |fingerprint| diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml index 8d0993e9ff8..47c5656db27 100644 --- a/app/views/notify/unknown_sign_in_email.html.haml +++ b/app/views/notify/unknown_sign_in_email.html.haml @@ -32,7 +32,7 @@ %td{ style: "#{default_style}border-top:1px solid #ededed;" } = _('Time') %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } - = @time.strftime('%Y-%m-%d %l:%M:%S %p %Z') + = @time.strftime('%Y-%m-%d %H:%M:%S %Z') %tr.spacer %td{ style: spacer_style } diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index d78b542ae8a..c14efa99555 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -22,15 +22,15 @@ .account-well.gl-mb-3 %ul %li - = _('Your Primary Email will be used for avatar detection.') + - profile_message = _('Your primary email is used for avatar detection. You can change it in your %{openingTag}profile settings%{closingTag}.') % { openingTag: "<a href='#{profile_path}'>".html_safe, closingTag: '</a>'.html_safe} + = profile_message.html_safe %li - = _('Your Commit Email will be used for web based operations, such as edits and merges.') + = _('Your commit email is used for web based operations, such as edits and merges.') %li - - address = profile_notifications_path - - notification_message = _('Your Default Notification Email will be used for account notifications if a %{openingTag}group-specific email address%{closingTag} is not set.') % { openingTag: "<a href='#{address}'>".html_safe, closingTag: '</a>'.html_safe} + - notification_message = _('Your default notification email is used for account notifications if a %{openingTag}group-specific email address%{closingTag} is not set.') % { openingTag: "<a href='#{profile_notifications_path}'>".html_safe, closingTag: '</a>'.html_safe} = notification_message.html_safe %li - = _('Your Public Email will be displayed on your public profile.') + = _('Your public email will be displayed on your public profile.') %li = _('All email addresses will be used to identify your commits.') %ul.content-list diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 853188c563f..22cb95b346a 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -4,13 +4,15 @@ %div - if @user.errors.any? .gl-alert.gl-alert-danger.gl-my-5 - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', css_class: 'gl-icon') - = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - %ul - - @user.errors.full_messages.each do |msg| - %li= msg + .gl-alert-container + %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'gl-icon') + = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + %ul + - @user.errors.full_messages.each do |msg| + %li= msg = hidden_field_tag :notification_type, 'global' .row.gl-mt-3 diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index ee04d9142b1..3661b93e33c 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -40,15 +40,15 @@ %h4.gl-mt-0 = s_('AccessTokens|Feed token') %p - = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.') + = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.') %p = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.feed-token-reset = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } + - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } = reset_message.html_safe - if incoming_email_token_enabled? @@ -59,15 +59,15 @@ %h4.gl-mt-0 = s_('AccessTokens|Incoming email token') %p - = s_('AccessTokens|Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses.') + = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.') %p = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.incoming-email-token-reset = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } + - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } = reset_message.html_safe - if static_objects_external_storage_enabled? @@ -78,7 +78,7 @@ %h4.gl-mt-0 = s_('AccessTokens|Static object token') %p - = s_('AccessTokens|Your static object token is used to authenticate you when repository static objects (e.g. archives, blobs, ...) are being served from an external storage.') + = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.') %p = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8 @@ -88,5 +88,5 @@ - reset_link = url_for [:reset, :static_object_token, :profile] - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } - reset_link_end = '</a>'.html_safe - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can access repository static objects as if they were you. You should %{reset_link_start}reset it%{reset_link_end} if that ever happens.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } = reset_message.html_safe diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index c3ec2f7bab3..411954aed6a 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -11,7 +11,7 @@ .row.js-search-settings-section .col-lg-4.profile-settings-sidebar %h4.gl-mt-0 - = s_("Profiles|Public Avatar") + = s_("Profiles|Public avatar") %p - if @user.avatar? - if gravatar_enabled? @@ -27,18 +27,17 @@ .md = brand_profile_image_guidelines .col-lg-8 - .clearfix.avatar-image.gl-mb-3 + .avatar-image = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do - = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' + = image_tag avatar_icon_for_user(@user, 96), alt: '', class: 'avatar s96' %h5.gl-mt-0= s_("Profiles|Upload new avatar") - .gl-mt-2.gl-mb-3 - %button.gl-button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") + .gl-my-3 + %button.gl-button.btn.btn-default.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") %span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.") = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' - .form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.") + .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.") - if @user.avatar? - %hr - = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'gl-button btn btn-danger btn-inverted' + = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'gl-button btn btn-danger-secondary btn-sm gl-mt-5' .col-lg-12 %hr .row.js-search-settings-section @@ -101,6 +100,7 @@ = render 'profiles/name', form: f, user: @user = f.text_field :id, class: 'gl-form-input', readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } + = f.text_field :pronouns, class: 'input-md gl-form-input', help: s_("Profiles|Enter your pronouns to let people know how to refer to you") = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md gl-form-input', placeholder: s_("Profiles|username") = f.text_field :linkedin, class: 'input-md gl-form-input', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") @@ -124,10 +124,10 @@ = f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true .help-block = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") - .row.gl-mt-3.gl-mb-3.gl-justify-content-end - .col-lg-8 - = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm' - = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-cancel' + .row.gl-justify-content-end.gl-mt-5 + .col-lg-8.gl-display-flex + = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3' + = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel' .modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } } .modal-dialog @@ -136,7 +136,7 @@ %h4.modal-title = s_("Profiles|Position and size your new avatar") %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body .profile-crop-image-container %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 71262f4bcb9..3cd571c23d3 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -46,7 +46,9 @@ = form_tag profile_two_factor_auth_path, method: :post do |f| - if @error .gl-alert.gl-alert-danger.gl-mb-5 - = @error + .gl-alert-container + .gl-alert-content + = @error .form-group = label_tag :pin_code, _('Pin code'), class: "label-bold" = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } diff --git a/app/views/projects/_bitbucket_import_modal.html.haml b/app/views/projects/_bitbucket_import_modal.html.haml index c54a4ceb890..1379a339feb 100644 --- a/app/views/projects/_bitbucket_import_modal.html.haml +++ b/app/views/projects/_bitbucket_import_modal.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.modal-title Import projects from Bitbucket %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body To enable importing projects from Bitbucket, - if current_user.admin? diff --git a/app/views/projects/_deletion_failed.html.haml b/app/views/projects/_deletion_failed.html.haml index 7e65f2f1cef..21c799f5bb6 100644 --- a/app/views/projects/_deletion_failed.html.haml +++ b/app/views/projects/_deletion_failed.html.haml @@ -2,7 +2,9 @@ - return unless project.delete_error.present? .project-deletion-failed-message.gl-alert.gl-alert-warning - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - This project was scheduled for deletion, but failed with the following message: - = project.delete_error + .gl-alert-container + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + This project was scheduled for deletion, but failed with the following message: + = project.delete_error diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 8642dc5fc8c..597a22bf34a 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -7,7 +7,7 @@ - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") #tree-holder.tree-holder.clearfix - .nav-block.gl-display-flex.gl-align-items-center + .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch = render 'projects/tree/tree_header', tree: @tree #js-last-commit diff --git a/app/views/projects/_gitlab_import_modal.html.haml b/app/views/projects/_gitlab_import_modal.html.haml index 5519415cdc3..689e100ab96 100644 --- a/app/views/projects/_gitlab_import_modal.html.haml +++ b/app/views/projects/_gitlab_import_modal.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.modal-title Import projects from GitLab.com %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body To enable importing projects from GitLab.com, - if current_user.admin? diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index a70679dab5f..26291c0358e 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -25,14 +25,14 @@ %span.access-request-links.gl-ml-3 = render 'shared/members/access_request_links', source: @project - - if @project.tag_list.present? - = cache_if(cache_enabled, [@project, :tag_list], expires_in: 1.day) do + - if @project.topic_list.present? + = cache_if(cache_enabled, [@project, :topic_list], expires_in: 1.day) do %span.home-panel-topic-list.mt-2.w-100.d-inline-flex.gl-font-base.gl-font-weight-normal.gl-align-items-center = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - @project.topics_to_show.each do |topic| - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" - - explore_project_topic_path = explore_projects_path(tag: topic) + - explore_project_topic_path = explore_projects_path(topic: topic) - if topic.length > max_project_topic_length %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } = topic.titleize diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index c0fe788b56a..e6ded3ad912 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -83,7 +83,7 @@ .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } - = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f| + = form_for @project, html: { class: 'new_project' } do |f| %hr = render "shared/import_form", f: f = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label diff --git a/app/views/projects/_invite_members.html.haml b/app/views/projects/_invite_members.html.haml index e3a512d6451..ab630d34501 100644 --- a/app/views/projects/_invite_members.html.haml +++ b/app/views/projects/_invite_members.html.haml @@ -3,6 +3,10 @@ track_event: 'render' } } = s_('InviteMember|Invite your team') %p= s_('InviteMember|Add members to this project and start collaborating with your team.') -= link_to s_('InviteMember|Invite members'), project_project_members_path(@project, sort: :access_level_desc), - class: 'gl-button btn btn-confirm gl-mb-8 gl-xs-w-full', - data: { track_event: 'click_button', track_label: 'invite_members_empty_project' } +.js-invite-members-trigger{ data: { variant: 'confirm', + classes: 'gl-mb-8 gl-xs-w-full', + display_text: s_('InviteMember|Invite members'), + event: 'click_button', + label: 'invite_members_empty_project' } } + += render 'shared/issuable/invite_members_trigger', project: @project diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml index 990ac9fefb9..f75216a71b6 100644 --- a/app/views/projects/_visibility_modal.html.haml +++ b/app/views/projects/_visibility_modal.html.haml @@ -7,7 +7,7 @@ .modal-header %h3.page-title= _('Reduce this project’s visibility?') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true }= sprite_icon("close") + %span{ "aria-hidden": "true" }= sprite_icon("close") .modal-body %p - if @project.group diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index e50b964a253..9fa65f27651 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -1,4 +1,6 @@ = render "projects/blob/breadcrumb", blob: blob +- project = @project.present(current_user: current_user) +- ref = local_assigns[:ref] || @ref .info-well.d-none.d-sm-block .well-segment @@ -12,7 +14,12 @@ - if @code_navigation_path #js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } } - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml) - #js-view-blob-app{ data: { blob_path: blob.path, project_path: @project.full_path } } + -# Data info will be removed once we migrate this to use GraphQL + -# Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/330406 + #js-view-blob-app{ data: { blob_path: blob.path, + project_path: @project.full_path, + target_branch: project.empty_repo? ? ref : @ref, + original_branch: @ref } } .gl-spinner-container = loading_icon(size: 'md') - else diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index b310939c5a3..95a5d63e07f 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -1,4 +1,6 @@ .file-header-content + - if Gitlab::MarkupHelper.gitlab_markdown?(blob.path) + .js-table-contents = blob_icon blob.mode, blob.name %strong.file-title-name.gl-word-break-all{ data: { qa_selector: 'file_name_content' } } diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml index 57477e59167..905dc2a49ec 100644 --- a/app/views/projects/blob/_new_dir.html.haml +++ b/app/views/projects/blob/_new_dir.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title= _('Create New Directory') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body = form_tag project_create_dir_path(@project, @id), method: :post, remote: false, class: 'js-create-dir-form js-quick-submit js-requires-input' do .form-group.row diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml index d3440ee41b5..298a36e28ec 100644 --- a/app/views/projects/blob/_remove.html.haml +++ b/app/views/projects/blob/_remove.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title Delete #{@blob.name} %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body = form_tag project_blob_path(@project, @id), method: :delete, class: 'js-delete-blob-form js-quick-submit js-requires-input' do diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index 28e33e3ac9b..6d2751bb7d4 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title= title %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body = form_tag form_path, method: method, class: 'js-quick-submit js-upload-blob-form', data: { method: method } do .dropzone diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index a0d82ffd2c7..8713ce79d96 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -5,6 +5,7 @@ - external_embed = local_assigns.fetch(:external_embed, false) - viewer_url = local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: viewer.type, format: :json)) } if load_async +- add_page_startup_api_call viewer_url .blob-viewer{ data: { type: viewer.type, rich_type: rich_type, url: viewer_url, path: viewer.blob.path }, class: ('hidden' if hidden) } - if render_error = render 'projects/blob/render_error', viewer: viewer diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 9f89981e7ca..ecbef9a11a7 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -5,11 +5,13 @@ - if @conflict .gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5 - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - Someone edited the file the same time you did. Please check out - = link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link' - and make sure your changes will not unintentionally remove theirs. + .gl-alert-container + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + Someone edited the file the same time you did. Please check out + = link_to _('the file'), project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link' + and make sure your changes will not unintentionally remove theirs. %h3.page-title.blob-edit-page-title Edit file diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index a5414ce7ef2..60cb06f71ba 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -46,35 +46,40 @@ title: s_('Branches|Compare') do = s_('Branches|Compare') - = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name] + = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], class: 'gl-vertical-align-top' - - if can?(current_user, :push_code, @project) + - if Feature.enabled?(:delete_branch_confirmation_modals, @project, default_enabled: :yaml) + = render 'projects/branches/delete_branch_modal_button', project: @project, branch: branch, merged: merged + + - elsif can?(current_user, :push_code, @project) - if branch.name == @project.repository.root_ref - delete_default_branch_tooltip = s_('Branches|The default branch cannot be deleted') - %span.has-tooltip{ title: delete_default_branch_tooltip } - %button{ class: "gl-button btn btn-danger disabled", disabled: true, 'aria-label' => delete_default_branch_tooltip } - = sprite_icon("remove") + %span.gl-display-inline-block.has-tooltip{ title: delete_default_branch_tooltip } + %button{ class: 'gl-button btn btn-default btn-icon disabled', disabled: true, 'aria-label' => delete_default_branch_tooltip } + = sprite_icon('remove', css_class: 'gl-button-icon gl-icon') - elsif protected_branch?(@project, branch) - if can?(current_user, :push_to_delete_protected_branch, @project) - %button{ class: "gl-button btn btn-danger has-tooltip", - title: s_('Branches|Delete protected branch'), - data: { toggle: "modal", - target: "#modal-delete-branch", + - delete_protected_branch_tooltip = s_('Branches|Delete protected branch') + %button{ class: 'gl-button btn btn-default btn-icon has-tooltip', + title: delete_protected_branch_tooltip, + 'aria-label' => delete_protected_branch_tooltip, + data: { toggle: 'modal', + target: '#modal-delete-branch', delete_path: project_branch_path(@project, branch.name), branch_name: branch.name, - is_merged: ("true" if merged) } } - = sprite_icon("remove") + is_merged: ('true' if merged) } } + = sprite_icon('remove', css_class: 'gl-button-icon gl-icon') - else - - delete_protected_branch_tooltip = s_('Branches|Only a project maintainer or owner can delete a protected branch') - %span.has-tooltip{ title: delete_protected_branch_tooltip } - %button{ class: "gl-button btn btn-danger disabled", disabled: true, 'aria-label' => delete_protected_branch_tooltip } - = sprite_icon("remove") + - delete_protected_branch_disabled_tooltip = s_('Branches|Only a project maintainer or owner can delete a protected branch') + %span.has-tooltip{ title: delete_protected_branch_disabled_tooltip } + %button{ class: 'gl-button btn btn-default btn-icon disabled', disabled: true, 'aria-label' => delete_protected_branch_disabled_tooltip, data: { testid: 'remove-protected-branch' } } + = sprite_icon('remove', css_class: 'gl-button-icon gl-icon') - else = link_to project_branch_path(@project, branch.name), - class: "gl-button btn btn-danger js-remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip", + class: 'gl-button btn btn-default btn-icon js-remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip', title: s_('Branches|Delete branch'), method: :delete, data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } }, remote: true, 'aria-label' => s_('Branches|Delete branch') do - = sprite_icon("remove") + = sprite_icon('remove', css_class: 'gl-button-icon gl-icon') diff --git a/app/views/projects/branches/_delete_branch_modal_button.html.haml b/app/views/projects/branches/_delete_branch_modal_button.html.haml new file mode 100644 index 00000000000..829a459ad2c --- /dev/null +++ b/app/views/projects/branches/_delete_branch_modal_button.html.haml @@ -0,0 +1,18 @@ +- if branch.name == @project.repository.root_ref + .js-delete-branch-button{ data: { tooltip: s_('Branches|The default branch cannot be deleted'), + disabled: true.to_s } } +- elsif protected_branch?(@project, branch) + - if can?(current_user, :push_to_delete_protected_branch, @project) + .js-delete-branch-button{ data: { branch_name: branch.name, + is_protected_branch: true.to_s, + merged: merged.to_s, + default_branch_name: @project.repository.root_ref, + delete_path: project_branch_path(@project, branch.name) } } + - else + .js-delete-branch-button{ data: { is_protected_branch: true.to_s, + disabled: true.to_s } } +- else + .js-delete-branch-button{ data: { branch_name: branch.name, + merged: merged.to_s, + default_branch_name: @project.repository.root_ref, + delete_path: project_branch_path(@project, branch.name) } } diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml index 5c5653401fb..2b45b4eddcc 100644 --- a/app/views/projects/branches/_delete_protected_modal.html.haml +++ b/app/views/projects/branches/_delete_protected_modal.html.haml @@ -7,7 +7,7 @@ %span.js-branch-name.ref-name>[branch name] = s_("Branches|Delete protected branch '%{branch_name}'?").html_safe % { branch_name: title_branch_name } %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body %p diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 129b207a26f..b1d465d0e75 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -46,7 +46,7 @@ %ul.content-list.all-branches - @branches.each do |branch| = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any? - - if Feature.enabled?(:branches_pagination_without_count, @project, default_enabled: true) + - if Feature.enabled?(:branches_pagination_without_count, @project, default_enabled: :yaml) = render('kaminari/gitlab/without_count', previous_path: @prev_path, next_path: @next_path) - else = paginate @branches, theme: 'gitlab' @@ -54,4 +54,7 @@ .nothing-here-block = s_('Branches|No branches to show') -= render 'projects/branches/delete_protected_modal' +- if Feature.enabled?(:delete_branch_confirmation_modals, @project, default_enabled: :yaml) && can?(current_user, :push_code, @project) + .js-delete-branch-modal +- elsif can?(current_user, :push_code, @project) + = render 'projects/branches/delete_protected_modal' diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index ee195e69a98..74d10f11898 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -26,14 +26,19 @@ = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' %li.divider.mt-2 - %li.pt-2.gl-new-dropdown-item - %label.label-bold{ class: 'gl-px-4!' } - = _('Open in your IDE') + %li.pt-2.gl-new-dropdown-item + %label.label-bold{ class: 'gl-px-4!' } + = _('Open in your IDE') + - if ssh_enabled? + %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + CGI.escape(project.ssh_url_to_repo) } + .gl-new-dropdown-item-text-wrapper + = _('Visual Studio Code (SSH)') + - if http_enabled? %a.dropdown-item.open-with-link{ href: 'vscode://vscode.git/clone?url=' + CGI.escape(project.http_url_to_repo) } .gl-new-dropdown-item-text-wrapper - = _('Visual Studio Code') - - if show_xcode_link?(@project) - %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } - .gl-new-dropdown-item-text-wrapper - = _("Xcode") + = _('Visual Studio Code (HTTPS)') + - if show_xcode_link?(@project) + %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } + .gl-new-dropdown-item-text-wrapper + = _("Xcode") = render_if_exists 'projects/buttons/kerberos_clone_field' diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 2f89a3f62ed..2d32e07d379 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -2,11 +2,11 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" - .project-action-button.dropdown.inline> - %button.gl-button.btn.btn-default.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } - = sprite_icon('download', css_class: 'gl-icon') + .project-action-button.dropdown.gl-new-dropdown.inline> + %button.gl-button.btn.btn-default.dropdown-toggle.gl-dropdown-toggle.dropdown-icon-only.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } + = sprite_icon('download', css_class: 'gl-icon dropdown-icon') %span.sr-only= _('Select Archive Format') - = sprite_icon('chevron-down', css_class: 'gl-icon') + = sprite_icon('chevron-down', css_class: 'gl-icon dropdown-chevron') .dropdown-menu.dropdown-menu-right{ role: 'menu' } %section %h5.m-0.dropdown-bold-header= _('Download source code') diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 0cc595de7be..824e876500b 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -97,7 +97,7 @@ #{job.coverage}% %td - .gl-display-flex + .gl-text-right .btn-group - if can?(current_user, :read_job_artifacts, job) && job.artifacts? = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'gl-button btn btn-default btn-icon' do diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index e6c9a7166a9..9e0dd93c683 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -35,8 +35,10 @@ - if hidden > 0 %li.gl-alert.gl-alert-warning - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) + .gl-alert-container + = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) - if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty? %button.gl-button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } } diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml index 1eeafac5f1e..cf4e39f9659 100644 --- a/app/views/projects/confluences/show.html.haml +++ b/app/views/projects/confluences/show.html.haml @@ -6,9 +6,9 @@ = s_('WikiEmpty|Confluence is enabled') %p - wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629' - - wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url } - = s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.").html_safe % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe } - = link_to @project.confluence_service.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do + - wiki_confluence_epic_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe, url: wiki_confluence_epic_link_url) + = format(s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.").html_safe, wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe) + = link_to @project.confluence_integration.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do = sprite_icon('external-link') = s_('WikiEmpty|Go to Confluence') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 71730da0595..c1f6cfc40c3 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,6 +1,6 @@ - page_title _("Value Stream Analytics") - add_page_specific_style 'page_bundles/cycle_analytics' - svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") } -- initial_data = { request_path: project_cycle_analytics_path(@project) }.merge!(svgs) +- initial_data = { request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs) #js-cycle-analytics{ data: initial_data } diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index b76f6b27aa8..027b81d6c68 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -7,7 +7,7 @@ = render "home_panel" = render "archived_notice", project: @project -= render "invite_members" if experiment_enabled?(:invite_members_empty_project_version_a) && can_import_members? += render "invite_members" if can_import_members? %h4.gl-mt-0.gl-mb-3 = _('The repository for this project is empty') @@ -44,6 +44,7 @@ :preserve git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} cd #{h @project.path} + git switch -c #{default_branch_name} touch README.md git add README.md git commit -m "add README" @@ -56,7 +57,7 @@ %pre.bg-light :preserve cd existing_folder - git init + git init --initial-branch=#{default_branch_name} git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')} git add . git commit -m "Initial commit" diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml index 7d48cba74d0..53fe30422ca 100644 --- a/app/views/projects/feature_flags/index.html.haml +++ b/app/views/projects/feature_flags/index.html.haml @@ -14,4 +14,4 @@ "can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project), "new-feature-flag-path" => can?(current_user, :create_feature_flag, @project) ? new_project_feature_flag_path(@project): nil, "rotate-instance-id-path" => can?(current_user, :admin_feature_flags_client, @project) ? reset_token_project_feature_flags_client_path(@project, format: :json) : nil, - "new-user-list-path" => can?(current_user, :admin_feature_flags_user_lists, @project) ? new_project_feature_flags_user_list_path(@project) : nil } } + "user-list-path" => can?(current_user, :admin_feature_flags_user_lists, @project) ? project_feature_flags_user_lists_path(@project) : nil } } diff --git a/app/views/projects/feature_flags_user_lists/edit.html.haml b/app/views/projects/feature_flags_user_lists/edit.html.haml index ea47cc06c0e..1ff488ff0f0 100644 --- a/app/views/projects/feature_flags_user_lists/edit.html.haml +++ b/app/views/projects/feature_flags_user_lists/edit.html.haml @@ -1,4 +1,5 @@ - add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project) - breadcrumb_title s_('FeatureFlags|Edit User List') - page_title s_('FeatureFlags|Edit User List') diff --git a/app/views/projects/feature_flags_user_lists/index.html.haml b/app/views/projects/feature_flags_user_lists/index.html.haml new file mode 100644 index 00000000000..f0e3c36992a --- /dev/null +++ b/app/views/projects/feature_flags_user_lists/index.html.haml @@ -0,0 +1,8 @@ +- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- breadcrumb_title s_('FeatureFlags|User Lists') +- page_title s_('FeatureFlags|Feature Flag User Lists') + +#js-user-lists{ data: { project_id: @project.id, + feature_flags_help_page_path: help_page_path("operations/feature_flags"), + new_user_list_path: can?(current_user, :create_feature_flag, @project) ? new_project_feature_flags_user_list_path(@project): nil, + error_state_svg_path: image_path('illustrations/feature_flag.svg') } } diff --git a/app/views/projects/feature_flags_user_lists/new.html.haml b/app/views/projects/feature_flags_user_lists/new.html.haml index 3d25453cb66..f2e1ea38d9c 100644 --- a/app/views/projects/feature_flags_user_lists/new.html.haml +++ b/app/views/projects/feature_flags_user_lists/new.html.haml @@ -1,5 +1,6 @@ - @breadcrumb_link = new_project_feature_flags_user_list_path(@project) - add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project) - breadcrumb_title s_('FeatureFlags|New User List') - page_title s_('FeatureFlags|New User List') diff --git a/app/views/projects/feature_flags_user_lists/show.html.haml b/app/views/projects/feature_flags_user_lists/show.html.haml index add256f0190..2c88f3da66b 100644 --- a/app/views/projects/feature_flags_user_lists/show.html.haml +++ b/app/views/projects/feature_flags_user_lists/show.html.haml @@ -1,4 +1,5 @@ - add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project) +- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project) - breadcrumb_title s_('FeatureFlags|List details') - page_title s_('FeatureFlags|Feature Flag User List Details') diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 267fc3ae986..0716eda79a8 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -10,7 +10,8 @@ project_name: @project.name, project_path: @project.path, project_description: @project.description, - project_visibility: @project.visibility } } + project_visibility: @project.visibility, + restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } } - else .row.gl-mt-3 .col-lg-3 diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index 06522d9d434..1289f7aa0c4 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -15,8 +15,7 @@ = button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" - if show_new_issue_link?(@project) = link_to _("New issue"), new_project_issue_path(@project, - issue: { assignee_id: finder.assignee.try(:id), - milestone_id: finder.milestones.first.try(:id) }), + issue: { milestone_id: finder.milestones.first.try(:id) }), class: "gl-button btn btn-confirm", id: "new_issue_link" diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml index 819837a9eff..2d8b7315a29 100644 --- a/app/views/projects/jobs/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml @@ -12,7 +12,7 @@ = s_('Jobs|Use jobs to automate your tasks') %p = s_('Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.') - = link_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'btn gl-button btn-info js-empty-state-button' + = link_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'btn gl-button btn-confirm js-empty-state-button' - else .nothing-here-block= s_('Jobs|No jobs to show') - else diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml index 09466ed2244..80a58053ff7 100644 --- a/app/views/projects/merge_requests/_awards_block.html.haml +++ b/app/views/projects/merge_requests/_awards_block.html.haml @@ -1,5 +1,5 @@ -.content-block.content-block-small.emoji-list-container.js-noteable-awards +.content-block.emoji-block.emoji-list-container.js-noteable-awards = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) do - .ml-auto.mt-auto.mb-auto + .ml-auto.gl-my-2 #js-vue-sort-issue-discussions = render "projects/merge_requests/discussion_filter" diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index 5fa8f908122..f6afac493d5 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -1,10 +1,9 @@ - display_issuable_type = issuable_display_type(@merge_request) -- button_action_class = @merge_request.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary' - button_class = "btn gl-button #{!@merge_request.closed? && 'js-draft-toggle-button'}" - toggle_class = "btn gl-button dropdown-toggle" .float-left.btn-group.gl-ml-3.gl-display-none.gl-md-display-flex - = link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} #{button_action_class}" do + = link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} btn-confirm-secondary" do - if @merge_request.closed? = _('Reopen') = display_issuable_type @@ -12,9 +11,9 @@ = @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft') - if !@merge_request.closed? || !issuable_author_is_current_user(@merge_request) - = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do + = button_tag type: 'button', class: "#{toggle_class} btn-confirm-secondary btn-icon", data: { 'toggle' => 'dropdown' } do %span.gl-sr-only= _('Toggle dropdown') - = sprite_icon "angle-down", size: 12 + = sprite_icon "chevron-down", size: 12, css_class: "gl-button-icon" %ul.dropdown-menu.dropdown-menu-right - if @merge_request.open? diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index e42032fef66..5a983fb5565 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -7,9 +7,11 @@ = cache_if(Feature.enabled?(:cached_mr_title, @project, default_enabled: :yaml), cache_key, expires_in: 1.day) do - if @merge_request.closed_or_merged_without_fork? .gl-alert.gl-alert-danger.gl-mb-5 - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - The source project of this merge request has been removed. + .gl-alert-container + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + The source project of this merge request has been removed. .detail-page-header.border-bottom-0.pt-0.pb-0 .detail-page-header-body diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 606442d71a9..5f2cb1cfcc4 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -1,21 +1,22 @@ -- artifacts_endpoint_placeholder = ':pipeline_artifacts_id' += cache_if(Feature.enabled?(:cached_mr_widget, @merge_request.project), [@merge_request.project, @merge_request, current_user], expires_in: 10.minutes) do + - artifacts_endpoint_placeholder = ':pipeline_artifacts_id' -= javascript_tag do - :plain - window.gl = window.gl || {}; - window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} + = javascript_tag do + :plain + window.gl = window.gl || {}; + window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} - window.gl.mrWidgetData.artifacts_endpoint = '#{downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json)}'; - window.gl.mrWidgetData.artifacts_endpoint_placeholder = '#{artifacts_endpoint_placeholder}'; - window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; - window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}'; - window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}'; - window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; - window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}'; - window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}'; - window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; - window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}'; - window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; - window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}'; + window.gl.mrWidgetData.artifacts_endpoint = '#{downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json)}'; + window.gl.mrWidgetData.artifacts_endpoint_placeholder = '#{artifacts_endpoint_placeholder}'; + window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; + window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}'; + window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}'; + window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; + window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}'; + window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}'; + window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; + window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}'; + window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; + window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}'; -#js-vue-mr-widget.mr-widget + #js-vue-mr-widget.mr-widget diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml index 019015a4d86..5fcb5d3f876 100644 --- a/app/views/projects/merge_requests/edit.html.haml +++ b/app/views/projects/merge_requests/edit.html.haml @@ -1,3 +1,5 @@ +- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project) +- breadcrumb_title @merge_request.to_reference - page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests") %h3.page-title diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml index f0bf5af7732..fd1b2328a98 100644 --- a/app/views/projects/merge_requests/invalid.html.haml +++ b/app/views/projects/merge_requests/invalid.html.haml @@ -8,21 +8,23 @@ = render "projects/merge_requests/mr_box" .gl-alert.gl-alert-danger - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %p - We cannot render this merge request properly because - - if @merge_request.for_fork? && !@merge_request.source_project - fork project was removed - - elsif !@merge_request.source_branch_exists? - %span{ class: badge_inverse_css_classes }= @merge_request.source_branch - does not exist in - %span{ class: badge_info_css_classes }= @merge_request.source_project_path - - elsif !@merge_request.target_branch_exists? - %span{ class: badge_inverse_css_classes }= @merge_request.target_branch - does not exist in - %span{ class: badge_info_css_classes }= @merge_request.target_project_path - - else - of internal error + .gl-alert-container + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content{ role: 'alert' } + %p + We cannot render this merge request properly because + - if @merge_request.for_fork? && !@merge_request.source_project + fork project was removed + - elsif !@merge_request.source_branch_exists? + %span{ class: badge_inverse_css_classes }= @merge_request.source_branch + does not exist in + %span{ class: badge_info_css_classes }= @merge_request.source_project_path + - elsif !@merge_request.target_branch_exists? + %span{ class: badge_inverse_css_classes }= @merge_request.target_branch + does not exist in + %span{ class: badge_info_css_classes }= @merge_request.target_project_path + - else + of internal error - %strong - Please close merge request or change branches with existing one + %strong + Please close merge request or change branches with existing one diff --git a/app/views/projects/merge_requests/tabs/_tab.html.haml b/app/views/projects/merge_requests/tabs/_tab.html.haml index dcd8db90509..9d942da8098 100644 --- a/app/views/projects/merge_requests/tabs/_tab.html.haml +++ b/app/views/projects/merge_requests/tabs/_tab.html.haml @@ -2,6 +2,8 @@ - tab_class = local_assigns.fetch(:class, nil) - qa_selector = local_assigns.fetch(:qa_selector, nil) - id = local_assigns.fetch(:id, nil) +- attrs = { class: [tab_class, ("active" if params[:tab] == tab_name)], data: { qa_selector: qa_selector } } +- attrs[:id] = id if id.present? -%li{ class: [tab_class, ("active" if params[:tab] == tab_name)], id: id, data: { qa_selector: qa_selector } } +%li{ attrs } = yield diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 5a6c2c5faaf..99fe64723d9 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -13,14 +13,18 @@ - if can?(current_user, :read_issue, @project) && @milestone.total_issues_count == 0 .gl-alert.gl-alert-info.gl-mt-3.gl-mb-5{ data: { testid: 'no-issues-alert' } } - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - %span= _('Assign some issues to this milestone.') + .gl-alert-container + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + %span= _('Assign some issues to this milestone.') - elsif @milestone.complete? && @milestone.active? .gl-alert.gl-alert-success.gl-mt-3.gl-mb-5{ data: { testid: 'all-issues-closed-alert' } } - = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - %span= _('All issues for this milestone are closed. You may close this milestone now.') + .gl-alert-container + = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-content + .gl-alert-body + %span= _('All issues for this milestone are closed. You may close this milestone now.') = render 'shared/milestones/tabs', milestone: @milestone = render 'shared/milestones/sidebar', milestone: @milestone, project: @project, affix_offset: 153 diff --git a/app/views/projects/packages/infrastructure_registry/show.html.haml b/app/views/projects/packages/infrastructure_registry/show.html.haml new file mode 100644 index 00000000000..e5701343b83 --- /dev/null +++ b/app/views/projects/packages/infrastructure_registry/show.html.haml @@ -0,0 +1,15 @@ +- add_to_breadcrumbs _("Infrastructure Registry"), project_infrastructure_registry_index_path(@project) +- add_to_breadcrumbs @package.name, project_infrastructure_registry_index_path(@project) +- breadcrumb_title @package.version +- page_title _("Infrastructure Registry") +- @content_class = "limit-container-width" unless fluid_layout + +.row + .col-12 + #js-vue-packages-detail{ data: { package: package_from_presenter(@package), + can_delete: can?(current_user, :destroy_package, @project).to_s, + svg_path: image_path('illustrations/no-packages.svg'), + project_name: @project.name, + project_path: expose_url(@project.full_path), + terraform_help_path: help_page_path('user/infrastructure/index'), + project_list_url: project_infrastructure_registry_index_path(@project)} } diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 5b23234791b..67a2eeb7e7b 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -19,5 +19,3 @@ - link_start = '<a href="%{url}" target="_blank" class="gl-alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page } - link_end = '</a>'.html_safe = html_escape_once(s_('GitLabPages|Access Control is enabled for this Pages website; only authorized users will be able to access it. To make your website publicly available, navigate to your project\'s %{strong_start}Settings > General > Visibility%{strong_end} and select %{strong_start}Everyone%{strong_end} in pages section. Read the %{link_start}documentation%{link_end} for more information.')).html_safe % { link_start: link_start, link_end: link_end, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - .card-footer.gl-alert-info - = s_('GitLabPages|It may take up to 30 minutes before the site is available after the first deployment.') diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 628c4780cf2..66aee7dedf3 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -7,7 +7,7 @@ .form-group.row .col-md-9 = f.label :cron, _('Interval Pattern'), class: 'label-bold' - #interval-pattern-input{ data: { initial_interval: @schedule.cron } } + #interval-pattern-input{ data: { initial_interval: @schedule.cron, daily_limit: @schedule.daily_limit } } .form-group.row .col-md-9 = f.label :cron_timezone, _('Cron Timezone'), class: 'label-bold' diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 42bb8117766..9669b2e72dc 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -5,7 +5,10 @@ = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json, code_quality_walkthrough: params[:code_quality_walkthrough]), +- list_url = project_pipelines_path(@project, format: :json, code_quality_walkthrough: params[:code_quality_walkthrough]) +- add_page_startup_api_call list_url + +#pipelines-list-vue{ data: { endpoint: list_url, project_id: @project.id, params: params.to_json, "artifacts-endpoint" => downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json), @@ -21,4 +24,5 @@ "has-gitlab-ci" => has_gitlab_ci?(@project).to_s, "add-ci-yml-path" => can?(current_user, :create_pipeline, @project) && @project.present(current_user: current_user).add_ci_yml_path, "suggested-ci-templates" => experiment_suggested_ci_templates.to_json, - "code-quality-page-path" => @project.present(current_user: current_user).add_code_quality_ci_yml_path } } + "code-quality-page-path" => @project.present(current_user: current_user).add_code_quality_ci_yml_path, + "ci-runner-settings-path" => project_settings_ci_cd_path(@project, ci_runner_templates: true, anchor: 'js-runners-settings') } } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 93b0a525191..1c134d914e9 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -12,7 +12,7 @@ .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } - - if @pipeline.failed? && @pipeline.user_not_verified? + - if Gitlab.com? && show_cc_validation_alert?(@pipeline) #js-cc-validation-required-alert - if @pipeline.commit.present? @@ -28,7 +28,6 @@ - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url } = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } - #js-pipeline-notification{ data: { dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs') } } = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 0fa9fb7079b..d1b6db95392 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -25,8 +25,11 @@ title: _("Import members from another project") - if @project.allowed_to_share_with_group? .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } } - - if !membership_locked? - .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } + - if can_manage_project_members?(@project) && !membership_locked? + .js-invite-members-trigger{ data: { variant: 'success', + classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', + trigger_source: 'project-members-page', + display_text: _('Invite members') } } = render 'projects/invite_members_modal', project: @project - else @@ -51,52 +54,15 @@ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } = render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } - = render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access' + = render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access', groups_select_tag_data: { skip_groups: @skip_groups } - elsif !membership_locked? .invite-member= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) - elsif @project.allowed_to_share_with_group? - .invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access' - %ul.nav-links.mobile-separator.nav.nav-tabs - %li.nav-item - = link_to '#tab-members', class: ['nav-link', ('active' unless groups_tab_active?)], data: { toggle: 'tab' } do - %span - = _('Members') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @project_members.total_count - - if show_groups?(@group_links) - %li.nav-item - = link_to '#tab-groups', class: ['nav-link', ('active' if groups_tab_active?)] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do - %span - = _('Groups') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @group_links.count - - if show_invited_members?(@project, @invited_members) - %li.nav-item - = link_to '#tab-invited-members', class: 'nav-link', data: { toggle: 'tab' } do - %span - = _('Invited') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @invited_members.count - - if show_access_requests?(@project, @requesters) - %li.nav-item - = link_to '#tab-access-requests', class: 'nav-link', data: { toggle: 'tab' } do - %span - = _('Access requests') - %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count - .tab-content - #tab-members.tab-pane{ class: ('active' unless groups_tab_active?) } - .js-project-members-list{ data: { members_data: project_members_list_data_json(@project, @project_members, { param_name: :page, params: { search_groups: nil } }) } } - .loading - .gl-spinner.gl-spinner-md - - if show_groups?(@group_links) - #tab-groups.tab-pane{ class: ('active' if groups_tab_active?) } - .js-project-group-links-list{ data: { members_data: project_group_links_list_data_json(@project, @group_links) } } - .loading - .gl-spinner.gl-spinner-md - - if show_invited_members?(@project, @invited_members) - #tab-invited-members.tab-pane - .js-project-invited-members-list{ data: { members_data: project_members_list_data_json(@project, @invited_members) } } - .loading - .gl-spinner.gl-spinner-md - - if show_access_requests?(@project, @requesters) - #tab-access-requests.tab-pane - .js-project-access-requests-list{ data: { members_data: project_members_list_data_json(@project, @requesters) } } - .loading - .gl-spinner.gl-spinner-md + .invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access', groups_select_tag_data: { skip_groups: @skip_groups } + .js-project-members-list-app{ data: { members_data: project_members_app_data_json(@project, + members: @project_members, + group_links: @group_links, + invited: @invited_members, + access_requests: @requesters) } } + .loading + .gl-spinner.gl-spinner-md diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 2691513c994..b13117960dd 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -8,10 +8,11 @@ .flash-container %table.table.table-bordered %colgroup + %col{ width: "30%" } %col{ width: "20%" } %col{ width: "20%" } - %col{ width: "20%" } - %col{ width: "20%" } + %col{ width: "10%" } + %col{ width: "10%" } - if can_admin_project %col %thead @@ -22,12 +23,10 @@ = s_("ProtectedBranch|Allowed to merge") %th = s_("ProtectedBranch|Allowed to push") - - - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project, default_enabled: :yaml) - %th - = s_("ProtectedBranch|Allow force push") - %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow force push for all users with push access.'), 'aria-hidden': 'true' } - = sprite_icon('question', size: 16, css_class: 'gl-text-gray-500') + %th + = s_("ProtectedBranch|Allowed to force push") + %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow all users with push access to force push.'), 'aria-hidden': 'true' } + = sprite_icon('question', size: 16, css_class: 'gl-text-gray-500') = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table_head' diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index 9fdcea96c00..ad131b22924 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -21,13 +21,14 @@ = f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-md-2 text-left text-md-right' .col-md-10 = yield :push_access_levels - - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project, default_enabled: :yaml) - .form-group.row - = f.label :allow_force_push, s_("ProtectedBranch|Allow force push:"), class: 'col-md-2 gl-text-left text-md-right' - .col-md-10 - = render "shared/buttons/project_feature_toggle", class_list: "js-force-push-toggle project-feature-toggle" - .form-text.gl-text-gray-600.gl-mt-0 - = s_("ProtectedBranch|Allow force push for all users with push access.") + .form-group.row + = f.label :allow_force_push, s_("ProtectedBranch|Allowed to force push:"), class: 'col-md-2 gl-text-left text-md-right' + .col-md-10 + = render "shared/buttons/project_feature_toggle", class_list: "js-force-push-toggle project-feature-toggle" + .form-text.gl-text-gray-600.gl-mt-0 + - force_push_docs_url = help_page_url('topics/git/git_rebase', anchor: 'force-push') + - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } + = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe = render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f .card-footer = f.submit s_('ProtectedBranch|Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_button' } diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index c2a5efa7b7c..08246a173d8 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -14,7 +14,7 @@ %ul %li Allow only users with Maintainer #{link_to "permissions", help_page_path("user/permissions")} to create new protected branches. %li Allow only users with Maintainer permissions to push code. - %li Prevent <strong>anyone</strong> from force-pushing to the branch. + %li Prevent <strong>anyone</strong> from #{link_to "force-pushing", help_page_path('topics/git/git_rebase', anchor: 'force-push')} to the branch. %li Prevent <strong>anyone</strong> from deleting the branch. - if can? current_user, :admin_project, @project diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index f56fd7f557d..bdb5f021b70 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -18,6 +18,8 @@ "project_path": @project.full_path, "gid_prefix": container_repository_gid_prefix, "is_admin": current_user&.admin.to_s, + "show_cleanup_policy_on_alert": show_cleanup_policy_on_alert(@project).to_s, + "cleanup_policies_settings_path": project_settings_packages_and_registries_path(@project), character_error: @character_error.to_s, user_callouts_path: user_callouts_path, user_callout_id: UserCalloutsHelper::UNFINISHED_TAG_CLEANUP_CALLOUT, diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index 88895634990..210cc414007 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -7,6 +7,10 @@ = render partial: 'ci/runner/how_to_setup_runner_automatically', locals: { type: 'specific', clusters_path: project_clusters_path(@project) } + - if params[:ci_runner_templates] + %hr + = render partial: 'ci/runner/setup_runner_in_aws', + locals: { registration_token: @project.runners_token } %hr = render partial: 'ci/runner/how_to_setup_runner', locals: { registration_token: @project.runners_token, diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 3c99b4c5e68..724684c9a0a 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,15 +1,15 @@ -- if lookup_context.template_exists?('top', "projects/services/#{@service.to_param}", true) - = render "projects/services/#{@service.to_param}/top" +- if lookup_context.template_exists?('top', "projects/services/#{integration.to_param}", true) + = render "projects/services/#{integration.to_param}/top", integration: integration %h3.page-title - = @service.title - - if @service.operating? + = integration.title + - if integration.operating? = sprite_icon('check', css_class: 'gl-text-green-500') -= form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, @service) } }) do |form| - = render 'shared/service_settings', form: form, integration: @service - %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referrer } += form_for(integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_service_path(@project, integration) } }) do |form| + = render 'shared/service_settings', form: form, integration: integration + %input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referer } -- if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true) +- if lookup_context.template_exists?('show', "projects/services/#{integration.to_param}", true) %hr - = render "projects/services/#{@service.to_param}/show" + = render "projects/services/#{integration.to_param}/show", integration: integration diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index 1aaea50c8d5..a250daafdbb 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,8 +1,9 @@ -- breadcrumb_title @service.title +- breadcrumb_title @integration.title - add_to_breadcrumbs _('Integration Settings'), project_settings_integrations_path(@project) -- page_title @service.title, _('Integrations') +- page_title @integration.title, _('Integrations') - @content_class = 'limit-container-width' unless fluid_layout -= render 'form' += render 'form', integration: @integration + - if @web_hook_logs - = render partial: 'projects/hook_logs/index', locals: { hook: @service.service_hook, hook_logs: @web_hook_logs, project: @project } + = render partial: 'projects/hook_logs/index', locals: { hook: @integration.service_hook, hook_logs: @web_hook_logs, project: @project } diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index fe983961657..9d8ce186232 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -57,7 +57,7 @@ .form-group = label_tag :request_url, s_('MattermostService|Request URL'), class: 'col-12 col-form-label label-bold' .col-12.input-group - = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' + = text_field_tag :request_url, service_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#request_url', class: 'input-group-text') diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index 4a7757daebc..993df389fb0 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -10,8 +10,8 @@ %p.inline = s_("MattermostService|After you configure the integration, view your new Mattermost commands by entering") %kbd.inline /<trigger> help - - if !enabled && @service.project_level? - = render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service + - if !enabled && integration.project_level? + = render 'projects/services/mattermost_slash_commands/detailed_help', integration: integration -- if enabled && @service.project_level? - = render 'projects/services/mattermost_slash_commands/installation_info', subject: @service +- if enabled && integration.project_level? + = render 'projects/services/mattermost_slash_commands/installation_info', integration: integration diff --git a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml index 53ee363de53..38adc69dd5e 100644 --- a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml @@ -1,5 +1,5 @@ .services-installation-info - - unless @service.activated? + - unless integration.activated? .row .col-sm-9.offset-sm-3 = link_to new_project_mattermost_path(@project), class: 'btn gl-button btn-lg' do diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml index 3786b845692..a34aa22acbb 100644 --- a/app/views/projects/services/prometheus/_configuration_banner.html.haml +++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml @@ -1,26 +1,26 @@ %h4 - = s_('PrometheusService|Auto configuration') + = s_('PrometheusService|Prometheus cluster integration') -- if service.manual_configuration? +- if integration.manual_configuration? .info-well - = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration.') + = s_('PrometheusService|To use a Prometheus installed on a cluster, deactivate the manual configuration.') - else .container-fluid .row - - if service.prometheus_available? + - if integration.prometheus_available? .col-sm-2 .svg-container = image_tag 'illustrations/monitoring/getting_started.svg' .col-sm-10 %p.text-success.gl-mt-3 - = s_('PrometheusService|GitLab is managing Prometheus on your clusters.') + = s_('PrometheusService|You have a cluster with the Prometheus integration enabled.') = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button' - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' .col-sm-10 %p.gl-mt-3 - = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments.') - = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm' + = s_('PrometheusService|Configure GitLab to query a Prometheus installed in one of your clusters.') + = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm' %hr diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml index a901d5b3575..724950bcb39 100644 --- a/app/views/projects/services/prometheus/_custom_metrics.html.haml +++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml @@ -6,7 +6,7 @@ = link_to s_('PrometheusService|More information'), help_page_path('operations/metrics/index.md', anchor: 'adding-custom-metrics'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 - .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{@service.active}" } } + .card.custom-monitored-metrics.js-panel-custom-monitored-metrics{ data: { qa_selector: 'custom_metrics_container', active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } } .card-header %strong = s_('PrometheusService|Custom metrics') diff --git a/app/views/projects/services/prometheus/_external_alerts.html.haml b/app/views/projects/services/prometheus/_external_alerts.html.haml index b27b1ab8723..168b4853a9a 100644 --- a/app/views/projects/services/prometheus/_external_alerts.html.haml +++ b/app/views/projects/services/prometheus/_external_alerts.html.haml @@ -1,5 +1,5 @@ - return unless can?(current_user, :read_prometheus_alerts, @project) -- return unless @service.manual_configuration? +- return unless integration.manual_configuration? - notify_url = notify_project_prometheus_alerts_url(@project, format: :json) - authorization_key = @project.alerting_setting.try(:token) diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml index 04bd800c47a..9b3cb8893c4 100644 --- a/app/views/projects/services/prometheus/_help.html.haml +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -1,5 +1,5 @@ - if @project - = render 'projects/services/prometheus/configuration_banner', project: @project, service: @service + = render 'projects/services/prometheus/configuration_banner', project: @project, integration: integration %h4.gl-mb-3 = s_('PrometheusService|Manual configuration') diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 4bafd4d06e0..09fe77b8a9c 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -1,6 +1,6 @@ - project = local_assigns.fetch(:project) -= render 'projects/services/prometheus/custom_metrics', project: project += render 'projects/services/prometheus/custom_metrics', project: project, integration: integration .col-lg-3 %p diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 9ce61ed5c13..3350ac8a6c5 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -4,6 +4,6 @@ = s_('PrometheusService|Metrics') .row.gl-mb-3.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring - = render 'projects/services/prometheus/metrics', project: @project + = render 'projects/services/prometheus/metrics', project: @project, integration: integration -= render 'projects/services/prometheus/external_alerts', project: @project += render 'projects/services/prometheus/external_alerts', project: @project, integration: integration diff --git a/app/views/projects/services/prometheus/_top.html.haml b/app/views/projects/services/prometheus/_top.html.haml index db02ea85865..f7446273a80 100644 --- a/app/views/projects/services/prometheus/_top.html.haml +++ b/app/views/projects/services/prometheus/_top.html.haml @@ -1,4 +1,4 @@ -- return unless @service.manual_configuration? +- return unless integration.manual_configuration? .row .col-lg-12 diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index b68addcb093..9702f9b08f2 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -11,7 +11,7 @@ %p.inline = s_("SlackService|See list of available commands in Slack after setting up this service, by entering") %kbd.inline /<command> help - - if @service.project_level? + - if integration.project_level? %p= _("To set up this service:") %ul.list-unstyled.indent-list %li @@ -39,7 +39,7 @@ .form-group = label_tag :url, 'URL', class: 'col-12 col-form-label label-bold' .col-12.input-group - = text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' + = text_field_tag :url, service_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#url', class: 'input-group-text') diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 845fb299b74..0891e3e0526 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -15,8 +15,8 @@ .row .form-group.col-md-9 - = f.label :tag_list, _('Topics (optional)'), class: 'label-bold' - = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control gl-form-input" + = f.label :topics, _('Topics (optional)'), class: 'label-bold' + = f.text_field :topics, value: @project.topic_list.join(', '), maxlength: 2000, class: "form-control gl-form-input" %p.form-text.text-muted= _('Separate topics with commas.') = render_if_exists 'compliance_management/compliance_framework/project_settings', f: f diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index c4b5c23be13..c89c9879f4b 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -18,7 +18,7 @@ = f.label :auto_cancel_pending_pipelines, class: 'form-check-label' do %strong= _("Auto-cancel redundant pipelines") .form-text.text-muted - = _("New pipelines cause older pending pipelines on the same branch to be cancelled.") + = _("New pipelines cause older pending or running pipelines on the same branch to be cancelled.") = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'auto-cancel-redundant-pipelines'), target: '_blank' .form-group @@ -36,7 +36,7 @@ = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted = html_escape(_("The name of the CI/CD configuration file. A path relative to the root directory is optional (for example %{code_open}my/path/.myfile.yml%{code_close}).")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-path'), target: '_blank' + = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-file'), target: '_blank' %hr .form-group diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index d955dabd04c..ade3d40a8df 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -76,17 +76,7 @@ = render 'projects/triggers/index' - if settings_container_registry_expiration_policy_available?(@project) - %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _("Clean up image tags") - %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } - = expanded ? _('Collapse') : _('Expand') - %p - = _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.") - = link_to _('How does cleanup work?'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy'), target: '_blank', rel: 'noopener noreferrer' - .settings-content - = render 'projects/registry/settings/index' + = render 'projects/registry/settings/index' = render_if_exists 'projects/settings/ci_cd/auto_rollback', expanded: expanded diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index af37795a7c5..84635941436 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -2,18 +2,8 @@ - breadcrumb_title _('Integration Settings') - page_title _('Integrations') -- if show_webhooks_moved_alert? - .gl-alert.gl-alert-info.js-webhooks-moved-alert.gl-mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } } - = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', css_class: 'gl-icon') - .gl-alert-body - = _('Webhooks have moved. They can now be found under the Settings menu.') - .gl-alert-actions - = link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'gl-button btn gl-alert-action btn-info' - %h3= _('Integrations') - integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') } - webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) } %p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe } -= render 'shared/integrations/index', integrations: @services += render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index 0418d7df42d..34255af9cc6 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -6,7 +6,7 @@ %section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _('Alert integrations') + = _('Alerts') %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' } = _('Expand') %p diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml index 6fa6b23b0da..9803ffc3c4e 100644 --- a/app/views/projects/settings/operations/_configuration_banner.html.haml +++ b/app/views/projects/settings/operations/_configuration_banner.html.haml @@ -1,9 +1,9 @@ %b - = s_('PrometheusService|Auto configuration') + = s_('PrometheusService|Prometheus cluster integration') - if service.manual_configuration? .info-well.p-2.mt-2 - = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration.') + = s_('PrometheusService|To use a Prometheus installed on a cluster, deactivate the manual configuration.') - else .container-fluid .row @@ -13,12 +13,12 @@ = image_tag 'illustrations/monitoring/getting_started.svg' .col-sm-10 %p.text-success.gl-mt-3 - = s_('PrometheusService|GitLab manages Prometheus on your clusters.') + = s_('PrometheusService|You have a cluster with the Prometheus integration enabled.') = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'gl-button btn btn-default' - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' .col-sm-10 %p.gl-mt-3 - = s_('PrometheusService|Monitor your project’s environments by deploying and configuring Prometheus on your clusters.') - = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'gl-button btn btn-confirm' + = s_('PrometheusService|Configure GitLab to query a Prometheus installed in one of your clusters.') + = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm' diff --git a/app/views/projects/settings/operations/_tracing.html.haml b/app/views/projects/settings/operations/_tracing.html.haml index a591fa33096..343fd22c051 100644 --- a/app/views/projects/settings/operations/_tracing.html.haml +++ b/app/views/projects/settings/operations/_tracing.html.haml @@ -1,23 +1,13 @@ - setting = tracing_setting -- has_jaeger_url = setting.external_url.present? %section.settings.border-0.no-animate - .settings-header{ :class => "border-top" } + .settings-header{ :class => 'border-top' } %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _("Jaeger tracing") + = _('Tracing') %button.btn.btn-default.gl-button.js-settings-toggle{ type: 'button' } = _('Expand') %p - - if has_jaeger_url - - tracing_link = link_to sanitize(setting.external_url, scrubber: Rails::Html::TextOnlyScrubber.new), target: "_blank", rel: 'noopener noreferrer' do - %span - = _('Tracing') - = sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle') - - else - - tracing_link = link_to project_tracing_path(@project) do - %span - = _('Tracing') - = _("To open Jaeger from GitLab to view tracing from the %{link} page, add a URL to your Jaeger server.").html_safe % { link: tracing_link } + = _('Embed an image of your existing Jaeger server in GitLab.') = link_to _('Learn more.'), help_page_path('operations/tracing'), target: '_blank', rel: 'noopener noreferrer' .settings-content = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f| @@ -27,8 +17,8 @@ = form.label :external_url, _('Jaeger URL'), class: 'label-bold' = form.url_field :external_url, class: 'form-control gl-form-input', placeholder: 'https://jaeger.example.com' %p.form-text.text-muted - - jaeger_help_url = "https://www.jaegertracing.io/docs/getting-started/" + - jaeger_help_url = 'https://www.jaegertracing.io/docs/getting-started/' - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url } - - link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe - = _("Learn more about %{link_start_tag}Jaeger configuration%{link_end_tag}.").html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag } + - link_end_tag = "#{sprite_icon('external-link', css_class: 'gl-ml-2 gl-vertical-align-middle')}</a>".html_safe + = _('Learn more about %{link_start_tag}Jaeger configuration%{link_end_tag}.').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag } = f.submit _('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index af183046e1e..e2c1a00a587 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -3,11 +3,11 @@ - page_title title - breadcrumb_title title += render 'projects/settings/operations/metrics_dashboard' += render 'projects/settings/operations/tracing' += render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/alert_management' = render 'projects/settings/operations/incidents' -= render 'projects/settings/operations/error_tracking' -= render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service) -= render 'projects/settings/operations/metrics_dashboard' = render 'projects/settings/operations/grafana_integration' -= render 'projects/settings/operations/tracing' = render_if_exists 'projects/settings/operations/status_page' += render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service) diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 561ac7b347d..626ddc20431 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -1,16 +1,15 @@ - breadcrumb_title _('Packages & Registries') - page_title _('Packages & Registries') - @content_class = 'limit-container-width' unless fluid_layout -- expanded = true -%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = _("Clean up image tags") - %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } - = expanded ? _('Collapse') : _('Expand') - %p - = _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.") - = link_to _('How does cleanup work?'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy'), target: '_blank', rel: 'noopener noreferrer' - .settings-content - = render 'projects/registry/settings/index' +#js-registry-settings{ data: { project_id: @project.id, + project_path: @project.full_path, + cadence_options: cadence_options.to_json, + keep_n_options: keep_n_options.to_json, + older_than_options: older_than_options.to_json, + is_admin: current_user&.admin.to_s, + admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), + enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s, + help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy'), + show_cleanup_policy_on_alert: show_cleanup_policy_on_alert(@project).to_s, + tags_regex_help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'regex-pattern-examples') } } diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 90b79fddff1..4757f50739b 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -2,6 +2,7 @@ - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) - @content_class = "limit-container-width" unless fluid_layout - @skip_current_level_breadcrumb = true +- add_page_specific_style 'page_bundles/project' = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 340f9811f9a..60cc043f813 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,4 +1,4 @@ -.tree-ref-container +.tree-ref-container.gl-display-flex.mb-2.mb-md-0 .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true @@ -16,5 +16,5 @@ .project-clone-holder.d-none.d-md-inline-block> = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' - .project-clone-holder.d-block.d-md-none.mt-sm-2.mt-md-0.ml-sm-2> + .project-clone-holder.d-block.d-md-none.mt-sm-2.mt-md-0.ml-md-2> = render "shared/mobile_clone_panel" diff --git a/app/views/registrations/invites/new.html.haml b/app/views/registrations/invites/new.html.haml index 6e6ff7aaeee..0feae9b17e9 100644 --- a/app/views/registrations/invites/new.html.haml +++ b/app/views/registrations/invites/new.html.haml @@ -13,6 +13,5 @@ url: users_sign_up_invites_path, button_text: _('Continue'), show_omniauth_providers: social_signin_enabled?, - omniauth_providers_placement: :top, - suggestion_path: nil + omniauth_providers_placement: :top = render 'devise/shared/sign_in_link' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index d5fbee34fa0..4ba906dd02f 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -4,7 +4,7 @@ .gl-md-display-flex - if %w(issues merge_requests).include?(@scope) #js-search-sidebar{ class: search_bar_classes } - .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden + .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden = render partial: "search/results/empty" = render_if_exists 'shared/promotions/promote_advanced_search' - else @@ -14,7 +14,7 @@ .results.gl-md-display-flex.gl-mt-3 - if %w(issues merge_requests).include?(@scope) #js-search-sidebar{ class: search_bar_classes } - .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden + .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - if @scope == 'commits' %ul.content-list.commit-list = render partial: "search/results/commit", collection: @search_objects diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index cb8bab7d0de..da0adba88db 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -5,6 +5,10 @@ = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title .gl-text-gray-500.gl-my-3 - = sprintf(s_(' %{project_name}#%{issuable_iid} · created %{issuable_created} by %{author} · updated %{issuable_updated}'), { project_name: issuable.project.full_name, issuable_iid: issuable.iid, issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), issuable_updated: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe + = issuable_project_reference(issuable) + · + = sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe + · + = sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe .description.term.col-sm-10.gl-px-0 = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 6f70c927147..f788bf53a4c 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,13 +1,14 @@ - if show_auto_devops_implicitly_enabled_banner?(project, current_user) - .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.gl-alert.gl-alert-info - = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss'), class: 'hide-auto-devops-implicitly-enabled-banner alert-link', data: { project_id: project.id } } - = sprite_icon('close', css_class: 'gl-icon') + = render 'shared/global_alert', + variant: :info, + alert_class: 'qa-auto-devops-banner auto-devops-implicitly-enabled-banner', + close_button_class: 'hide-auto-devops-implicitly-enabled-banner', + close_button_data: { project_id: project.id } do .gl-alert-body = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found.") - - unless Gitlab.config.registry.enabled - %div - = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.') - .gl-alert-actions.gl-mt-3 + - unless Gitlab.config.registry.enabled + %div + = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.') + .gl-alert-actions = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-info' = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-2' diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml index ed52aa01047..96b128eb2ec 100644 --- a/app/views/shared/_confirm_fork_modal.html.haml +++ b/app/views/shared/_confirm_fork_modal.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title= _('Fork project?') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body.p-3 %p= _("You can’t %{tag_start}edit%{tag_end} files directly in this project. Fork this project and submit a merge request with your changes.") % { tag_start: '', tag_end: ''} .modal-footer diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index 4e7e5c9d3ba..8b13bb948ee 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title= _('Confirmation required') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body %p.text-danger.js-confirm-text diff --git a/app/views/shared/_global_alert.html.haml b/app/views/shared/_global_alert.html.haml new file mode 100644 index 00000000000..bebc72fe428 --- /dev/null +++ b/app/views/shared/_global_alert.html.haml @@ -0,0 +1,20 @@ +- icons = { info: 'information-o', warning: 'warning', success: 'check-circle', danger: 'error', tip: 'bulb' } + +- title = local_assigns.fetch(:title, nil) +- variant = local_assigns.fetch(:variant, :info) +- alert_class = local_assigns.fetch(:alert_class, nil) +- alert_data = local_assigns.fetch(:alert_data, nil) +- close_button_class = local_assigns.fetch(:close_button_class, nil) +- close_button_data = local_assigns.fetch(:close_button_data, nil) +- icon = icons[variant] + +%div{ role: 'alert', class: ["gl-alert-#{variant}", alert_class], data: alert_data } + %div{ class: [container_class, @content_class, 'gl-px-0!'] } + .gl-alert + = sprite_icon(icon, size: 16, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}") + %button.gl-alert-dismiss.js-close{ type: 'button', aria: { label: _('Close') }, class: close_button_class, data: close_button_data } + = sprite_icon('close', size: 16) + - if title + .gl-alert-title + = title + = yield diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index cf9ee1a5231..65e02341936 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -6,14 +6,8 @@ = f.label :import_url, class: 'label-bold' do %span = _('Git repository URL') - = f.text_field :import_url, - value: import_url.sanitized_url, - autocomplete: 'off', - class: 'form-control gl-form-input', - placeholder: 'https://gitlab.company.com/group/project.git', - required: true, - pattern: '(?:git|https?):\/\/.*/.*\.git$', - title: _('Please provide a valid URL ending with .git') + = f.text_field :import_url, value: import_url.sanitized_url, + autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true .row .form-group.col-md-6 diff --git a/app/views/shared/_search_settings.html.haml b/app/views/shared/_search_settings.html.haml index 2974b2bf4d0..7265f090967 100644 --- a/app/views/shared/_search_settings.html.haml +++ b/app/views/shared/_search_settings.html.haml @@ -1,3 +1,5 @@ +- return if @hide_search_settings + - container_class = local_assigns.fetch(:container_class, 'gl-mt-5') %div{ class: container_class } diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 7af356c0820..c70fce7a38f 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -7,7 +7,7 @@ .js-integration-help-html.gl-display-none -# All content below will be repositioned in Vue - if lookup_context.template_exists?('help', "projects/services/#{integration.to_param}", true) - = render "projects/services/#{integration.to_param}/help", subject: integration + = render "projects/services/#{integration.to_param}/help", integration: integration - elsif integration.help.present? .info-well .well-segment diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index 9d1970093b8..a5a411db8a0 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,8 +1,9 @@ %a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } = sprite_icon('chevron-double-lg-left', css_class: 'icon-chevron-double-lg-left') - = sprite_icon('chevron-double-lg-right', css_class: 'icon-chevron-double-lg-right') - %span.collapse-text= _("Collapse sidebar") + - if sidebar_refactor_disabled? + = sprite_icon('chevron-double-lg-right', css_class: 'icon-chevron-double-lg-right') + %span.collapse-text.gl-ml-3= _("Collapse sidebar") = button_tag class: 'close-nav-button', type: 'button' do = sprite_icon('close') - %span.collapse-text= _("Close sidebar") + %span.collapse-text.gl-ml-3= _("Close sidebar") diff --git a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml index 43081499920..9f230a4a09b 100644 --- a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml +++ b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml @@ -1,7 +1,5 @@ .block.time-tracking - %time-tracker{ ":time-estimate" => "issue.timeEstimate || 0", - ":time-spent" => "issue.timeSpent || 0", - ":human-time-estimate" => "issue.humanTimeEstimate", - ":human-time-spent" => "issue.humanTimeSpent", - ":limit-to-hours" => "timeTrackingLimitToHours", + %time-tracker{ ":limit-to-hours" => "timeTrackingLimitToHours", + ":issuable-iid" => "issue.iid ? issue.iid.toString() : ''", + ":full-path" => "issue.project ? issue.project.fullPath : ''", "root-path" => "#{root_url}" } diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml index cab0adf159b..d48e9f3d02e 100644 --- a/app/views/shared/file_hooks/_index.html.haml +++ b/app/views/shared/file_hooks/_index.html.haml @@ -19,9 +19,6 @@ %li .monospace = File.basename(file) - - if File.dirname(file).ends_with?('plugins') - .text-warning - = _('Plugins directory is deprecated and will be removed in 14.0. Please move this file into /file_hooks directory.') - else .card.bg-light.text-center diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml index 61c0e5c42f4..ca1d3d53f16 100644 --- a/app/views/shared/form_elements/_apply_template_warning.html.haml +++ b/app/views/shared/form_elements/_apply_template_warning.html.haml @@ -1,7 +1,7 @@ .form-group.row.js-template-warning.hidden.js-issuable-template-warning .col-sm-12 .warning_message.mb-0{ role: 'alert' } - %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": true, "aria-label": _("Close") } + %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": "true", "aria-label": _("Close") } = sprite_icon("close") %p diff --git a/app/views/shared/gitpod/_enable_gitpod_modal.html.haml b/app/views/shared/gitpod/_enable_gitpod_modal.html.haml index dacfbf63db8..6a8ff98a09e 100644 --- a/app/views/shared/gitpod/_enable_gitpod_modal.html.haml +++ b/app/views/shared/gitpod/_enable_gitpod_modal.html.haml @@ -4,7 +4,7 @@ .modal-header %h3.page-title= _('Enable Gitpod?') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × + %span{ "aria-hidden": "true" } × .modal-body.p-3 %p= (_("To use Gitpod you must first enable the feature in the integrations section of your %{user_prefs}.") % { user_prefs: link_to(_('user preferences'), profile_preferences_path(anchor: 'gitpod')) }).html_safe .modal-footer diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index de657e39453..e79719d41b0 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -24,14 +24,6 @@ = render 'shared/form_elements/description', model: issuable, form: form, project: project -- if issuable.respond_to?(:confidential) - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = form.check_box :confidential, class: 'form-check-input' - = form.label :confidential, class: 'form-check-label' do - This issue is confidential and should only be visible to team members with at least Reporter access. - = render 'shared/issuable/form/metadata', issuable: issuable, form: form, project: project, presenter: presenter = render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 3e89969f46e..c03697a4076 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -23,7 +23,7 @@ - checkbox_id = 'check-all-issues' %label.gl-sr-only{ for: checkbox_id }= _('Select all') = check_box_tag checkbox_id, nil, false, class: "check-all-issues left" - - if Feature.enabled?(:boards_filtered_search, @group) && is_epic_board + - if is_epic_board #js-board-filtered-search{ data: { full_path: @group&.full_path } } - else .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fb2019bef15..416c788603a 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -30,7 +30,8 @@ .block.reviewer.qa-reviewer-block = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in - = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar + - if @project.group.present? + = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type - if issuable_sidebar[:supports_milestone] - milestone = issuable_sidebar[:milestone] || {} @@ -42,7 +43,7 @@ = milestone[:title] - else = _('None') - .title.hide-collapsed + .hide-collapsed.gl-line-height-20.gl-mb-2.gl-text-gray-900{ data: { testid: "milestone_title" } } = _('Milestone') = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading') - if can_edit_issuable diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 86369b32e98..7416fda6b44 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -42,22 +42,8 @@ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] - options[:data].merge!(data) - - if directly_invite_members? - - options[:dropdown_class] += ' dropdown-extended-height' - - options[:footer_content] = true - - options[:wrapper_class] = 'js-sidebar-assignee-dropdown' - - options[:toggle_class] += ' js-invite-members-track' - - data['track-event'] = 'show_invite_members' - - options[:data].merge!(data) - - invite_text = _('Invite Members') - - track_label = 'edit_assignee' - - = dropdown_tag(title, options: options) do - %ul.dropdown-footer-list - %li - .js-invite-members-trigger{ data: { trigger_element: 'anchor', - display_text: invite_text, - event: 'click_invite_members', - label: track_label } } - - else - = dropdown_tag(title, options: options) + = render 'shared/issuable/sidebar_user_dropdown', + options: options, + wrapper_class: 'js-sidebar-assignee-dropdown', + track_label: 'edit_assignee', + trigger_source: "#{issuable_type}-assignee-dropdown" diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml index 1a8f1a2639f..bc76d292dd6 100644 --- a/app/views/shared/issuable/_sidebar_reviewers.html.haml +++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml @@ -39,4 +39,8 @@ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] - options[:data].merge!(data) - = dropdown_tag(title, options: options) + = render 'shared/issuable/sidebar_user_dropdown', + options: options, + wrapper_class: 'js-sidebar-reviewer-dropdown', + track_label: 'edit_reviewer', + trigger_source: "#{issuable_type}-reviewer-dropdown" diff --git a/app/views/shared/issuable/_sidebar_user_dropdown.html.haml b/app/views/shared/issuable/_sidebar_user_dropdown.html.haml new file mode 100644 index 00000000000..3a17db5acf8 --- /dev/null +++ b/app/views/shared/issuable/_sidebar_user_dropdown.html.haml @@ -0,0 +1,21 @@ +- options = local_assigns.fetch(:options) +- data = options[:data] + +- if directly_invite_members? + - options[:dropdown_class] += ' dropdown-extended-height' + - options[:footer_content] = true + - options[:wrapper_class] = local_assigns.fetch(:wrapper_class) + - options[:toggle_class] += ' js-invite-members-track' + - data['track-event'] = 'show_invite_members' + - data['track-label'] = local_assigns.fetch(:track_label) + + = dropdown_tag(data['dropdown-title'], options: options) do + %ul.dropdown-footer-list + %li + .js-invite-members-trigger{ data: { trigger_element: 'anchor', + display_text: _('Invite Members'), + event: 'click_invite_members', + trigger_source: local_assigns.fetch(:trigger_source), + label: data['track-label'] } } +- else + = dropdown_tag(data['dropdown-title'], options: options) diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 70e931ac164..1f391e8a321 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -37,10 +37,12 @@ data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) - if source_level < target_level - .gl-alert.gl-alert-warning.gl-mt-4 - = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - = visibilityMismatchString - %br - = _('Review the target project before submitting to avoid exposing %{source} changes.') % { source: source_visibility } + .gl-alert.gl-alert-warning.gl-alert-not-dismissible.gl-max-content.gl-mt-4 + .gl-alert-container + .gl-alert-content{ role: 'alert' } + = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + = visibilityMismatchString + %br + = _('Review the target project before submitting to avoid exposing %{source} changes.') % { source: source_visibility } %hr diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 366e819d252..1043eb49752 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -2,11 +2,19 @@ - issuable = local_assigns.fetch(:issuable) - presenter = local_assigns.fetch(:presenter) -- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) +- return unless can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable) - has_due_date = issuable.has_attribute?(:due_date) - form = local_assigns.fetch(:form) +- if issuable.respond_to?(:confidential) + .form-group.row + .offset-sm-2.col-sm-10 + .form-check + = form.check_box :confidential, class: 'form-check-input' + = form.label :confidential, class: 'form-check-label' do + This issue is confidential and should only be visible to team members with at least Reporter access. + %hr .row %div{ class: (has_due_date ? "col-lg-6" : "col-12") } diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml index ca2749b6bf9..26d30341999 100644 --- a/app/views/shared/issue_type/_emoji_block.html.haml +++ b/app/views/shared/issue_type/_emoji_block.html.haml @@ -4,7 +4,7 @@ .row.gl-m-0.gl-justify-content-space-between .js-noteable-awards = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path - .new-branch-col + .new-branch-col.gl-my-2 = render_if_exists "projects/issues/timeline_toggle", issuable: issuable #js-vue-sort-issue-discussions #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } } diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index d98ba074687..2aac3a94c49 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -29,7 +29,7 @@ = group_link.human_access = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3") .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable - = dropdown_title(_("Change permissions")) + = dropdown_title(_("Change role")) .dropdown-content %ul - Gitlab::Access.options_with_owner.each do |role, role_id| diff --git a/app/views/shared/members/_invite_group.html.haml b/app/views/shared/members/_invite_group.html.haml index d59f2950df6..cefdf825eaa 100644 --- a/app/views/shared/members/_invite_group.html.haml +++ b/app/views/shared/members/_invite_group.html.haml @@ -3,21 +3,25 @@ - submit_url = local_assigns[:submit_url] - group_link_field = local_assigns[:group_link_field] - group_access_field = local_assigns[:group_access_field] +- groups_select_tag_data = local_assigns[:groups_select_tag_data] + .row .col-sm-12 = form_tag submit_url, class: 'invite-group-form js-requires-input', method: :post do .form-group = label_tag group_link_field, _("Select a group to invite"), class: "label-bold" - = groups_select_tag(group_link_field, data: { skip_groups: @skip_groups }, class: 'input-clamp qa-group-select-field', required: true) + = groups_select_tag(group_link_field, data: groups_select_tag_data, class: 'input-clamp qa-group-select-field', required: true) + .form-text.text-muted.gl-mb-3 + = _('Group sharing provides access to all group members (including members who inherited group membership from a parent group).') .form-group - = label_tag group_access_field, _("Max access level"), class: "label-bold" + = label_tag group_access_field, _("Max role"), class: "label-bold" .select-wrapper = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control" = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") .form-text.text-muted.gl-mb-3 - permissions_docs_path = help_page_path('user/permissions') - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } - = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = _("%{link_start}Learn more%{link_end} about roles.").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .form-group = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' .clearable-input diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml index 49111c821b1..e6863ed56a5 100644 --- a/app/views/shared/members/_invite_member.html.haml +++ b/app/views/shared/members/_invite_member.html.haml @@ -10,14 +10,14 @@ = label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold" = users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite') .form-group - = label_tag :access_level, _("Choose a role permission"), class: "label-bold" + = label_tag :access_level, _("Select a role"), class: "label-bold" .select-wrapper = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control" = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") .form-text.text-muted.gl-mb-3 - permissions_docs_path = help_page_path('user/permissions') - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } - = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = _("%{link_start}Learn more%{link_end} about roles.").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .form-group = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' .clearable-input diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 88e2a74d235..8f334be0427 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -81,7 +81,7 @@ = member.human_access = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3") .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable - = dropdown_title(_("Change permissions")) + = dropdown_title(_("Change role")) .dropdown-content %ul - member.valid_level_roles.each do |role, role_id| diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index a62ed009552..184904dd7ab 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -3,7 +3,7 @@ - labels = issuable.labels - assignees = issuable.assignees - base_url_args = [project] -- issuable_type_args = base_url_args + [issuable.class.table_name] +- issuable_type_args = base_url_args + [issuable.class.table_name.to_sym] - issuable_url_args = base_url_args + [issuable] %li.issuable-row diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 0088cd35781..56b2b0d5801 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -98,6 +98,7 @@ time_spent: @milestone.total_time_spent, human_time_estimate: @milestone.human_total_time_estimate, human_time_spent: @milestone.human_total_time_spent, + iid: @milestone.iid, limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } } = render_if_exists 'shared/milestones/weight', milestone: milestone diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml index 2f10914ef3d..cbee0e0429c 100644 --- a/app/views/shared/nav/_scope_menu.html.haml +++ b/app/views/shared/nav/_scope_menu.html.haml @@ -1,6 +1,6 @@ -.context-header - = link_to scope_menu.link, **scope_menu.container_html_options do - %span.avatar-container.rect-avatar.s40.project-avatar - = source_icon(scope_menu.container, alt: scope_menu.title, class: 'avatar s40 avatar-tile', width: 40, height: 40) - %span.sidebar-context-title - = scope_menu.title +- if sidebar_refactor_enabled? + = nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) do + = render 'shared/nav/scope_menu_body', scope_menu: scope_menu +- else + .context-header + = render 'shared/nav/scope_menu_body', scope_menu: scope_menu diff --git a/app/views/shared/nav/_scope_menu_body.html.haml b/app/views/shared/nav/_scope_menu_body.html.haml new file mode 100644 index 00000000000..a94c681e2d3 --- /dev/null +++ b/app/views/shared/nav/_scope_menu_body.html.haml @@ -0,0 +1,8 @@ +- avatar_size = sidebar_refactor_disabled? ? 40 : 32 +- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32' + += link_to scope_menu.link, **scope_menu.container_html_options, data: { qa_selector: 'project_scope_link' } do + %span{ class: ['avatar-container', 'rect-avatar', 'project-avatar', avatar_size_class] } + = source_icon(scope_menu.container, alt: scope_menu.title, class: ['avatar', 'avatar-tile', avatar_size_class], width: avatar_size, height: avatar_size) + %span.sidebar-context-title + = scope_menu.title diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml index 552dcbfd6fd..54c3b8a281d 100644 --- a/app/views/shared/nav/_sidebar.html.haml +++ b/app/views/shared/nav/_sidebar.html.haml @@ -1,11 +1,13 @@ %aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label } .nav-sidebar-inner-scroll - - if sidebar.scope_menu + - if sidebar.scope_menu && sidebar_refactor_disabled? = render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu - elsif sidebar.render_raw_scope_menu_partial = render sidebar.render_raw_scope_menu_partial %ul.sidebar-top-level-items.qa-project-sidebar + - if sidebar.scope_menu && sidebar_refactor_enabled? + = render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu - if sidebar.renderable_menus.any? = render partial: 'shared/nav/sidebar_menu', collection: sidebar.renderable_menus - if sidebar.render_raw_menus_partial diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml index 67c775d1a85..b80bd515a32 100644 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ b/app/views/shared/nav/_sidebar_menu.html.haml @@ -15,13 +15,13 @@ %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do - = link_to sidebar_menu.link, **sidebar_menu.collapsed_container_html_options do - %strong.fly-out-top-item-name - = sidebar_menu.title - - if sidebar_menu.has_pill? - %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } - = number_with_delimiter(sidebar_menu.pill_count) + - if sidebar_refactor_disabled? + = link_to sidebar_menu.link, class: "'has-sub-items' if sidebar_menu.has_renderable_items?", **sidebar_menu.collapsed_container_html_options do + = render 'shared/nav/sidebar_menu_collapsed', sidebar_menu: sidebar_menu + - else + %span.fly-out-top-item-container + = render 'shared/nav/sidebar_menu_collapsed', sidebar_menu: sidebar_menu - - if sidebar_menu.has_items? + - if sidebar_menu.has_renderable_items? %li.divider.fly-out-top-item = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items diff --git a/app/views/shared/nav/_sidebar_menu_collapsed.html.haml b/app/views/shared/nav/_sidebar_menu_collapsed.html.haml new file mode 100644 index 00000000000..78567a991df --- /dev/null +++ b/app/views/shared/nav/_sidebar_menu_collapsed.html.haml @@ -0,0 +1,5 @@ +%strong.fly-out-top-item-name + = sidebar_menu.title +- if sidebar_menu.has_pill? + %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } + = number_with_delimiter(sidebar_menu.pill_count) diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml index 0b0e4c7aec9..674ce593ee2 100644 --- a/app/views/shared/nav/_sidebar_menu_item.html.haml +++ b/app/views/shared/nav/_sidebar_menu_item.html.haml @@ -1,4 +1,4 @@ -= nav_link(**sidebar_menu_item.active_routes) do += nav_link(**sidebar_menu_item.active_routes, html_options: sidebar_menu_item.nav_link_html_options) do = link_to sidebar_menu_item.link, **sidebar_menu_item.container_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do %span = sidebar_menu_item.title diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml index 6a362866f41..3cbe35e5c15 100644 --- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml @@ -33,6 +33,5 @@ %p.small = _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence } -- if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project, default_enabled: :yaml) - %td - = render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allow force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name } +%td + = render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allowed to force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name } diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml index b83def8b802..e0cc1e924d8 100644 --- a/app/views/shared/runners/_runner_type_alert.html.haml +++ b/app/views/shared/runners/_runner_type_alert.html.haml @@ -5,16 +5,16 @@ = s_('Runners|This runner is available to all groups and projects in your GitLab instance.') .gl-alert-body = s_('Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.') - = link_to _('Learn more.'), help_page_path('ci/runners/README', anchor: 'shared-runners'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'shared-runners'), target: '_blank', rel: 'noopener noreferrer' - elsif runner.group_type? %h4.gl-alert-title = s_('Runners|This runner is available to all projects and subgroups in a group.') .gl-alert-body = s_('Runners|Use Group runners when you want all projects in a group to have access to a set of runners.') - = link_to _('Learn more.'), help_page_path('ci/runners/README', anchor: 'group-runners'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'group-runners'), target: '_blank', rel: 'noopener noreferrer' - else %h4.gl-alert-title = s_('Runners|This runner is associated with specific projects.') .gl-alert-body = s_('Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.') - = link_to _('Learn more.'), help_page_path('ci/runners/README', anchor: 'specific-runners'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'specific-runners'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml index 19167f04855..0eeceac28c8 100644 --- a/app/views/shared/wikis/diff.html.haml +++ b/app/views/shared/wikis/diff.html.haml @@ -5,7 +5,7 @@ .wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row = wiki_sidebar_toggle_button - %h3.page-title.gl-flex-fill-1 + %h3.page-title.gl-flex-grow-1 = link_to_wiki_page @page %span.light · diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml index 4bdeee3996f..729646c2731 100644 --- a/app/views/shared/wikis/edit.html.haml +++ b/app/views/shared/wikis/edit.html.haml @@ -7,7 +7,7 @@ .wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row = wiki_sidebar_toggle_button - %h3.page-title.gl-flex-fill-1 + %h3.page-title.gl-flex-grow-1 - if @page.persisted? = link_to_wiki_page @page %span.light diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml index 1889b6501c9..c1918198594 100644 --- a/app/views/shared/wikis/pages.html.haml +++ b/app/views/shared/wikis/pages.html.haml @@ -5,7 +5,7 @@ - add_page_specific_style 'page_bundles/wiki' .wiki-page-header.top-area.flex-column.flex-lg-row - %h3.page-title.gl-flex-fill-1 + %h3.page-title.gl-flex-grow-1 = s_("Wiki|Wiki Pages") .nav-controls.pb-md-3.pb-lg-0 diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index a5b95883361..effd58ad200 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -23,7 +23,7 @@ data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }> = sprite_icon('error') - else - = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn gl-button btn-default btn-icon', + = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referer), class: link_classes + 'btn gl-button btn-default btn-icon', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = sprite_icon('error') - if can?(current_user, :read_user_profile, @user) @@ -56,6 +56,9 @@ .user-info .cover-title{ itemprop: 'name' } = @user.name + - if @user.pronouns.present? + %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle + = "(#{@user.pronouns})" - if @user&.status && user_status_set_to_busy?(@user.status) %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)") diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b216c2bff28..31c590183d1 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -21,6 +21,24 @@ :weight: 1 :idempotent: true :tags: [] +- :name: authorized_project_update:authorized_project_update_project_recalculate + :worker_name: AuthorizedProjectUpdate::ProjectRecalculateWorker + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :high + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: authorized_project_update:authorized_project_update_user_refresh_from_replica + :worker_name: AuthorizedProjectUpdate::UserRefreshFromReplicaWorker + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range :worker_name: AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker :feature_category: :authentication_and_authorization @@ -144,16 +162,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: cronjob:analytics_instance_statistics_count_job_trigger - :worker_name: Analytics::InstanceStatistics::CountJobTriggerWorker - :feature_category: :devops_reports - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: - - :exclude_from_kubernetes - :name: cronjob:analytics_usage_trends_count_job_trigger :worker_name: Analytics::UsageTrends::CountJobTriggerWorker :feature_category: :devops_reports @@ -423,15 +431,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: cronjob:prune_web_hook_logs - :worker_name: PruneWebHookLogsWorker - :feature_category: :integrations - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] - :name: cronjob:releases_manage_evidence :worker_name: Releases::ManageEvidenceWorker :feature_category: :release_evidence @@ -477,7 +476,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: cronjob:repository_archive_cache :worker_name: RepositoryArchiveCacheWorker @@ -647,15 +646,6 @@ :idempotent: :tags: - :exclude_from_kubernetes -- :name: deployment:deployments_execute_hooks - :worker_name: Deployments::ExecuteHooksWorker - :feature_category: :continuous_delivery - :has_external_dependencies: - :urgency: :low - :resource_boundary: :cpu - :weight: 3 - :idempotent: - :tags: [] - :name: deployment:deployments_finished :worker_name: Deployments::FinishedWorker :feature_category: :continuous_delivery @@ -827,15 +817,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: gcp_cluster:clusters_cleanup_app - :worker_name: Clusters::Cleanup::AppWorker - :feature_category: :kubernetes_management - :has_external_dependencies: true - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] - :name: gcp_cluster:clusters_cleanup_project_namespace :worker_name: Clusters::Cleanup::ProjectNamespaceWorker :feature_category: :kubernetes_management @@ -1088,15 +1069,6 @@ :weight: 2 :idempotent: :tags: [] -- :name: incident_management:incident_management_process_alert - :worker_name: IncidentManagement::ProcessAlertWorker - :feature_category: :incident_management - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 2 - :idempotent: - :tags: [] - :name: incident_management:incident_management_process_alert_worker_v2 :worker_name: IncidentManagement::ProcessAlertWorkerV2 :feature_category: :incident_management @@ -1106,15 +1078,6 @@ :weight: 2 :idempotent: true :tags: [] -- :name: incident_management:incident_management_process_prometheus_alert - :worker_name: IncidentManagement::ProcessPrometheusAlertWorker - :feature_category: :incident_management - :has_external_dependencies: - :urgency: :low - :resource_boundary: :cpu - :weight: 2 - :idempotent: - :tags: [] - :name: jira_connect:jira_connect_sync_branch :worker_name: JiraConnect::SyncBranchWorker :feature_category: :integrations @@ -1317,6 +1280,15 @@ :weight: 1 :idempotent: :tags: [] +- :name: package_repositories:packages_debian_generate_distribution + :worker_name: Packages::Debian::GenerateDistributionWorker + :feature_category: :package_registry + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_repositories:packages_debian_process_changes :worker_name: Packages::Debian::ProcessChangesWorker :feature_category: :package_registry @@ -1401,8 +1373,7 @@ :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: - - :exclude_from_kubernetes + :tags: [] - :name: pipeline_background:ci_pipeline_artifacts_create_quality_report :worker_name: Ci::PipelineArtifacts::CreateQualityReportWorker :feature_category: :code_testing @@ -1457,7 +1428,7 @@ :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: true + :idempotent: :tags: [] - :name: pipeline_creation:create_pipeline :worker_name: CreatePipelineWorker @@ -1564,7 +1535,7 @@ :worker_name: PipelineHooksWorker :feature_category: :continuous_integration :has_external_dependencies: - :urgency: :high + :urgency: :low :resource_boundary: :cpu :weight: 2 :idempotent: @@ -1639,15 +1610,6 @@ :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: - :tags: [] -- :name: pipeline_processing:pipeline_update - :worker_name: PipelineUpdateWorker - :feature_category: :continuous_integration - :has_external_dependencies: - :urgency: :high - :resource_boundary: :unknown - :weight: 5 :idempotent: true :tags: [] - :name: pipeline_processing:stage_update @@ -1777,16 +1739,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: analytics_instance_statistics_counter_job - :worker_name: Analytics::InstanceStatistics::CounterJobWorker - :feature_category: :devops_reports - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: - - :exclude_from_kubernetes - :name: analytics_usage_trends_counter_job :worker_name: Analytics::UsageTrends::CounterJobWorker :feature_category: :devops_reports @@ -2116,15 +2068,6 @@ :idempotent: true :tags: - :exclude_from_kubernetes -- :name: git_garbage_collect - :worker_name: GitGarbageCollectWorker - :feature_category: :gitaly - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] - :name: github_import_advance_stage :worker_name: Gitlab::GithubImport::AdvanceStageWorker :feature_category: :importers @@ -2292,15 +2235,6 @@ :weight: 1 :idempotent: true :tags: [] -- :name: merge_requests_assignees_change - :worker_name: MergeRequests::AssigneesChangeWorker - :feature_category: :source_code_management - :has_external_dependencies: - :urgency: :high - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: merge_requests_delete_source_branch :worker_name: MergeRequests::DeleteSourceBranchWorker :feature_category: :source_code_management @@ -2570,15 +2504,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: project_schedule_bulk_repository_shard_moves - :worker_name: ProjectScheduleBulkRepositoryShardMovesWorker - :feature_category: :gitaly - :has_external_dependencies: - :urgency: :throttled - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: project_service :worker_name: ProjectServiceWorker :feature_category: :integrations @@ -2588,15 +2513,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: project_update_repository_storage - :worker_name: ProjectUpdateRepositoryStorageWorker - :feature_category: :gitaly - :has_external_dependencies: - :urgency: :throttled - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: projects_git_garbage_collect :worker_name: Projects::GitGarbageCollectWorker :feature_category: :gitaly @@ -2811,24 +2727,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: snippet_schedule_bulk_repository_shard_moves - :worker_name: SnippetScheduleBulkRepositoryShardMovesWorker - :feature_category: :gitaly - :has_external_dependencies: - :urgency: :throttled - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] -- :name: snippet_update_repository_storage - :worker_name: SnippetUpdateRepositoryStorageWorker - :feature_category: :gitaly - :has_external_dependencies: - :urgency: :throttled - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: [] - :name: snippets_schedule_bulk_repository_shard_moves :worker_name: Snippets::ScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly @@ -2901,16 +2799,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: users_update_open_issue_count - :worker_name: Users::UpdateOpenIssueCountWorker - :feature_category: :users - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :tags: - - :exclude_from_kubernetes - :name: web_hook :worker_name: WebHookWorker :feature_category: :integrations @@ -2930,6 +2818,15 @@ :idempotent: true :tags: - :exclude_from_kubernetes +- :name: web_hooks_log_execution + :worker_name: WebHooks::LogExecutionWorker + :feature_category: :integrations + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: wikis_git_garbage_collect :worker_name: Wikis::GitGarbageCollectWorker :feature_category: :gitaly diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb deleted file mode 100644 index 083c01b166d..00000000000 --- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Analytics - module InstanceStatistics - # This worker will be removed in 14.0 - class CountJobTriggerWorker - include ApplicationWorker - - sidekiq_options retry: 3 - include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - - feature_category :devops_reports - tags :exclude_from_kubernetes - urgency :low - - idempotent! - - def perform - # Delegate to the new worker - Analytics::UsageTrends::CountJobTriggerWorker.new.perform - end - end - end -end diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb deleted file mode 100644 index a4dda45ff72..00000000000 --- a/app/workers/analytics/instance_statistics/counter_job_worker.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Analytics - module InstanceStatistics - # This worker will be removed in 14.0 - class CounterJobWorker - include ApplicationWorker - - sidekiq_options retry: 3 - - feature_category :devops_reports - urgency :low - tags :exclude_from_kubernetes - - idempotent! - - def perform(*args) - # Delegate to the new worker - Analytics::UsageTrends::CounterJobWorker.new.perform(*args) - end - end - end -end diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb new file mode 100644 index 00000000000..3f0672992ef --- /dev/null +++ b/app/workers/authorized_project_update/project_recalculate_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class ProjectRecalculateWorker + include ApplicationWorker + include Gitlab::ExclusiveLeaseHelpers + + feature_category :authentication_and_authorization + urgency :high + queue_namespace :authorized_project_update + + deduplicate :until_executing, including_scheduled: true + idempotent! + + def perform(project_id) + project = Project.find_by_id(project_id) + return unless project + + in_lock(lock_key(project), ttl: 10.seconds) do + AuthorizedProjectUpdate::ProjectRecalculateService.new(project).execute + end + end + + private + + def lock_key(project) + "#{self.class.name.underscore}/#{project.root_namespace.id}" + end + end +end diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb new file mode 100644 index 00000000000..5ca9de63fd7 --- /dev/null +++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + class UserRefreshFromReplicaWorker < ::AuthorizedProjectsWorker + feature_category :authentication_and_authorization + urgency :low + queue_namespace :authorized_project_update + deduplicate :until_executing, including_scheduled: true + + idempotent! + + # This worker will start reading data from the replica database soon + # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/333219 + end +end diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb index 2e4e2dd3232..ab4d9c13422 100644 --- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb @@ -2,10 +2,9 @@ module AuthorizedProjectUpdate class UserRefreshOverUserRangeWorker # rubocop:disable Scalability/IdempotentWorker - # When the feature flag named `periodic_project_authorization_update_via_replica` is enabled, - # this worker checks if a specific user requires an update to their project_authorizations records. + # This worker checks if users requires an update to their project_authorizations records. # This check is done via the data read from the database replica (and not from the primary). - # If this check returns true, a completely new Sidekiq job is enqueued for this specific user + # If this check returns true, a completely new Sidekiq job is enqueued for a specific user # so as to update its project_authorizations records. # There is a possibility that the data in the replica is lagging behind the primary @@ -24,25 +23,16 @@ module AuthorizedProjectUpdate # `data_consistency :delayed` and not `idempotent!` # See https://gitlab.com/gitlab-org/gitlab/-/issues/325291 deduplicate :until_executing, including_scheduled: true - data_consistency :delayed, feature_flag: :delayed_consistency_for_user_refresh_over_range_worker + data_consistency :delayed def perform(start_user_id, end_user_id) - if Feature.enabled?(:periodic_project_authorization_update_via_replica) - User.where(id: start_user_id..end_user_id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord - enqueue_project_authorizations_refresh(user) if project_authorizations_needs_refresh?(user) - end - else - use_primary_database - AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute + User.where(id: start_user_id..end_user_id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord + enqueue_project_authorizations_refresh(user) if project_authorizations_needs_refresh?(user) end end private - def use_primary_database - # no-op in CE, overriden in EE - end - def project_authorizations_needs_refresh?(user) AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(user).needs_refresh? end @@ -54,5 +44,3 @@ module AuthorizedProjectUpdate end end end - -AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker.prepend_mod_with('AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker') diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index be79d6b2afb..a0d1d9dca45 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -9,17 +9,7 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker queue_namespace :pipeline_hooks feature_category :continuous_integration urgency :high - data_consistency :delayed, feature_flag: :load_balancing_for_build_hooks_worker - - DATA_CONSISTENCY_DELAY = 3 - - def self.perform_async(*args) - if Feature.enabled?(:delayed_perform_for_build_hooks_worker, default_enabled: :yaml) - perform_in(DATA_CONSISTENCY_DELAY.seconds, *args) - else - super - end - end + data_consistency :delayed # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index e9bb2d88a81..aa3c03f773e 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -10,11 +10,12 @@ class BuildQueueWorker # rubocop:disable Scalability/IdempotentWorker feature_category :continuous_integration urgency :high worker_resource_boundary :cpu + data_consistency :sticky, feature_flag: :load_balancing_for_build_queue_worker # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| - Ci::UpdateBuildQueueService.new.execute(build) + Ci::UpdateBuildQueueService.new.tick(build) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 8ad31c68374..25a86ead76e 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -15,7 +15,8 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker @bulk_import = BulkImport.find_by_id(bulk_import_id) return unless @bulk_import - return if @bulk_import.finished? + return if @bulk_import.finished? || @bulk_import.failed? + return @bulk_import.fail_op! if all_entities_failed? return @bulk_import.finish! if all_entities_processed? && @bulk_import.started? return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running @@ -55,6 +56,10 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker entities.all? { |entity| entity.finished? || entity.failed? } end + def all_entities_failed? + entities.all? { |entity| entity.failed? } + end + def max_batch_size_exceeded? started_entities.count >= DEFAULT_BATCH_SIZE end diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index cccc24d3bdc..24e75ad0f85 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -24,7 +24,7 @@ module BulkImports end def http_client(configuration) - @client ||= Clients::Http.new( + @client ||= Clients::HTTP.new( uri: configuration.url, token: configuration.access_token ) diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 256301bf097..d3297017714 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -4,6 +4,8 @@ module BulkImports class PipelineWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + NDJSON_PIPELINE_PERFORM_DELAY = 1.minute + feature_category :importers tags :exclude_from_kubernetes @@ -40,6 +42,15 @@ module BulkImports private def run(pipeline_tracker) + if ndjson_pipeline?(pipeline_tracker) + status = ExportStatus.new(pipeline_tracker, pipeline_tracker.pipeline_class.relation) + + raise(Pipeline::ExpiredError, 'Pipeline timeout') if job_timeout?(pipeline_tracker) + raise(Pipeline::FailedError, status.error) if status.failed? + + return reenqueue(pipeline_tracker) if status.started? + end + pipeline_tracker.update!(status_event: 'start', jid: jid) context = ::BulkImports::Pipeline::Context.new(pipeline_tracker) @@ -48,7 +59,7 @@ module BulkImports pipeline_tracker.finish! rescue StandardError => e - pipeline_tracker.fail_op! + pipeline_tracker.update!(status_event: 'fail_op', jid: jid) logger.error( worker: self.class.name, @@ -67,5 +78,17 @@ module BulkImports def logger @logger ||= Gitlab::Import::Logger.build end + + def ndjson_pipeline?(pipeline_tracker) + pipeline_tracker.pipeline_class.ndjson_pipeline? + end + + def job_timeout?(pipeline_tracker) + (Time.zone.now - pipeline_tracker.entity.created_at) > Pipeline::NDJSON_EXPORT_TIMEOUT + end + + def reenqueue(pipeline_tracker) + self.class.perform_in(NDJSON_PIPELINE_PERFORM_DELAY, pipeline_tracker.id, pipeline_tracker.stage, pipeline_tracker.entity.id) + end end end diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 4dace43298d..ca41a7fb577 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -15,7 +15,7 @@ module Ci def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - Ci::ProcessPipelineService + Ci::PipelineCreation::StartPipelineService .new(pipeline) .execute end diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb index dd7bfff4eb1..ec0cb69d0c7 100644 --- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb @@ -9,7 +9,6 @@ module Ci include PipelineBackgroundQueue feature_category :code_testing - tags :exclude_from_kubernetes idempotent! diff --git a/app/workers/clusters/cleanup/app_worker.rb b/app/workers/clusters/cleanup/app_worker.rb deleted file mode 100644 index 1d01cec174b..00000000000 --- a/app/workers/clusters/cleanup/app_worker.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Cleanup - class AppWorker # rubocop:disable Scalability/IdempotentWorker - include ClusterCleanupMethods - - def perform(cluster_id, execution_count = 0) - Clusters::Cluster.with_persisted_applications.find_by_id(cluster_id).try do |cluster| - break unless cluster.cleanup_uninstalling_applications? - - break exceeded_execution_limit(cluster) if exceeded_execution_limit?(execution_count) - - ::Clusters::Cleanup::AppService.new(cluster, execution_count).execute - end - end - end - end -end diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 843be4896a3..3cba1eb31c5 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -13,6 +13,7 @@ module ApplicationWorker include Gitlab::SidekiqVersioning::Worker LOGGING_EXTRA_KEY = 'extra' + DEFAULT_DELAY_INTERVAL = 1 included do set_queue @@ -51,6 +52,16 @@ module ApplicationWorker subclass.after_set_class_attribute { subclass.set_queue } end + def perform_async(*args) + # Worker execution for workers with data_consistency set to :delayed or :sticky + # will be delayed to give replication enough time to complete + if utilizes_load_balancing_capabilities? + perform_in(delay_interval, *args) + else + super + end + end + def set_queue queue_name = ::Gitlab::SidekiqConfig::WorkerRouter.global.route(self) sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue @@ -111,5 +122,11 @@ module ApplicationWorker Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule) end end + + protected + + def delay_interval + DEFAULT_DELAY_INTERVAL.seconds + end end end diff --git a/app/workers/concerns/security_scans_queue.rb b/app/workers/concerns/security_scans_queue.rb index f731317bb37..27e97169926 100644 --- a/app/workers/concerns/security_scans_queue.rb +++ b/app/workers/concerns/security_scans_queue.rb @@ -8,6 +8,6 @@ module SecurityScansQueue included do queue_namespace :security_scans - feature_category :static_application_security_testing + feature_category :vulnerability_management end end diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index 6dee9402691..096be808787 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -71,6 +71,20 @@ module WorkerAttributes class_attributes[:urgency] || :low end + # Allows configuring worker's data_consistency. + # + # Worker can utilize Sidekiq readonly database replicas capabilities by setting data_consistency attribute. + # Workers with data_consistency set to :delayed or :sticky, calling #perform_async + # will be delayed in order to give replication process enough time to complete. + # + # - *data_consistency* values: + # - 'always' - The job is required to use the primary database (default). + # - 'sticky' - The uses a replica as long as possible. It switches to primary either on write or long replication lag. + # - 'delayed' - The job would switch to primary only on write. It would use replica always. + # If there's a long replication lag the job will be delayed, and only if the replica is not up to date on the next retry, + # it will switch to the primary. + # - *feature_flag* - allows you to toggle a job's `data_consistency, which permits you to safely toggle load balancing capabilities for a specific job. + # If disabled, job will default to `:always`, which means that the job will always use the primary. def data_consistency(data_consistency, feature_flag: nil) raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency) raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency] @@ -85,11 +99,16 @@ module WorkerAttributes # Since the deduplication should always take into account the latest binary replication pointer into account, # not the first one, the deduplication will not work with sticky or delayed. # Follow up issue to improve this: https://gitlab.com/gitlab-org/gitlab/-/issues/325291 - if idempotent? && get_data_consistency != :always + if idempotent? && utilizes_load_balancing_capabilities? raise ArgumentError, "Class can't be marked as idempotent if data_consistency is not set to :always" end end + # If data_consistency is not set to :always, worker will try to utilize load balancing capabilities and use the replica + def utilizes_load_balancing_capabilities? + get_data_consistency != :always + end + def get_data_consistency class_attributes[:data_consistency] || :always end @@ -167,6 +186,12 @@ module WorkerAttributes class_attributes[:deduplication_options] || {} end + def deduplication_enabled? + return true unless get_deduplication_options[:feature_flag] + + Feature.enabled?(get_deduplication_options[:feature_flag], default_enabled: :yaml) + end + def big_payload! set_class_attribute(:big_payload, true) end diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 40cc233307a..3027d46b8b1 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -65,19 +65,9 @@ module ContainerExpirationPolicies def container_repository strong_memoize(:container_repository) do ContainerRepository.transaction do - # rubocop: disable CodeReuse/ActiveRecord # We need a lock to prevent two workers from picking up the same row - container_repository = if loopless_enabled? - next_container_repository - else - ContainerRepository.waiting_for_cleanup - .order(:expiration_policy_cleanup_status, :expiration_policy_started_at) - .limit(1) - .lock('FOR UPDATE SKIP LOCKED') - .first - end - - # rubocop: enable CodeReuse/ActiveRecord + container_repository = next_container_repository + container_repository&.tap(&:cleanup_ongoing!) end end @@ -102,28 +92,20 @@ module ContainerExpirationPolicies def cleanup_scheduled_count strong_memoize(:cleanup_scheduled_count) do - if loopless_enabled? - limit = max_running_jobs + 1 - ContainerExpirationPolicy.with_container_repositories - .runnable_schedules - .limit(limit) - .count - else - ContainerRepository.cleanup_scheduled.count - end + limit = max_running_jobs + 1 + ContainerExpirationPolicy.with_container_repositories + .runnable_schedules + .limit(limit) + .count end end def cleanup_unfinished_count strong_memoize(:cleanup_unfinished_count) do - if loopless_enabled? - limit = max_running_jobs + 1 - ContainerRepository.with_unfinished_cleanup - .limit(limit) - .count - else - ContainerRepository.cleanup_unfinished.count - end + limit = max_running_jobs + 1 + ContainerRepository.with_unfinished_cleanup + .limit(limit) + .count end end @@ -132,21 +114,13 @@ module ContainerExpirationPolicies now = Time.zone.now - if loopless_enabled? - policy.next_run_at < now || (now + max_cleanup_execution_time.seconds < policy.next_run_at) - else - now + max_cleanup_execution_time.seconds < policy.next_run_at - end + policy.next_run_at < now || (now + max_cleanup_execution_time.seconds < policy.next_run_at) end def throttling_enabled? Feature.enabled?(:container_registry_expiration_policies_throttling) end - def loopless_enabled? - Feature.enabled?(:container_registry_expiration_policies_loopless) - end - def max_cleanup_execution_time ::Gitlab::CurrentSettings.container_registry_delete_tags_service_timeout end diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index dec13485d13..b15d1bf90bd 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -14,11 +14,18 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo BATCH_SIZE = 1000 def perform + process_stale_ongoing_cleanups throttling_enabled? ? perform_throttled : perform_unthrottled end private + def process_stale_ongoing_cleanups + threshold = delete_tags_service_timeout.seconds + 30.minutes + ContainerRepository.with_stale_ongoing_cleanup(threshold.ago) + .update_all(expiration_policy_cleanup_status: :cleanup_unfinished) + end + def perform_unthrottled with_runnable_policy(preloaded: true) do |policy| with_context(project: policy.project, @@ -31,18 +38,6 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo def perform_throttled try_obtain_lease do - unless loopless_enabled? - with_runnable_policy do |policy| - ContainerExpirationPolicy.transaction do - policy.schedule_next_run! - ContainerRepository.for_project_id(policy.id) - .each_batch do |relation| - relation.update_all(expiration_policy_cleanup_status: :cleanup_scheduled) - end - end - end - end - ContainerExpirationPolicies::CleanupContainerRepositoryWorker.perform_with_capacity end end @@ -79,11 +74,11 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo Feature.enabled?(:container_registry_expiration_policies_throttling) end - def loopless_enabled? - Feature.enabled?(:container_registry_expiration_policies_loopless) - end - def lease_timeout 5.hours end + + def delete_tags_service_timeout + ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout || 0 + end end diff --git a/app/workers/deployments/execute_hooks_worker.rb b/app/workers/deployments/execute_hooks_worker.rb deleted file mode 100644 index 3046aa28e20..00000000000 --- a/app/workers/deployments/execute_hooks_worker.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Deployments - # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/329360 - class ExecuteHooksWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - sidekiq_options retry: 3 - - queue_namespace :deployment - feature_category :continuous_delivery - worker_resource_boundary :cpu - - def perform(deployment_id) - if (deploy = Deployment.find_by_id(deployment_id)) - deploy.execute_hooks(Time.current) - end - end - end -end diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 3c48c4ba3cd..9702fac39ba 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop: disable Scalability/IdempotentWorker class ExpirePipelineCacheWorker include ApplicationWorker @@ -9,8 +10,12 @@ class ExpirePipelineCacheWorker queue_namespace :pipeline_cache urgency :high worker_resource_boundary :cpu + data_consistency :delayed, feature_flag: :load_balancing_for_expire_pipeline_cache_worker - idempotent! + # This worker _should_ be idempotent, but due to us moving this to data_consistency :delayed + # and an ongoing incompatibility between the two switches, we need to disable this. + # Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/325291 is resolved + # idempotent! # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) @@ -21,3 +26,4 @@ class ExpirePipelineCacheWorker end # rubocop: enable CodeReuse/ActiveRecord end +# rubocop:enable Scalability/IdempotentWorker diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb deleted file mode 100644 index a2aab23db7b..00000000000 --- a/app/workers/git_garbage_collect_worker.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# According to our docs, we can only remove workers on major releases -# https://docs.gitlab.com/ee/development/sidekiq_style_guide.html#removing-workers. -# -# We need to still maintain this until 14.0 but with the current functionality. -# -# In https://gitlab.com/gitlab-org/gitlab/-/issues/299290 we track that removal. -class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - sidekiq_options retry: false - feature_category :gitaly - loggable_arguments 1, 2, 3 - - def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil) - ::Projects::GitGarbageCollectWorker.new.perform(project_id, task, lease_key, lease_uuid) - end -end diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb deleted file mode 100644 index 3b90e296ad4..00000000000 --- a/app/workers/incident_management/process_alert_worker.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module IncidentManagement - class ProcessAlertWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - sidekiq_options retry: 3 - - queue_namespace :incident_management - feature_category :incident_management - - # `project_id` and `alert_payload` are deprecated and can be removed - # starting from 14.0 release - # https://gitlab.com/gitlab-org/gitlab/-/issues/224500 - # - # This worker is not scheduled anymore since - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60285 - # and will be removed completely via - # https://gitlab.com/gitlab-org/gitlab/-/issues/224500 - # in 14.0. - def perform(_project_id = nil, _alert_payload = nil, alert_id = nil) - return unless alert_id - - alert = find_alert(alert_id) - return unless alert - - result = create_issue_for(alert) - return if result.success? - - log_warning(alert, result) - end - - private - - def find_alert(alert_id) - AlertManagement::Alert.find_by_id(alert_id) - end - - def create_issue_for(alert) - AlertManagement::CreateAlertIssueService - .new(alert, User.alert_bot) - .execute - end - - def log_warning(alert, result) - issue_id = result.payload[:issue]&.id - - Gitlab::AppLogger.warn( - message: 'Cannot process an Incident', - issue_id: issue_id, - alert_id: alert.id, - errors: result.message - ) - end - end -end diff --git a/app/workers/incident_management/process_prometheus_alert_worker.rb b/app/workers/incident_management/process_prometheus_alert_worker.rb deleted file mode 100644 index 7b5c6fd9001..00000000000 --- a/app/workers/incident_management/process_prometheus_alert_worker.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module IncidentManagement - class ProcessPrometheusAlertWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - sidekiq_options retry: 3 - - queue_namespace :incident_management - feature_category :incident_management - worker_resource_boundary :cpu - - def perform(project_id, alert_hash) - # no-op - # - # This worker is not scheduled anymore since - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35943 - # and will be removed completely via - # https://gitlab.com/gitlab-org/gitlab/-/issues/227146 - # in 14.0. - end - end -end diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb index dba791c3f05..8166dda135e 100644 --- a/app/workers/issue_placement_worker.rb +++ b/app/workers/issue_placement_worker.rb @@ -41,7 +41,7 @@ class IssuePlacementWorker IssuePlacementWorker.perform_async(nil, leftover.project_id) if leftover.present? rescue RelativePositioning::NoSpaceLeft => e Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id) - IssueRebalancingWorker.perform_async(nil, project_id.presence || issue.project_id) + IssueRebalancingWorker.perform_async(nil, *root_namespace_id_to_rebalance(issue, project_id)) end def find_issue(issue_id, project_id) @@ -53,4 +53,11 @@ class IssuePlacementWorker project.issues.take end # rubocop: enable CodeReuse/ActiveRecord + + private + + def root_namespace_id_to_rebalance(issue, project_id) + project_id = project_id.presence || issue.project_id + Project.find(project_id)&.self_or_root_group_ids + end end diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb index 9eac451f107..66ef7dd3152 100644 --- a/app/workers/issue_rebalancing_worker.rb +++ b/app/workers/issue_rebalancing_worker.rb @@ -9,21 +9,44 @@ class IssueRebalancingWorker urgency :low feature_category :issue_tracking tags :exclude_from_kubernetes + deduplicate :until_executed, including_scheduled: true - def perform(ignore = nil, project_id = nil) - return if project_id.nil? + def perform(ignore = nil, project_id = nil, root_namespace_id = nil) + # we need to have exactly one of the project_id and root_namespace_id params be non-nil + raise ArgumentError, "Expected only one of the params project_id: #{project_id} and root_namespace_id: #{root_namespace_id}" if project_id && root_namespace_id + return if project_id.nil? && root_namespace_id.nil? - project = Project.find(project_id) + # pull the projects collection to be rebalanced either the project if namespace is not a group(i.e. user namesapce) + # or the root namespace, this also makes the worker backward compatible with previous version where a project_id was + # passed as the param + projects_to_rebalance = projects_collection(project_id, root_namespace_id) - # Temporary disable reabalancing for performance reasons + # something might have happened with the namespace between scheduling the worker and actually running it, + # maybe it was removed. + if projects_to_rebalance.blank? + Gitlab::ErrorTracking.log_exception( + ArgumentError.new("Projects to be rebalanced not found for arguments: project_id #{project_id}, root_namespace_id: #{root_namespace_id}"), + { project_id: project_id, root_namespace_id: root_namespace_id }) + + return + end + + # Temporary disable rebalancing for performance reasons # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 - return if project.root_namespace&.issue_repositioning_disabled? + return if projects_to_rebalance.take&.root_namespace&.issue_repositioning_disabled? # rubocop:disable CodeReuse/ActiveRecord + + IssueRebalancingService.new(projects_to_rebalance).execute + rescue IssueRebalancingService::TooManyIssues => e + Gitlab::ErrorTracking.log_exception(e, root_namespace_id: root_namespace_id, project_id: project_id) + end + + private - # All issues are equivalent as far as we are concerned - issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord + def projects_collection(project_id, root_namespace_id) + # we can have either project_id(older version) or project_id if project is part of a user namespace and not a group + # or root_namespace_id(newer version) never both. + return Project.id_in([project_id]) if project_id - IssueRebalancingService.new(issue).execute - rescue ActiveRecord::RecordNotFound, IssueRebalancingService::TooManyIssues => e - Gitlab::ErrorTracking.log_exception(e, project_id: project_id) + Namespace.find_by_id(root_namespace_id)&.all_projects end end diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb index b8211286d1c..4e8566d86c9 100644 --- a/app/workers/jira_connect/sync_branch_worker.rb +++ b/app/workers/jira_connect/sync_branch_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module JiraConnect - class SyncBranchWorker # rubocop:disable Scalability/IdempotentWorker + class SyncBranchWorker include ApplicationWorker sidekiq_options retry: 3 diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb index 6b3a6ae84ad..bf31df2271f 100644 --- a/app/workers/jira_connect/sync_merge_request_worker.rb +++ b/app/workers/jira_connect/sync_merge_request_worker.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module JiraConnect - class SyncMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker + class SyncMergeRequestWorker include ApplicationWorker sidekiq_options retry: 3 diff --git a/app/workers/merge_requests/assignees_change_worker.rb b/app/workers/merge_requests/assignees_change_worker.rb deleted file mode 100644 index fe39f20151f..00000000000 --- a/app/workers/merge_requests/assignees_change_worker.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class MergeRequests::AssigneesChangeWorker - include ApplicationWorker - - sidekiq_options retry: 3 - - feature_category :source_code_management - urgency :high - deduplicate :until_executed - idempotent! - - def perform(merge_request_id, user_id, old_assignee_ids) - merge_request = MergeRequest.find(merge_request_id) - current_user = User.find(user_id) - - # if a user was added and then removed, or removed and then added - # while waiting for this job to run, assume that nothing happened. - users = User.id_in(old_assignee_ids - merge_request.assignee_ids) - - return if users.blank? - - ::MergeRequests::HandleAssigneesChangeService - .new(project: merge_request.target_project, current_user: current_user) - .execute(merge_request, users, execute_hooks: true) - rescue ActiveRecord::RecordNotFound - end -end diff --git a/app/workers/packages/debian/generate_distribution_worker.rb b/app/workers/packages/debian/generate_distribution_worker.rb new file mode 100644 index 00000000000..68fdd80ffb1 --- /dev/null +++ b/app/workers/packages/debian/generate_distribution_worker.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Packages + module Debian + class GenerateDistributionWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include Gitlab::Utils::StrongMemoize + + # The worker is idempotent, by reusing component files with the same file_sha256. + # + # See GenerateDistributionService#find_or_create_component_file + deduplicate :until_executed + idempotent! + + queue_namespace :package_repositories + feature_category :package_registry + + loggable_arguments 0 + + def perform(container_type, distribution_id) + @container_type = container_type + @distribution_id = distribution_id + + return unless distribution + + ::Packages::Debian::GenerateDistributionService.new(distribution).execute + end + + private + + def container_class + return ::Packages::Debian::GroupDistribution if @container_type == :group + + ::Packages::Debian::ProjectDistribution + end + + def distribution + strong_memoize(:distribution) do + container_class.find_by_id(@distribution_id) + end + end + end + end +end diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index fbb672f52e3..97e6adbbf18 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -7,8 +7,8 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker include PipelineQueue queue_namespace :pipeline_hooks - urgency :high worker_resource_boundary :cpu + data_consistency :delayed, feature_flag: :load_balancing_for_pipeline_hooks_worker # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index dc14789fe73..a35b32c35f2 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PipelineProcessWorker # rubocop:disable Scalability/IdempotentWorker +class PipelineProcessWorker include ApplicationWorker sidekiq_options retry: 3 @@ -10,12 +10,12 @@ class PipelineProcessWorker # rubocop:disable Scalability/IdempotentWorker feature_category :continuous_integration urgency :high loggable_arguments 1 - data_consistency :delayed, feature_flag: :load_balancing_for_pipeline_process_worker + + idempotent! + deduplicate :until_executing, feature_flag: :ci_idempotent_pipeline_process_worker # rubocop: disable CodeReuse/ActiveRecord - # `_build_ids` is deprecated and will be removed in 14.0 - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/232806 - def perform(pipeline_id, _build_ids = nil) + def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| Ci::ProcessPipelineService .new(pipeline) diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb deleted file mode 100644 index e8feb4f2db2..00000000000 --- a/app/workers/pipeline_update_worker.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# This worker is deprecated and will be removed in 14.0 -# See: https://gitlab.com/gitlab-org/gitlab/-/issues/232806 -class PipelineUpdateWorker - include ApplicationWorker - - sidekiq_options retry: 3 - include PipelineQueue - - queue_namespace :pipeline_processing - urgency :high - - idempotent! - - def perform(_pipeline_id) - # no-op - end -end diff --git a/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb deleted file mode 100644 index 23d1594e4d9..00000000000 --- a/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853. -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 14.0 -# https://gitlab.com/gitlab-org/gitlab/-/issues/322393 -class ProjectScheduleBulkRepositoryShardMovesWorker < Projects::ScheduleBulkRepositoryShardMovesWorker - idempotent! - urgency :throttled -end diff --git a/app/workers/project_update_repository_storage_worker.rb b/app/workers/project_update_repository_storage_worker.rb deleted file mode 100644 index 0d68c0e16f8..00000000000 --- a/app/workers/project_update_repository_storage_worker.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853. -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 14.0 -# https://gitlab.com/gitlab-org/gitlab/-/issues/322393 -class ProjectUpdateRepositoryStorageWorker < Projects::UpdateRepositoryStorageWorker - idempotent! - urgency :throttled -end diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb index 5e694529bc0..0f8229bdf09 100644 --- a/app/workers/propagate_integration_worker.rb +++ b/app/workers/propagate_integration_worker.rb @@ -9,9 +9,7 @@ class PropagateIntegrationWorker idempotent! loggable_arguments 1 - # TODO: Keep overwrite parameter for backwards compatibility. Remove after >= 14.0 - # https://gitlab.com/gitlab-org/gitlab/-/issues/255382 - def perform(integration_id, overwrite = nil) + def perform(integration_id) Admin::PropagateIntegrationService.propagate(Integration.find(integration_id)) end end diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb deleted file mode 100644 index abfaabbf01d..00000000000 --- a/app/workers/prune_web_hook_logs_worker.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -# Worker that deletes a fixed number of outdated rows from the "web_hook_logs" -# table. -class PruneWebHookLogsWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - sidekiq_options retry: 3 - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext - - feature_category :integrations - - # The maximum number of rows to remove in a single job. - DELETE_LIMIT = 50_000 - - def perform - cutoff_date = 90.days.ago.beginning_of_day - - WebHookLog.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT) - end -end diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb index b42883549ca..ca4b70a0485 100644 --- a/app/workers/remove_unreferenced_lfs_objects_worker.rb +++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RemoveUnreferencedLfsObjectsWorker # rubocop:disable Scalability/IdempotentWorker +class RemoveUnreferencedLfsObjectsWorker include ApplicationWorker sidekiq_options retry: 3 @@ -10,8 +10,16 @@ class RemoveUnreferencedLfsObjectsWorker # rubocop:disable Scalability/Idempoten # rubocop:enable Scalability/CronWorkerContext feature_category :git_lfs + deduplicate :until_executed + idempotent! def perform - LfsObject.destroy_unreferenced + number_of_removed_files = 0 + + LfsObject.unreferenced_in_batches do |lfs_objects_without_projects| + number_of_removed_files += lfs_objects_without_projects.destroy_all.count # rubocop: disable Cop/DestroyAll + end + + number_of_removed_files end end diff --git a/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb b/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb deleted file mode 100644 index 94a6b22538b..00000000000 --- a/app/workers/snippet_schedule_bulk_repository_shard_moves_worker.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853. -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 14.0 -# https://gitlab.com/gitlab-org/gitlab/-/issues/322393 -class SnippetScheduleBulkRepositoryShardMovesWorker < Snippets::ScheduleBulkRepositoryShardMovesWorker - idempotent! - feature_category :gitaly - urgency :throttled -end diff --git a/app/workers/snippet_update_repository_storage_worker.rb b/app/workers/snippet_update_repository_storage_worker.rb deleted file mode 100644 index befae6db4f4..00000000000 --- a/app/workers/snippet_update_repository_storage_worker.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# This class was moved to a namespace in https://gitlab.com/gitlab-org/gitlab/-/issues/299853. -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 14.0 -# https://gitlab.com/gitlab-org/gitlab/-/issues/322393 -class SnippetUpdateRepositoryStorageWorker < Snippets::UpdateRepositoryStorageWorker # rubocop:disable Scalability/IdempotentWorker - idempotent! - urgency :throttled -end diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb index 9d5143fe655..b67849942b0 100644 --- a/app/workers/ssh_keys/expired_notification_worker.rb +++ b/app/workers/ssh_keys/expired_notification_worker.rb @@ -11,20 +11,37 @@ module SshKeys tags :exclude_from_kubernetes idempotent! + BATCH_SIZE = 500 + + # rubocop: disable CodeReuse/ActiveRecord def perform - return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml) + order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'expires_at_utc', + order_expression: Arel.sql("date(expires_at AT TIME ZONE 'UTC')").asc, + nullable: :not_nullable, + distinct: false, + add_to_projections: true + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: Key.arel_table[:id].asc + ) + ]) - # rubocop:disable CodeReuse/ActiveRecord - User.with_ssh_key_expired_today.find_each(batch_size: 10_000) do |user| - with_context(user: user) do - Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expired ssh key(s)" + scope = Key.expired_and_not_notified.order(order) - keys = user.expired_today_and_unnotified_keys + iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope, use_union_optimization: true) + iterator.each_batch(of: BATCH_SIZE) do |relation| + users = User.where(id: relation.map(&:user_id)) # Keyset pagination will load the rows - Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: false }).execute + users.each do |user| + with_context(user: user) do + Keys::ExpiryNotificationService.new(user, { keys: user.expired_and_unnotified_keys, expiring_soon: false }).execute + end end - # rubocop:enable CodeReuse/ActiveRecord end end + # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/app/workers/ssh_keys/expiring_soon_notification_worker.rb b/app/workers/ssh_keys/expiring_soon_notification_worker.rb index 1ec655b5cf5..d87e31c36a5 100644 --- a/app/workers/ssh_keys/expiring_soon_notification_worker.rb +++ b/app/workers/ssh_keys/expiring_soon_notification_worker.rb @@ -12,8 +12,6 @@ module SshKeys idempotent! def perform - return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml) - # rubocop:disable CodeReuse/ActiveRecord User.with_ssh_key_expiring_soon.find_each(batch_size: 10_000) do |user| with_context(user: user) do diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 6b9f90ce1fc..b3b3d6e7554 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -15,22 +15,46 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker BUILD_PENDING_OUTDATED_TIMEOUT = 1.day BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour BUILD_PENDING_STUCK_TIMEOUT = 1.hour + BUILD_LOOKBACK = 5.days def perform return unless try_obtain_lease Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds" - drop :running, BUILD_RUNNING_OUTDATED_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure - drop :pending, BUILD_PENDING_OUTDATED_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure - drop :scheduled, BUILD_SCHEDULED_OUTDATED_TIMEOUT, 'scheduled_at IS NOT NULL AND scheduled_at < ?', :stale_schedule - drop_stuck :pending, BUILD_PENDING_STUCK_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure + drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure) + + drop( + Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: BUILD_PENDING_OUTDATED_TIMEOUT.ago), + failure_reason: :stuck_or_timeout_failure + ) + + drop(scheduled_timed_out_builds, failure_reason: :stale_schedule) + + drop_stuck( + Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: BUILD_PENDING_STUCK_TIMEOUT.ago), + failure_reason: :stuck_or_timeout_failure + ) remove_lease end private + def scheduled_timed_out_builds + Ci::Build.where(status: :scheduled).where( # rubocop: disable CodeReuse/ActiveRecord + 'ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?', + BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago + ) + end + + def running_timed_out_builds + Ci::Build.running.where( # rubocop: disable CodeReuse/ActiveRecord + 'ci_builds.updated_at < ?', + BUILD_RUNNING_OUTDATED_TIMEOUT.ago + ) + end + def try_obtain_lease @uuid = Gitlab::ExclusiveLease.new(EXCLUSIVE_LEASE_KEY, timeout: 30.minutes).try_obtain end @@ -39,28 +63,27 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid) end - def drop(status, timeout, condition, reason) - search(status, timeout, condition) do |build| - drop_build :outdated, build, status, timeout, reason + def drop(builds, failure_reason:) + fetch(builds) do |build| + drop_build :outdated, build, failure_reason end end - def drop_stuck(status, timeout, condition, reason) - search(status, timeout, condition) do |build| + def drop_stuck(builds, failure_reason:) + fetch(builds) do |build| break unless build.stuck? - drop_build :stuck, build, status, timeout, reason + drop_build :stuck, build, failure_reason end end # rubocop: disable CodeReuse/ActiveRecord - def search(status, timeout, condition) + def fetch(builds) loop do - jobs = Ci::Build.where(status: status) - .where(condition, timeout.ago) - .includes(:tags, :runner, project: [:namespace, :route]) + jobs = builds.includes(:tags, :runner, project: [:namespace, :route]) .limit(100) .to_a + break if jobs.empty? jobs.each do |job| @@ -70,8 +93,8 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker end # rubocop: enable CodeReuse/ActiveRecord - def drop_build(type, build, status, timeout, reason) - Gitlab::AppLogger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout}, reason: #{reason})" + def drop_build(type, build, reason) + Gitlab::AppLogger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{build.status}, failure_reason: #{reason})" Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'stuck_ci_jobs_worker_drop_build') do |b| b.drop(reason) end diff --git a/app/workers/users/update_open_issue_count_worker.rb b/app/workers/users/update_open_issue_count_worker.rb deleted file mode 100644 index d9e313d53df..00000000000 --- a/app/workers/users/update_open_issue_count_worker.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Users - class UpdateOpenIssueCountWorker - include ApplicationWorker - - feature_category :users - tags :exclude_from_kubernetes - idempotent! - - def perform(target_user_ids) - target_user_ids = Array.wrap(target_user_ids) - - raise ArgumentError, 'No target user ID provided' if target_user_ids.empty? - - target_users = User.id_in(target_user_ids) - raise ArgumentError, 'No valid target user ID provided' if target_users.empty? - - target_users.each do |user| - Users::UpdateAssignedOpenIssueCountService.new(target_user: user).execute - end - rescue StandardError => exception - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception) - end - end -end diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index dffab61dd0e..3480f49d640 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -8,6 +8,7 @@ class WebHookWorker feature_category :integrations worker_has_external_dependencies! loggable_arguments 2 + data_consistency :delayed, feature_flag: :load_balancing_for_web_hook_worker sidekiq_options retry: 4, dead: false @@ -15,7 +16,7 @@ class WebHookWorker hook = WebHook.find(hook_id) data = data.with_indifferent_access - WebHookService.new(hook, data, hook_name).execute + WebHookService.new(hook, data, hook_name, jid).execute end end # rubocop:enable Scalability/IdempotentWorker diff --git a/app/workers/web_hooks/log_execution_worker.rb b/app/workers/web_hooks/log_execution_worker.rb new file mode 100644 index 00000000000..58059370200 --- /dev/null +++ b/app/workers/web_hooks/log_execution_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module WebHooks + class LogExecutionWorker + include ApplicationWorker + + idempotent! + feature_category :integrations + urgency :low + + # This worker accepts an extra argument. This enables us to + # treat this worker as idempotent. Currently this is set to + # the Job ID (jid) of the parent worker. + def perform(hook_id, log_data, response_category, _unique_by) + hook = WebHook.find_by_id(hook_id) + + return unless hook # hook has been deleted before we could run. + + ::WebHooks::LogExecutionService + .new(hook: hook, log_data: log_data, response_category: response_category.to_sym) + .execute + end + end +end |