diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-19 18:09:21 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-19 18:09:21 +0000 |
commit | f602da84d10c36889714e46040f26cdfef5dce60 (patch) | |
tree | 6835a37866865775596881c5e3a35115f0ac8a49 /app | |
parent | 9c8e8b5ffc6e11d827fa42f2dce5f90c4dc19493 (diff) | |
download | gitlab-ce-f602da84d10c36889714e46040f26cdfef5dce60.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
25 files changed, 484 insertions, 55 deletions
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 98995730df4..b824a013f3b 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -1,40 +1,48 @@ <script> import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { + MEMBER_TYPES, + ACTIVE_TAB_QUERY_PARAM_NAME, + TAB_QUERY_PARAM_VALUES, + EE_TABS, +} from 'ee_else_ce/members/constants'; import MembersApp from './app.vue'; const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0; +export const TABS = [ + { + namespace: MEMBER_TYPES.user, + title: __('Members'), + }, + { + namespace: MEMBER_TYPES.group, + title: __('Groups'), + attrs: { 'data-qa-selector': 'groups_list_tab' }, + queryParamValue: TAB_QUERY_PARAM_VALUES.group, + }, + { + namespace: MEMBER_TYPES.invite, + title: __('Invited'), + canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.invite, + }, + { + namespace: MEMBER_TYPES.accessRequest, + title: __('Access requests'), + canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, + }, + ...EE_TABS, +]; + export default { name: 'MembersTabs', ACTIVE_TAB_QUERY_PARAM_NAME, - TABS: [ - { - namespace: MEMBER_TYPES.user, - title: __('Members'), - }, - { - namespace: MEMBER_TYPES.group, - title: __('Groups'), - attrs: { 'data-qa-selector': 'groups_list_tab' }, - queryParamValue: TAB_QUERY_PARAM_VALUES.group, - }, - { - namespace: MEMBER_TYPES.invite, - title: __('Invited'), - canManageMembersPermissionsRequired: true, - queryParamValue: TAB_QUERY_PARAM_VALUES.invite, - }, - { - namespace: MEMBER_TYPES.accessRequest, - title: __('Access requests'), - canManageMembersPermissionsRequired: true, - queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, - }, - ], + TABS, components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton }, inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'], data() { @@ -43,20 +51,17 @@ export default { }; }, computed: { - ...mapState({ - userCount(state) { - return countComputed(state, MEMBER_TYPES.user); - }, - groupCount(state) { - return countComputed(state, MEMBER_TYPES.group); - }, - inviteCount(state) { - return countComputed(state, MEMBER_TYPES.invite); - }, - accessRequestCount(state) { - return countComputed(state, MEMBER_TYPES.accessRequest); - }, - }), + ...mapState( + Object.values(MEMBER_TYPES).reduce((getters, memberType) => { + return { + ...getters, + // eslint-disable-next-line @gitlab/require-i18n-strings + [`${memberType}Count`](state) { + return countComputed(state, memberType); + }, + }; + }, {}), + ), urlParams() { return Object.keys(queryToObject(window.location.search, { gatherArrays: true })); }, diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue index 92b757ffcba..966eb90e402 100644 --- a/app/assets/javascripts/members/components/table/member_avatar.vue +++ b/app/assets/javascripts/members/components/table/member_avatar.vue @@ -6,7 +6,13 @@ import UserAvatar from '../avatars/user_avatar.vue'; export default { name: 'MemberAvatar', - components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, + components: { + UserAvatar, + InviteAvatar, + GroupAvatar, + AccessRequestAvatar: UserAvatar, + BannedAvatar: UserAvatar, + }, props: { memberType: { type: String, diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 3436bcab2fc..51eff428d63 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -1,5 +1,5 @@ <script> -import { MEMBER_TYPES } from '../../constants'; +import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import { isGroup, isDirectMember, diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 8c40cc3f29d..2fe816c7ea2 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -3,6 +3,12 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +// Overridden in EE +export const EE_APP_OPTIONS = {}; + +// Overridden in EE +export const EE_TABS = []; + export const FIELD_KEY_ACCOUNT = 'account'; export const FIELD_KEY_SOURCE = 'source'; export const FIELD_KEY_GRANTED = 'granted'; diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 0df876cabd7..34660f8f499 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -2,8 +2,8 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { parseDataAttributes } from '~/members/utils'; +import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import MembersTabs from './components/members_tabs.vue'; -import { MEMBER_TYPES } from './constants'; import membersStore from './store'; export const initMembersApp = (el, options) => { diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index fdc7bd39780..90a18d5cf5a 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -81,7 +81,7 @@ export default { </script> <template> - <settings-block :collapsible="false"> + <settings-block data-testid="container-expiration-policy-project-settings"> <template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template> <template #description> <span> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue index d75fb31fd98..7682754fdcb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue @@ -30,6 +30,11 @@ export default { type: String, required: true, }, + description: { + type: String, + required: false, + default: '', + }, }, }; </script> @@ -46,5 +51,10 @@ export default { {{ option.label }} </option> </gl-form-select> + <template v-if="description" #description> + <span data-testid="description" class="gl-text-gray-400"> + {{ description }} + </span> + </template> </gl-form-group> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue new file mode 100644 index 00000000000..1170407a349 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue @@ -0,0 +1,68 @@ +<script> +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + PACKAGES_CLEANUP_POLICY_TITLE, + PACKAGES_CLEANUP_POLICY_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue'; + +export default { + components: { + SettingsBlock, + GlAlert, + GlSprintf, + PackagesCleanupPolicyForm, + }, + inject: ['projectPath'], + i18n: { + FETCH_SETTINGS_ERROR_MESSAGE, + PACKAGES_CLEANUP_POLICY_TITLE, + PACKAGES_CLEANUP_POLICY_DESCRIPTION, + }, + apollo: { + packagesCleanupPolicy: { + query: packagesCleanupPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: (data) => data.project?.packagesCleanupPolicy || {}, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, + data() { + return { + fetchSettingsError: false, + packagesCleanupPolicy: {}, + }; + }, +}; +</script> + +<template> + <settings-block> + <template #title> {{ $options.i18n.PACKAGES_CLEANUP_POLICY_TITLE }}</template> + <template #description> + <span data-testid="description"> + <gl-sprintf :message="$options.i18n.PACKAGES_CLEANUP_POLICY_DESCRIPTION" /> + </span> + </template> + <template #default> + <gl-alert v-if="fetchSettingsError" variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> + </gl-alert> + <packages-cleanup-policy-form + v-else + v-model="packagesCleanupPolicy" + :is-loading="$apollo.queries.packagesCleanupPolicy.loading" + /> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue new file mode 100644 index 00000000000..b1751d5174a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -0,0 +1,137 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, + SET_CLEANUP_POLICY_BUTTON, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, +} from '~/packages_and_registries/settings/project/constants'; +import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; +import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; +import Tracking from '~/tracking'; +import ExpirationDropdown from './expiration_dropdown.vue'; + +export default { + components: { + GlButton, + ExpirationDropdown, + }, + mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + formOptions: formOptionsGenerator(), + i18n: { + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, + SET_CLEANUP_POLICY_BUTTON, + }, + data() { + return { + tracking: { + label: 'packages_cleanup_policies', + }, + mutationLoading: false, + }; + }, + computed: { + prefilledForm() { + return { + ...this.value, + keepNDuplicatedPackageFiles: this.findDefaultOption( + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + ), + }; + }, + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, + isSubmitButtonDisabled() { + return this.showLoadingIcon; + }, + isFieldDisabled() { + return this.showLoadingIcon; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles, + }; + }, + }, + methods: { + findDefaultOption(option) { + return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key; + }, + submit() { + this.track('submit_packages_cleanup_form'); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updatePackagesCleanupPolicyMutation, + variables: { + input: this.mutationVariables, + }, + }) + .then(({ data }) => { + const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? []; + if (errorMessage) { + throw errorMessage; + } else { + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); + } + }) + .catch(() => { + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE); + }) + .finally(() => { + this.mutationLoading = false; + }); + }, + onModelChange(newValue, model) { + this.$emit('input', { ...this.value, [model]: newValue }); + }, + }, +}; +</script> + +<template> + <form ref="form-element" @submit.prevent="submit"> + <div class="gl-md-max-w-50p"> + <expiration-dropdown + v-model="prefilledForm.keepNDuplicatedPackageFiles" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.keepNDuplicatedPackageFiles" + :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL" + :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION" + name="keep-n-duplicated-package-files" + data-testid="keep-n-duplicated-package-files-dropdown" + @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" + /> + </div> + <div class="gl-mt-7 gl-display-flex gl-align-items-center"> + <gl-button + data-testid="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + category="primary" + variant="confirm" + class="js-no-auto-disable gl-mr-4" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> + </div> + </form> +</template> 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 95af19e6d85..710cfe7b1eb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -1,15 +1,19 @@ <script> import ContainerExpirationPolicy from './container_expiration_policy.vue'; +import PackagesCleanupPolicy from './packages_cleanup_policy.vue'; export default { components: { ContainerExpirationPolicy, + PackagesCleanupPolicy, }, + inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'], }; </script> <template> - <section data-testid="registry-settings-app"> - <container-expiration-policy /> - </section> + <div> + <packages-cleanup-policy v-if="showPackageRegistrySettings" /> + <container-expiration-policy v-if="showContainerRegistrySettings" /> + </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 40f980d15fb..948520151ce 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -55,6 +55,31 @@ export const EXPIRATION_POLICY_FOOTER_NOTE = s__( 'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time', ); +export const PACKAGES_CLEANUP_POLICY_TITLE = s__( + 'PackageRegistry|Manage storage used by package assets', +); +export const PACKAGES_CLEANUP_POLICY_DESCRIPTION = s__( + 'PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets.', +); +export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__( + 'PackageRegistry|Number of duplicate assets to keep', +); +export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__( + 'PackageRegistry|Examples of assets include .pom & .jar files', +); + +export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles'; + +export const KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS = [ + { key: 'ONE_PACKAGE_FILE', label: 1, default: false }, + { key: 'TEN_PACKAGE_FILES', label: 10, default: false }, + { key: 'TWENTY_PACKAGE_FILES', label: 20, default: false }, + { key: 'THIRTY_PACKAGE_FILES', label: 30, default: false }, + { key: 'FORTY_PACKAGE_FILES', label: 40, default: false }, + { key: 'FIFTY_PACKAGE_FILES', label: 50, default: false }, + { key: 'ALL_PACKAGE_FILES', label: __('All'), default: true }, +]; + export const KEEP_N_OPTIONS = [ { key: 'ONE_TAG', variable: 1, default: false }, { key: 'FIVE_TAGS', variable: 5, default: false }, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql new file mode 100644 index 00000000000..a77ede37884 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql @@ -0,0 +1,4 @@ +fragment PackagesCleanupPolicyFields on PackagesCleanupPolicy { + keepNDuplicatedPackageFiles + nextRunAt +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql new file mode 100644 index 00000000000..31cdd67e881 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/packages_cleanup_policy.fragment.graphql" + +mutation updatePackagesCleanupPolicy($input: UpdatePackagesCleanupPolicyInput!) { + updatePackagesCleanupPolicy(input: $input) { + packagesCleanupPolicy { + ...PackagesCleanupPolicyFields + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql new file mode 100644 index 00000000000..0e9af253f2c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql @@ -0,0 +1,10 @@ +#import "../fragments/packages_cleanup_policy.fragment.graphql" + +query getProjectPackagesCleanupPolicy($projectPath: ID!) { + project(fullPath: $projectPath) { + id + packagesCleanupPolicy { + ...PackagesCleanupPolicyFields + } + } +} 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 17c33073668..daf1da6eac8 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 @@ -20,6 +20,8 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, + showContainerRegistrySettings, + showPackageRegistrySettings, } = el.dataset; return new Vue({ el, @@ -34,6 +36,8 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, + showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings), + showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings), }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js index b577a051862..847965454e9 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js @@ -1,5 +1,11 @@ import { n__ } from '~/locale'; -import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants'; +import { + KEEP_N_OPTIONS, + CADENCE_OPTIONS, + OLDER_THAN_OPTIONS, + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS, +} from './constants'; export const findDefaultOption = (options) => { const item = options.find((o) => o.default); @@ -25,5 +31,6 @@ export const formOptionsGenerator = () => { olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), cadence: CADENCE_OPTIONS, keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), + [KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME]: KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS, }; }; diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index c7c2f6f773e..62d47cb49b8 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -5,12 +5,11 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import { initMembersApp } from '~/members'; -import { MEMBER_TYPES } from '~/members/constants'; +import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; - -initMembersApp(document.querySelector('.js-group-members-list-app'), { +const APP_OPTIONS = { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, @@ -61,7 +60,10 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { tableFields: SHARED_FIELDS.concat('requested'), requestFormatter: groupMemberRequestFormatter, }, -}); + ...EE_APP_OPTIONS, +}; + +initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS); initInviteMembersModal(); initInviteGroupsModal(); 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 fe51591c32d..f2c30870a68 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 @@ -61,6 +61,10 @@ export default { GlFormCheckbox, GlToggle, ConfirmDanger, + otherProjectSettings: () => + import( + 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue' + ), }, mixins: [settingsMixin, glFeatureFlagsMixin()], @@ -905,6 +909,7 @@ export default { <template #help>{{ $options.i18n.pucWarningHelpText }}</template> </gl-form-checkbox> </project-setting-row> + <other-project-settings /> <confirm-danger v-if="isVisibilityReduced" button-variant="confirm" diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb new file mode 100644 index 00000000000..554e057ca83 --- /dev/null +++ b/app/channels/awareness_channel.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + REFRESH_INTERVAL = ENV.fetch("GITLAB_AWARENESS_REFRESH_INTERVAL_SEC", 60) + private_constant :REFRESH_INTERVAL + + # Produces a refresh interval value, based of the + # GITLAB_AWARENESS_REFRESH_INTERVAL_SEC environment variable or the given + # default. Makes sure, that the interval after a jitter is applied, is never + # less than half the predefined interval. + def self.refresh_interval(range: -10..10) + min = REFRESH_INTERVAL / 2.to_f + [min.to_i, REFRESH_INTERVAL.to_i + rand(range)].max.seconds + end + private_class_method :refresh_interval + + # keep clients updated about session membership + periodically every: self.refresh_interval do + transmit payload + end + + def subscribed + reject unless valid_subscription? + return if subscription_rejected? + + stream_for session, coder: ActiveSupport::JSON + + session.join(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + def unsubscribed + return if subscription_rejected? + + session.leave(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + # Allows a client to let the server know they are still around. This is not + # like a heartbeat mechanism. This can be triggered by any action that results + # in a meaningful "presence" update. Like scrolling the screen (debounce), + # window becoming active, user starting to type in a text field, etc. + def touch + session.touch!(current_user) + + transmit payload + end + + private + + def valid_subscription? + current_user.present? && path.present? + end + + def payload + { collaborators: collaborators } + end + + def collaborators + session.online_users_with_last_activity.map do |user, last_activity| + collaborator(user, last_activity) + end + end + + def collaborator(user, last_activity) + { + id: user.id, + name: user.name, + avatar_url: user.avatar_url(size: 36), + last_activity: last_activity, + last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( + Time.zone.now, last_activity + ) + } + end + + def session + @session ||= AwarenessSession.for(path) + end + + def path + params[:path] + end +end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 37b23345d2a..2021961772a 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -9,7 +9,7 @@ module Groups::GroupMembersHelper { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } end - def group_members_app_data(group, members:, invited:, access_requests:, include_relations:, search:) + def group_members_app_data(group, members:, invited:, access_requests:, banned:, include_relations:, search:) { user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }), group: group_group_links_list_data(group, include_relations, search), diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 20d40626449..ec64746d6b6 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -53,4 +53,14 @@ module PackagesHelper project.container_expiration_policy.nil? && project.container_repositories.exists? end + + def show_container_registry_settings(project) + Gitlab.config.registry.enabled && + Ability.allowed?(current_user, :admin_container_image, project) + end + + def show_package_registry_settings(project) + Gitlab.config.packages.enabled && + Ability.allowed?(current_user, :admin_package, project) + end end diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb index 602888182b1..a84a3454a27 100644 --- a/app/models/awareness_session.rb +++ b/app/models/awareness_session.rb @@ -143,17 +143,34 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass end end + def to_param + id&.to_s + end + + def to_s + "awareness_session=#{id}" + end + + def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) + users_with_last_activity.filter do |_user, last_activity| + user_online?(last_activity, threshold: threshold) + end + end + def users User.where(id: user_ids) end def users_with_last_activity - # where in (x, y, [...z]) is a set and does not maintain any order, we need to - # make sure to establish a stable order for both, the pairs returned from + # where in (x, y, [...z]) is a set and does not maintain any order, we need + # to make sure to establish a stable order for both, the pairs returned from # redis and the ActiveRecord query. Using IDs in ascending order. user_ids, last_activities = user_ids_with_last_activity .sort_by(&:first) .transpose + + return [] if user_ids.blank? + users = User.where(id: user_ids).order(id: :asc) users.zip(last_activities) end @@ -162,6 +179,10 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass attr_reader :id + def user_online?(last_activity, threshold:) + last_activity.to_i + threshold.to_i > Time.zone.now.to_i + end + # converts session id from hex to integer representation def id_i Integer(id, 16) if id.present? diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 850f25a6089..54270dc186e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -225,6 +225,10 @@ class ProjectPolicy < BasePolicy Gitlab.config.registry.enabled end + condition :packages_enabled do + Gitlab.config.packages.enabled + end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -795,6 +799,10 @@ class ProjectPolicy < BasePolicy enable :view_package_registry_project_settings end + rule { packages_enabled & can?(:admin_package) }.policy do + enable :view_package_registry_project_settings + end + private def user_is_user? diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 4150766d3d3..d9fef8940eb 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -26,6 +26,7 @@ members: @members, invited: @invited_members, access_requests: @requesters, + banned: @banned || [], include_relations: @include_relations, search: params[:search_groups]).to_json } } = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 378bb0f9306..1a7821d3268 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -8,6 +8,8 @@ keep_n_options: keep_n_options.to_json, older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, + show_container_registry_settings: show_container_registry_settings(@project).to_s, + show_package_registry_settings: show_package_registry_settings(@project).to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), |