diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /app/assets | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) | |
download | gitlab-ce-a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4.tar.gz |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'app/assets')
658 files changed, 16819 insertions, 9878 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 |