Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
c2acba9468
commit
f25663fd8e
|
|
@ -1,13 +1,24 @@
|
|||
<script>
|
||||
import { GlButton, GlIcon, GlLink, GlPagination, GlTable, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import {
|
||||
convertObjectPropsToCamelCase,
|
||||
normalizeHeaders,
|
||||
parseIntPagination,
|
||||
} from '~/lib/utils/common_utils';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import UserDate from '~/vue_shared/components/user_date.vue';
|
||||
import { EVENT_SUCCESS, FIELDS, FORM_SELECTOR, INITIAL_PAGE, PAGE_SIZE } from './constants';
|
||||
|
||||
/**
|
||||
* This component supports two different types of pagination:
|
||||
* 1. Frontend only pagination: all the data is passed to the frontend. The UI slices and displays the tokens.
|
||||
* 2. Backend pagination: backend sends only the data corresponding to the `page` parameter.
|
||||
*/
|
||||
|
||||
export default {
|
||||
EVENT_SUCCESS,
|
||||
FORM_SELECTOR,
|
||||
|
|
@ -41,16 +52,21 @@ export default {
|
|||
inject: [
|
||||
'accessTokenType',
|
||||
'accessTokenTypePlural',
|
||||
'backendPagination',
|
||||
'initialActiveAccessTokens',
|
||||
'noActiveTokensMessage',
|
||||
'showRole',
|
||||
],
|
||||
data() {
|
||||
const activeAccessTokens = this.convert(this.initialActiveAccessTokens);
|
||||
|
||||
return {
|
||||
activeAccessTokens: convertObjectPropsToCamelCase(this.initialActiveAccessTokens, {
|
||||
deep: true,
|
||||
}),
|
||||
currentPage: INITIAL_PAGE,
|
||||
activeAccessTokens,
|
||||
busy: false,
|
||||
currentPage: INITIAL_PAGE, // This is the page use in the GlTable. It stays 1 if the backend pagination is on.
|
||||
page: INITIAL_PAGE, // This is the page use in the GlPagination component
|
||||
perPage: PAGE_SIZE,
|
||||
totalItems: activeAccessTokens.length,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -70,17 +86,67 @@ export default {
|
|||
ignoredFields.push('role');
|
||||
}
|
||||
|
||||
return FIELDS.filter(({ key }) => !ignoredFields.includes(key));
|
||||
const fields = FIELDS.filter(({ key }) => !ignoredFields.includes(key));
|
||||
|
||||
// Remove the sortability of the columns if backend pagination is on.
|
||||
if (this.backendPagination) {
|
||||
return fields.map((field) => ({
|
||||
...field,
|
||||
sortable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return fields;
|
||||
},
|
||||
showPagination() {
|
||||
return this.activeAccessTokens.length > PAGE_SIZE;
|
||||
return this.totalItems > this.perPage;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.backendPagination) {
|
||||
this.fetchData();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
convert(accessTokens) {
|
||||
return convertObjectPropsToCamelCase(accessTokens, { deep: true });
|
||||
},
|
||||
async fetchData(newPage) {
|
||||
const url = new URL(document.location.href);
|
||||
url.pathname = `${url.pathname}.json`;
|
||||
|
||||
if (newPage) {
|
||||
url.searchParams.delete('page');
|
||||
url.searchParams.append('page', newPage);
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
const { data, headers } = await axios.get(url.toString());
|
||||
|
||||
const { page, perPage, total } = parseIntPagination(normalizeHeaders(headers));
|
||||
this.page = page;
|
||||
this.perPage = perPage;
|
||||
this.totalItems = total;
|
||||
this.busy = false;
|
||||
|
||||
if (newPage) {
|
||||
this.activeAccessTokens = this.convert(data);
|
||||
this.replaceHistory(newPage);
|
||||
}
|
||||
},
|
||||
replaceHistory(page) {
|
||||
window.history.replaceState(null, '', `?page=${page}`);
|
||||
},
|
||||
onSuccess(event) {
|
||||
const [{ active_access_tokens: activeAccessTokens }] = event.detail;
|
||||
this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true });
|
||||
const [{ active_access_tokens: activeAccessTokens, total: totalItems }] = event.detail;
|
||||
this.activeAccessTokens = this.convert(activeAccessTokens);
|
||||
this.totalItems = totalItems;
|
||||
this.currentPage = INITIAL_PAGE;
|
||||
this.page = INITIAL_PAGE;
|
||||
|
||||
if (this.backendPagination) {
|
||||
this.replaceHistory(INITIAL_PAGE);
|
||||
}
|
||||
},
|
||||
modalMessage(tokenName) {
|
||||
return sprintf(this.$options.i18n.modalMessage, {
|
||||
|
|
@ -101,6 +167,15 @@ export default {
|
|||
// For other columns the default sorting works OK
|
||||
return false;
|
||||
},
|
||||
async pageChanged(newPage) {
|
||||
if (this.backendPagination) {
|
||||
await this.fetchData(newPage);
|
||||
} else {
|
||||
this.currentPage = newPage;
|
||||
this.page = newPage;
|
||||
}
|
||||
window.scrollTo({ top: 0 });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -114,11 +189,12 @@ export default {
|
|||
:empty-text="noActiveTokensMessage"
|
||||
:fields="filteredFields"
|
||||
:items="activeAccessTokens"
|
||||
:per-page="$options.PAGE_SIZE"
|
||||
:per-page="perPage"
|
||||
:current-page="currentPage"
|
||||
:sort-compare="sortingChanged"
|
||||
show-empty
|
||||
stacked="sm"
|
||||
:busy="busy"
|
||||
>
|
||||
<template #cell(createdAt)="{ item: { createdAt } }">
|
||||
<user-date :date="createdAt" />
|
||||
|
|
@ -167,11 +243,13 @@ export default {
|
|||
</div>
|
||||
<gl-pagination
|
||||
v-if="showPagination"
|
||||
v-model="currentPage"
|
||||
:per-page="$options.PAGE_SIZE"
|
||||
:total-items="activeAccessTokens.length"
|
||||
:value="page"
|
||||
:per-page="perPage"
|
||||
:total-items="totalItems"
|
||||
:disabled="busy"
|
||||
align="center"
|
||||
class="gl-mt-5"
|
||||
@input="pageChanged"
|
||||
/>
|
||||
</div>
|
||||
</dom-element-listener>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
|
||||
import { parseRailsFormFields } from '~/lib/utils/forms';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import Translate from '~/vue_shared/translate';
|
||||
|
|
@ -23,6 +23,7 @@ export const initAccessTokenTableApp = () => {
|
|||
const {
|
||||
accessTokenType,
|
||||
accessTokenTypePlural,
|
||||
backendPagination,
|
||||
initialActiveAccessTokens: initialActiveAccessTokensJson,
|
||||
noActiveTokensMessage: noTokensMessage,
|
||||
} = el.dataset;
|
||||
|
|
@ -41,6 +42,7 @@ export const initAccessTokenTableApp = () => {
|
|||
provide: {
|
||||
accessTokenType,
|
||||
accessTokenTypePlural,
|
||||
backendPagination: parseBoolean(backendPagination),
|
||||
initialActiveAccessTokens,
|
||||
noActiveTokensMessage,
|
||||
showRole,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ const Api = {
|
|||
releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id',
|
||||
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
|
||||
adminStatisticsPath: '/api/:version/application/statistics',
|
||||
pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs',
|
||||
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
|
||||
pipelinesPath: '/api/:version/projects/:id/pipelines/',
|
||||
createPipelinePath: '/api/:version/projects/:id/pipeline',
|
||||
|
|
@ -797,14 +796,6 @@ const Api = {
|
|||
return axios.get(url);
|
||||
},
|
||||
|
||||
pipelineJobs(projectId, pipelineId, params) {
|
||||
const url = Api.buildUrl(this.pipelineJobsPath)
|
||||
.replace(':id', encodeURIComponent(projectId))
|
||||
.replace(':pipeline_id', encodeURIComponent(pipelineId));
|
||||
|
||||
return axios.get(url, { params });
|
||||
},
|
||||
|
||||
// Return all pipelines for a project or filter by query params
|
||||
pipelines(id, options = {}) {
|
||||
const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
|
|||
@impersonation_token.organization = Current.organization
|
||||
|
||||
if @impersonation_token.save
|
||||
active_access_tokens = active_impersonation_tokens
|
||||
render json: { new_token: @impersonation_token.token,
|
||||
active_access_tokens: active_impersonation_tokens }, status: :ok
|
||||
active_access_tokens: active_access_tokens, total: active_access_tokens.length }, status: :ok
|
||||
else
|
||||
render json: { errors: @impersonation_token.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@ module AccessTokensActions
|
|||
|
||||
if token_response.success?
|
||||
@resource_access_token = token_response.payload[:access_token]
|
||||
tokens, size = active_access_tokens(resource.root_ancestor)
|
||||
render json: { new_token: @resource_access_token.token,
|
||||
active_access_tokens: active_access_tokens }, status: :ok
|
||||
active_access_tokens: tokens, total: size }, status: :ok
|
||||
else
|
||||
render json: { errors: token_response.errors }, status: :unprocessable_entity
|
||||
end
|
||||
|
|
@ -72,7 +73,7 @@ module AccessTokensActions
|
|||
resource.members.load
|
||||
|
||||
@scopes = Gitlab::Auth.available_scopes_for(resource)
|
||||
@active_access_tokens = active_access_tokens
|
||||
@active_access_tokens, @active_access_tokens_size = active_access_tokens(resource.root_ancestor)
|
||||
if Feature.enabled?(:retain_resource_access_token_user_after_revoke, resource.root_ancestor) # rubocop:disable Style/GuardClause
|
||||
@inactive_access_tokens = inactive_access_tokens
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
module RenderAccessTokens
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def active_access_tokens
|
||||
def active_access_tokens(user_or_group)
|
||||
tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users
|
||||
size = tokens.size
|
||||
|
||||
if Feature.enabled?('access_token_pagination')
|
||||
if Feature.enabled?(:access_token_pagination, user_or_group)
|
||||
tokens = tokens.page(page)
|
||||
add_pagination_headers(tokens)
|
||||
end
|
||||
|
||||
represent(tokens)
|
||||
[represent(tokens), size]
|
||||
end
|
||||
|
||||
def inactive_access_tokens
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@ module UserSettings
|
|||
|
||||
@personal_access_token = result.payload[:personal_access_token]
|
||||
|
||||
tokens, size = active_access_tokens(current_user)
|
||||
if result.success?
|
||||
render json: { new_token: @personal_access_token.token,
|
||||
active_access_tokens: active_access_tokens }, status: :ok
|
||||
active_access_tokens: tokens, total: size }, status: :ok
|
||||
else
|
||||
render json: { errors: result.errors }, status: :unprocessable_entity
|
||||
end
|
||||
|
|
@ -73,7 +74,7 @@ module UserSettings
|
|||
|
||||
def set_index_vars
|
||||
@scopes = Gitlab::Auth.available_scopes_for(current_user)
|
||||
@active_access_tokens = active_access_tokens
|
||||
@active_access_tokens, @active_access_tokens_size = active_access_tokens(current_user)
|
||||
end
|
||||
|
||||
def represent(tokens)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ module Types
|
|||
field :expires_at, Types::TimeType, null: true,
|
||||
description: 'Date and time the membership expires.'
|
||||
|
||||
field :last_activity_on, Types::TimeType,
|
||||
description: 'Date of last activity in the namespace (group or project).'
|
||||
|
||||
field :user, Types::UserType, null: true,
|
||||
description: 'User that is associated with the member object.'
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
= render ::Layouts::CrudComponent.new(_('Active group access tokens'),
|
||||
icon: 'token',
|
||||
count: @active_access_tokens.size,
|
||||
count: @active_access_tokens_size,
|
||||
count_options: { class: 'js-token-count' },
|
||||
form_options: { class: 'gl-hidden js-toggle-content js-add-new-token-form' },
|
||||
options: { class: 'js-toggle-container js-token-card' }) do |c|
|
||||
|
|
@ -49,12 +49,13 @@
|
|||
help_path: help_page_path('user/group/settings/group_access_tokens.md', anchor: 'scopes-for-a-group-access-token')
|
||||
|
||||
- c.with_body do
|
||||
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } }
|
||||
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: Feature.enabled?(:access_token_pagination, @group.root_ancestor).to_s, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } }
|
||||
|
||||
- if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @group.root_ancestor)
|
||||
= render ::Layouts::CrudComponent.new(_('Inactive group access tokens'),
|
||||
icon: 'token',
|
||||
count: @inactive_access_tokens.size,
|
||||
count_options: { class: 'js-token-count' }) do |c|
|
||||
- c.with_body do
|
||||
#js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This group has no inactive access tokens.')} }
|
||||
.gl-mt-5
|
||||
= render ::Layouts::CrudComponent.new(_('Inactive group access tokens'),
|
||||
icon: 'token',
|
||||
count: @inactive_access_tokens.size,
|
||||
count_options: { class: 'js-token-count' }) do |c|
|
||||
- c.with_body do
|
||||
#js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This group has no inactive access tokens.')} }
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
= render ::Layouts::CrudComponent.new(_('Active project access tokens'),
|
||||
icon: 'token',
|
||||
count: @active_access_tokens.size,
|
||||
count: @active_access_tokens_size,
|
||||
count_options: { class: 'js-token-count' },
|
||||
form_options: { class: 'gl-hidden js-toggle-content js-add-new-token-form' },
|
||||
options: { class: 'gl-mt-5 js-toggle-container js-token-card' }) do |c|
|
||||
|
|
@ -40,12 +40,13 @@
|
|||
= render_if_exists 'projects/settings/access_tokens/form', type: type
|
||||
|
||||
- c.with_body do
|
||||
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } }
|
||||
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: Feature.enabled?(:access_token_pagination, @project.root_ancestor).to_s, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } }
|
||||
|
||||
- if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @project.root_ancestor)
|
||||
= render ::Layouts::CrudComponent.new(_('Inactive project access tokens'),
|
||||
icon: 'token',
|
||||
count: @inactive_access_tokens.size,
|
||||
count_options: { class: 'js-token-count' }) do |c|
|
||||
- c.with_body do
|
||||
#js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This project has no inactive access tokens.')} }
|
||||
.gl-mt-5
|
||||
= render ::Layouts::CrudComponent.new(_('Inactive project access tokens'),
|
||||
icon: 'token',
|
||||
count: @inactive_access_tokens.size,
|
||||
count_options: { class: 'js-token-count' }) do |c|
|
||||
- c.with_body do
|
||||
#js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This project has no inactive access tokens.')} }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
= render ::Layouts::CrudComponent.new(_('Active personal access tokens'),
|
||||
icon: 'token',
|
||||
count: @active_access_tokens.size,
|
||||
count: @active_access_tokens_size,
|
||||
count_options: { class: 'js-token-count' },
|
||||
toggle_text: _('Add new token'),
|
||||
toggle_options: { data: { testid: 'add-new-token-button' } },
|
||||
|
|
@ -34,6 +34,6 @@
|
|||
help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
|
||||
|
||||
- c.with_body do
|
||||
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } }
|
||||
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: Feature.enabled?(:access_token_pagination, current_user).to_s, initial_active_access_tokens: @active_access_tokens.to_json } }
|
||||
|
||||
#js-tokens-app{ data: { tokens_data: tokens_app_data } }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
migration_job_name: BackfillMembersRequestAcceptedAt
|
||||
description: Backfills `request_accepted_at` column in the `members` table to `created_at` column value for all existing records.
|
||||
feature_category: groups_and_projects
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/166812
|
||||
milestone: '17.5'
|
||||
queued_migration_version: 20240920083708
|
||||
finalized_by:
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DropIndexForOwaspTop10GroupLevelReports < Gitlab::Database::Migration[2.2]
|
||||
disable_ddl_transaction!
|
||||
milestone '17.6'
|
||||
|
||||
INDEX_NAME = 'index_for_owasp_top_10_group_level_reports'
|
||||
|
||||
def up
|
||||
remove_concurrent_index_by_name :vulnerability_reads, INDEX_NAME
|
||||
end
|
||||
|
||||
def down
|
||||
add_concurrent_index :vulnerability_reads, [:owasp_top_10, :state, :report_type,
|
||||
:severity, :traversal_ids, :vulnerability_id, :resolved_on_default_branch],
|
||||
where: 'archived = false',
|
||||
name: INDEX_NAME
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class QueueBackfillMembersRequestAcceptedAt < Gitlab::Database::Migration[2.2]
|
||||
milestone '17.5'
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_main
|
||||
|
||||
MIGRATION = "BackfillMembersRequestAcceptedAt"
|
||||
DELAY_INTERVAL = 2.minutes
|
||||
BATCH_SIZE = 1000
|
||||
SUB_BATCH_SIZE = 100
|
||||
|
||||
def up
|
||||
queue_batched_background_migration(
|
||||
MIGRATION,
|
||||
:members,
|
||||
:id,
|
||||
job_interval: DELAY_INTERVAL,
|
||||
batch_size: BATCH_SIZE,
|
||||
sub_batch_size: SUB_BATCH_SIZE
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
delete_batched_background_migration(MIGRATION, :members, :id, [])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
1c3c404213d4eaaa59cb40e025121787d790daf0a6e42bfd04ad7ccb657fae7d
|
||||
|
|
@ -0,0 +1 @@
|
|||
487b9bcc614eef44330b4c5024b0b9383a6271981f436fda86a0d95b93689e70
|
||||
|
|
@ -29162,8 +29162,6 @@ CREATE UNIQUE INDEX index_feature_gates_on_feature_key_and_key_and_value ON feat
|
|||
|
||||
CREATE UNIQUE INDEX index_features_on_key ON features USING btree (key);
|
||||
|
||||
CREATE INDEX index_for_owasp_top_10_group_level_reports ON vulnerability_reads USING btree (owasp_top_10, state, report_type, severity, traversal_ids, vulnerability_id, resolved_on_default_branch) WHERE (archived = false);
|
||||
|
||||
CREATE INDEX index_for_protected_environment_group_id_of_protected_environme ON protected_environment_deploy_access_levels USING btree (protected_environment_group_id);
|
||||
|
||||
CREATE INDEX index_for_protected_environment_project_id_of_protected_environ ON protected_environment_deploy_access_levels USING btree (protected_environment_project_id);
|
||||
|
|
|
|||
|
|
@ -25155,6 +25155,7 @@ Represents a Group Membership.
|
|||
| <a id="groupmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. |
|
||||
| <a id="groupmembergroup"></a>`group` | [`Group`](#group) | Group that a user is a member of. |
|
||||
| <a id="groupmemberid"></a>`id` | [`ID!`](#id) | ID of the member. |
|
||||
| <a id="groupmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). |
|
||||
| <a id="groupmembernotificationemail"></a>`notificationEmail` | [`String`](#string) | Group notification email for user. Only available for admins. |
|
||||
| <a id="groupmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. |
|
||||
| <a id="groupmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. |
|
||||
|
|
@ -29459,6 +29460,7 @@ Represents a Pending Group Membership.
|
|||
| <a id="pendinggroupmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. |
|
||||
| <a id="pendinggroupmemberid"></a>`id` | [`ID!`](#id) | ID of the member. |
|
||||
| <a id="pendinggroupmemberinvited"></a>`invited` | [`Boolean`](#boolean) | Whether the pending member has been invited. |
|
||||
| <a id="pendinggroupmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). |
|
||||
| <a id="pendinggroupmembername"></a>`name` | [`String`](#string) | Name of the pending member. |
|
||||
| <a id="pendinggroupmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. |
|
||||
| <a id="pendinggroupmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. |
|
||||
|
|
@ -29496,6 +29498,7 @@ Represents a Pending Project Membership.
|
|||
| <a id="pendingprojectmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. |
|
||||
| <a id="pendingprojectmemberid"></a>`id` | [`ID!`](#id) | ID of the member. |
|
||||
| <a id="pendingprojectmemberinvited"></a>`invited` | [`Boolean`](#boolean) | Whether the pending member has been invited. |
|
||||
| <a id="pendingprojectmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). |
|
||||
| <a id="pendingprojectmembername"></a>`name` | [`String`](#string) | Name of the pending member. |
|
||||
| <a id="pendingprojectmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. |
|
||||
| <a id="pendingprojectmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. |
|
||||
|
|
@ -32226,6 +32229,7 @@ Represents a Project Membership.
|
|||
| <a id="projectmembercreatedby"></a>`createdBy` | [`UserCore`](#usercore) | User that authorized membership. |
|
||||
| <a id="projectmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. |
|
||||
| <a id="projectmemberid"></a>`id` | [`ID!`](#id) | ID of the member. |
|
||||
| <a id="projectmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). |
|
||||
| <a id="projectmemberproject"></a>`project` | [`Project`](#project) | Project that User is a member of. |
|
||||
| <a id="projectmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. |
|
||||
| <a id="projectmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. |
|
||||
|
|
@ -41449,6 +41453,7 @@ Implementations:
|
|||
| <a id="memberinterfacecreatedby"></a>`createdBy` | [`UserCore`](#usercore) | User that authorized membership. |
|
||||
| <a id="memberinterfaceexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. |
|
||||
| <a id="memberinterfaceid"></a>`id` | [`ID!`](#id) | ID of the member. |
|
||||
| <a id="memberinterfacelastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). |
|
||||
| <a id="memberinterfaceupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. |
|
||||
| <a id="memberinterfaceuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. |
|
||||
|
||||
|
|
@ -41563,6 +41568,7 @@ Implementations:
|
|||
| <a id="pendingmemberinterfaceexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. |
|
||||
| <a id="pendingmemberinterfaceid"></a>`id` | [`ID!`](#id) | ID of the member. |
|
||||
| <a id="pendingmemberinterfaceinvited"></a>`invited` | [`Boolean`](#boolean) | Whether the pending member has been invited. |
|
||||
| <a id="pendingmemberinterfacelastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). |
|
||||
| <a id="pendingmemberinterfacename"></a>`name` | [`String`](#string) | Name of the pending member. |
|
||||
| <a id="pendingmemberinterfaceupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. |
|
||||
| <a id="pendingmemberinterfaceuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. |
|
||||
|
|
|
|||
|
|
@ -77,4 +77,6 @@ Updated files:
|
|||
|
||||
After running the script, you must commit all the modified files to Git and create a merge request.
|
||||
|
||||
The script is part of GDK and a frontend or backend developer can run the script and prepare the merge request.
|
||||
|
||||
If a group is split into multiple groups, you need to manually update the product_group.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module BackgroundMigration
|
||||
class BackfillMembersRequestAcceptedAt < BatchedMigrationJob
|
||||
operation_name :backfill_members_request_accepted_at
|
||||
feature_category :groups_and_projects
|
||||
|
||||
def perform
|
||||
each_sub_batch do |sub_batch|
|
||||
sub_batch
|
||||
.where(requested_at: nil)
|
||||
.where(invite_token: nil)
|
||||
.where(invite_accepted_at: nil)
|
||||
.where(request_accepted_at: nil)
|
||||
.update_all("request_accepted_at = created_at")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -43,6 +43,11 @@ RSpec.describe QA::Runtime::Namespace do
|
|||
end
|
||||
|
||||
describe '.path' do
|
||||
before do
|
||||
allow(QA::Runtime::Scenario).to receive(:gitlab_address).and_return("http://gitlab.test")
|
||||
described_class.instance_variable_set(:@sandbox_name, nil)
|
||||
end
|
||||
|
||||
it 'is always cached' do
|
||||
path = described_class.path
|
||||
expect(described_class.path).to eq(path)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { GlButton, GlPagination, GlTable } from '@gitlab/ui';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { nextTick } from 'vue';
|
||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
|
||||
import { EVENT_SUCCESS, PAGE_SIZE } from '~/access_tokens/components/constants';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
|
||||
import { sprintf } from '~/locale';
|
||||
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
|
||||
|
||||
describe('~/access_tokens/components/access_token_table_app', () => {
|
||||
let wrapper;
|
||||
let mockAxios;
|
||||
|
||||
const accessTokenType = 'personal access token';
|
||||
const accessTokenTypePlural = 'personal access tokens';
|
||||
const information = undefined;
|
||||
const noActiveTokensMessage = 'This user has no active personal access tokens.';
|
||||
const showRole = false;
|
||||
|
||||
|
|
@ -47,7 +50,6 @@ describe('~/access_tokens/components/access_token_table_app', () => {
|
|||
provide: {
|
||||
accessTokenType,
|
||||
accessTokenTypePlural,
|
||||
information,
|
||||
initialActiveAccessTokens: defaultActiveAccessTokens,
|
||||
noActiveTokensMessage,
|
||||
showRole,
|
||||
|
|
@ -57,9 +59,9 @@ describe('~/access_tokens/components/access_token_table_app', () => {
|
|||
};
|
||||
|
||||
const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => {
|
||||
wrapper
|
||||
.findComponent(DomElementListener)
|
||||
.vm.$emit(EVENT_SUCCESS, { detail: [{ active_access_tokens: activeAccessTokens }] });
|
||||
wrapper.findComponent(DomElementListener).vm.$emit(EVENT_SUCCESS, {
|
||||
detail: [{ active_access_tokens: activeAccessTokens, total: activeAccessTokens.length }],
|
||||
});
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
|
|
@ -68,199 +70,357 @@ describe('~/access_tokens/components/access_token_table_app', () => {
|
|||
const findCells = () => findTable().findAll('td');
|
||||
const findPagination = () => wrapper.findComponent(GlPagination);
|
||||
|
||||
beforeEach(() => {
|
||||
const headers = {
|
||||
'X-Page': 1,
|
||||
'X-Per-Page': 20,
|
||||
'X-Total': defaultActiveAccessTokens.length,
|
||||
};
|
||||
mockAxios = new MockAdapter(axios);
|
||||
mockAxios.onGet().reply(
|
||||
HTTP_STATUS_OK,
|
||||
[
|
||||
{
|
||||
active_access_tokens: defaultActiveAccessTokens,
|
||||
total: defaultActiveAccessTokens.length,
|
||||
},
|
||||
],
|
||||
headers,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.destroy();
|
||||
mockAxios.restore();
|
||||
});
|
||||
|
||||
it('should render an empty table with a default message', () => {
|
||||
createComponent({ initialActiveAccessTokens: [] });
|
||||
describe.each`
|
||||
backendPagination
|
||||
${true}
|
||||
${false}
|
||||
`('when backendPagination is $backendPagination', ({ backendPagination }) => {
|
||||
it('should render an empty table with a default message', () => {
|
||||
createComponent({ initialActiveAccessTokens: [], backendPagination });
|
||||
|
||||
const cells = findCells();
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells.at(0).text()).toBe(
|
||||
sprintf('This user has no active %{accessTokenTypePlural}.', { accessTokenTypePlural }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render an empty table with a custom message', () => {
|
||||
const noTokensMessage = 'This group has no active access tokens.';
|
||||
createComponent({ initialActiveAccessTokens: [], noActiveTokensMessage: noTokensMessage });
|
||||
|
||||
const cells = findCells();
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells.at(0).text()).toBe(noTokensMessage);
|
||||
});
|
||||
|
||||
describe('table headers', () => {
|
||||
it('should include `Action` column', () => {
|
||||
createComponent();
|
||||
|
||||
const headers = findHeaders();
|
||||
expect(headers.wrappers.map((header) => header.text())).toStrictEqual([
|
||||
'Token name',
|
||||
'Scopes',
|
||||
'Created',
|
||||
'Last Used',
|
||||
'Expires',
|
||||
'Action',
|
||||
]);
|
||||
const cells = findCells();
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells.at(0).text()).toBe(
|
||||
sprintf('This user has no active %{accessTokenTypePlural}.', { accessTokenTypePlural }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include `Role` column', () => {
|
||||
createComponent({ showRole: true });
|
||||
it('should render an empty table with a custom message', () => {
|
||||
const noTokensMessage = 'This group has no active access tokens.';
|
||||
createComponent({
|
||||
initialActiveAccessTokens: [],
|
||||
noActiveTokensMessage: noTokensMessage,
|
||||
backendPagination,
|
||||
});
|
||||
|
||||
const headers = findHeaders();
|
||||
expect(headers.wrappers.map((header) => header.text())).toStrictEqual([
|
||||
'Token name',
|
||||
'Scopes',
|
||||
'Created',
|
||||
'Last Used',
|
||||
'Expires',
|
||||
'Role',
|
||||
'Action',
|
||||
]);
|
||||
const cells = findCells();
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells.at(0).text()).toBe(noTokensMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('`Last Used` header should contain a link and an assistive message', () => {
|
||||
createComponent();
|
||||
|
||||
const headers = wrapper.findAll('th');
|
||||
const lastUsed = headers.at(3);
|
||||
const anchor = lastUsed.find('a');
|
||||
const assistiveElement = lastUsed.find('.gl-sr-only');
|
||||
expect(anchor.exists()).toBe(true);
|
||||
expect(anchor.attributes('href')).toBe(
|
||||
'/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used',
|
||||
);
|
||||
expect(assistiveElement.text()).toBe('The last time a token was used');
|
||||
});
|
||||
|
||||
it('updates the table after new tokens are created', async () => {
|
||||
createComponent({ initialActiveAccessTokens: [], showRole: true });
|
||||
await triggerSuccess();
|
||||
|
||||
const cells = findCells();
|
||||
expect(cells).toHaveLength(14);
|
||||
|
||||
// First row
|
||||
expect(cells.at(0).text()).toBe('a');
|
||||
expect(cells.at(1).text()).toBe('api');
|
||||
expect(cells.at(2).text()).not.toBe('Never');
|
||||
expect(cells.at(3).text()).toBe('Never');
|
||||
expect(cells.at(4).text()).toBe('Never');
|
||||
expect(cells.at(5).text()).toBe('Maintainer');
|
||||
let button = cells.at(6).findComponent(GlButton);
|
||||
expect(button.attributes()).toMatchObject({
|
||||
'aria-label': 'Revoke',
|
||||
'data-testid': 'revoke-button',
|
||||
href: '/-/user_settings/personal_access_tokens/1/revoke',
|
||||
'data-confirm': sprintf(
|
||||
'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.',
|
||||
|
||||
{ accessTokenType, tokenName: 'a' },
|
||||
),
|
||||
});
|
||||
expect(button.props('category')).toBe('tertiary');
|
||||
|
||||
// Second row
|
||||
expect(cells.at(7).text()).toBe('b');
|
||||
expect(cells.at(8).text()).toBe('api, sudo');
|
||||
expect(cells.at(9).text()).not.toBe('Never');
|
||||
expect(cells.at(10).text()).not.toBe('Never');
|
||||
expect(cells.at(11).text()).toBe('Expired');
|
||||
expect(cells.at(12).text()).toBe('Maintainer');
|
||||
button = cells.at(13).findComponent(GlButton);
|
||||
expect(button.attributes('href')).toBe('/-/user_settings/personal_access_tokens/2/revoke');
|
||||
expect(button.props('category')).toBe('tertiary');
|
||||
});
|
||||
|
||||
describe('when revoke_path is', () => {
|
||||
describe('absent in all tokens', () => {
|
||||
it('should not include `Action` column', () => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: defaultActiveAccessTokens.map(
|
||||
({ revoke_path, ...rest }) => rest,
|
||||
),
|
||||
showRole: true,
|
||||
});
|
||||
describe('table headers', () => {
|
||||
it('should include `Action` column', () => {
|
||||
createComponent({ backendPagination });
|
||||
|
||||
const headers = findHeaders();
|
||||
expect(headers).toHaveLength(6);
|
||||
['Token name', 'Scopes', 'Created', 'Last Used', 'Expires', 'Role'].forEach(
|
||||
(text, index) => {
|
||||
expect(headers.at(index).text()).toBe(text);
|
||||
},
|
||||
);
|
||||
expect(headers.wrappers.map((header) => header.text())).toStrictEqual([
|
||||
'Token name',
|
||||
'Scopes',
|
||||
'Created',
|
||||
'Last Used',
|
||||
'Expires',
|
||||
'Action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include `Role` column', () => {
|
||||
createComponent({ showRole: true, backendPagination });
|
||||
|
||||
const headers = findHeaders();
|
||||
expect(headers.wrappers.map((header) => header.text())).toStrictEqual([
|
||||
'Token name',
|
||||
'Scopes',
|
||||
'Created',
|
||||
'Last Used',
|
||||
'Expires',
|
||||
'Role',
|
||||
'Action',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ revoke_path: null }, { revoke_path: undefined }])(
|
||||
'%p in some tokens, does not show revoke button',
|
||||
(input) => {
|
||||
it('`Last Used` header should contain a link and an assistive message', () => {
|
||||
createComponent({ backendPagination });
|
||||
|
||||
const headers = wrapper.findAll('th');
|
||||
const lastUsed = headers.at(3);
|
||||
const anchor = lastUsed.find('a');
|
||||
const assistiveElement = lastUsed.find('.gl-sr-only');
|
||||
expect(anchor.exists()).toBe(true);
|
||||
expect(anchor.attributes('href')).toBe(
|
||||
'/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used',
|
||||
);
|
||||
expect(assistiveElement.text()).toBe('The last time a token was used');
|
||||
});
|
||||
|
||||
it('updates the table after new tokens are created', async () => {
|
||||
createComponent({ initialActiveAccessTokens: [], showRole: true, backendPagination });
|
||||
await triggerSuccess();
|
||||
|
||||
const cells = findCells();
|
||||
expect(cells).toHaveLength(14);
|
||||
|
||||
// First row
|
||||
expect(cells.at(0).text()).toBe('a');
|
||||
expect(cells.at(1).text()).toBe('api');
|
||||
expect(cells.at(2).text()).not.toBe('Never');
|
||||
expect(cells.at(3).text()).toBe('Never');
|
||||
expect(cells.at(4).text()).toBe('Never');
|
||||
expect(cells.at(5).text()).toBe('Maintainer');
|
||||
let button = cells.at(6).findComponent(GlButton);
|
||||
expect(button.attributes()).toMatchObject({
|
||||
'aria-label': 'Revoke',
|
||||
'data-testid': 'revoke-button',
|
||||
href: '/-/user_settings/personal_access_tokens/1/revoke',
|
||||
'data-confirm': sprintf(
|
||||
'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.',
|
||||
|
||||
{ accessTokenType, tokenName: 'a' },
|
||||
),
|
||||
});
|
||||
expect(button.props('category')).toBe('tertiary');
|
||||
|
||||
// Second row
|
||||
expect(cells.at(7).text()).toBe('b');
|
||||
expect(cells.at(8).text()).toBe('api, sudo');
|
||||
expect(cells.at(9).text()).not.toBe('Never');
|
||||
expect(cells.at(10).text()).not.toBe('Never');
|
||||
expect(cells.at(11).text()).toBe('Expired');
|
||||
expect(cells.at(12).text()).toBe('Maintainer');
|
||||
button = cells.at(13).findComponent(GlButton);
|
||||
expect(button.attributes('href')).toBe('/-/user_settings/personal_access_tokens/2/revoke');
|
||||
expect(button.props('category')).toBe('tertiary');
|
||||
});
|
||||
|
||||
describe('when revoke_path is', () => {
|
||||
describe('absent in all tokens', () => {
|
||||
it('should not include `Action` column', () => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: defaultActiveAccessTokens.map(
|
||||
({ revoke_path, ...rest }) => rest,
|
||||
),
|
||||
showRole: true,
|
||||
backendPagination,
|
||||
});
|
||||
|
||||
const headers = findHeaders();
|
||||
expect(headers).toHaveLength(6);
|
||||
['Token name', 'Scopes', 'Created', 'Last Used', 'Expires', 'Role'].forEach(
|
||||
(text, index) => {
|
||||
expect(headers.at(index).text()).toBe(text);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ revoke_path: null }, { revoke_path: undefined }])(
|
||||
'%p in some tokens, does not show revoke button',
|
||||
(input) => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: [
|
||||
defaultActiveAccessTokens.map((data) => ({ ...data, ...input }))[0],
|
||||
defaultActiveAccessTokens[1],
|
||||
],
|
||||
showRole: true,
|
||||
backendPagination,
|
||||
});
|
||||
|
||||
expect(findHeaders().at(6).text()).toBe('Action');
|
||||
expect(findCells().at(6).findComponent(GlButton).exists()).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when backendPagination is false', () => {
|
||||
it('sorts rows alphabetically', async () => {
|
||||
createComponent({ showRole: true, backendPagination: false });
|
||||
|
||||
const cells = findCells();
|
||||
|
||||
// First and second rows
|
||||
expect(cells.at(0).text()).toBe('a');
|
||||
expect(cells.at(7).text()).toBe('b');
|
||||
|
||||
const headers = findHeaders();
|
||||
await headers.at(0).trigger('click');
|
||||
await headers.at(0).trigger('click');
|
||||
|
||||
// First and second rows have swapped
|
||||
expect(cells.at(0).text()).toBe('b');
|
||||
expect(cells.at(7).text()).toBe('a');
|
||||
});
|
||||
|
||||
it('sorts rows by date', async () => {
|
||||
createComponent({ showRole: true, backendPagination: false });
|
||||
|
||||
const cells = findCells();
|
||||
|
||||
// First and second rows
|
||||
expect(cells.at(3).text()).toBe('Never');
|
||||
expect(cells.at(10).text()).not.toBe('Never');
|
||||
|
||||
const headers = findHeaders();
|
||||
await headers.at(3).trigger('click');
|
||||
|
||||
// First and second rows have swapped
|
||||
expect(cells.at(3).text()).not.toBe('Never');
|
||||
expect(cells.at(10).text()).toBe('Never');
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('does not show pagination component', () => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: [
|
||||
defaultActiveAccessTokens.map((data) => ({ ...data, ...input }))[0],
|
||||
defaultActiveAccessTokens[1],
|
||||
],
|
||||
showRole: true,
|
||||
initialActiveAccessTokens: Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0]),
|
||||
backendPagination: false,
|
||||
});
|
||||
|
||||
expect(findHeaders().at(6).text()).toBe('Action');
|
||||
expect(findCells().at(6).findComponent(GlButton).exists()).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('sorts rows alphabetically', async () => {
|
||||
createComponent({ showRole: true });
|
||||
|
||||
const cells = findCells();
|
||||
|
||||
// First and second rows
|
||||
expect(cells.at(0).text()).toBe('a');
|
||||
expect(cells.at(7).text()).toBe('b');
|
||||
|
||||
const headers = findHeaders();
|
||||
await headers.at(0).trigger('click');
|
||||
await headers.at(0).trigger('click');
|
||||
|
||||
// First and second rows have swapped
|
||||
expect(cells.at(0).text()).toBe('b');
|
||||
expect(cells.at(7).text()).toBe('a');
|
||||
});
|
||||
|
||||
it('sorts rows by date', async () => {
|
||||
createComponent({ showRole: true });
|
||||
|
||||
const cells = findCells();
|
||||
|
||||
// First and second rows
|
||||
expect(cells.at(3).text()).toBe('Never');
|
||||
expect(cells.at(10).text()).not.toBe('Never');
|
||||
|
||||
const headers = findHeaders();
|
||||
await headers.at(3).trigger('click');
|
||||
|
||||
// First and second rows have swapped
|
||||
expect(cells.at(3).text()).not.toBe('Never');
|
||||
expect(cells.at(10).text()).toBe('Never');
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('does not show pagination component', () => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0]),
|
||||
expect(findPagination().exists()).toBe(false);
|
||||
});
|
||||
|
||||
expect(findPagination().exists()).toBe(false);
|
||||
describe('when number of tokens exceeds the first page', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0]),
|
||||
backendPagination: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the pagination component', () => {
|
||||
expect(findPagination().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('when clicked on the second page', () => {
|
||||
it('shows only one token in the table', async () => {
|
||||
expect(findCells()).toHaveLength(PAGE_SIZE * 6);
|
||||
await findPagination().vm.$emit('input', 2);
|
||||
await nextTick();
|
||||
|
||||
expect(findCells()).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('scrolls to the top', async () => {
|
||||
const scrollToSpy = jest.spyOn(window, 'scrollTo');
|
||||
await findPagination().vm.$emit('input', 2);
|
||||
await nextTick();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when backendPagination is true', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ showRole: true, backendPagination: true });
|
||||
});
|
||||
|
||||
it('shows the pagination component', () => {
|
||||
createComponent({
|
||||
initialActiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0]),
|
||||
it('does not sort rows alphabetically', async () => {
|
||||
// await axios.waitForAll();
|
||||
const cells = findCells();
|
||||
|
||||
// First and second rows
|
||||
expect(cells.at(0).text()).toBe('a');
|
||||
expect(cells.at(7).text()).toBe('b');
|
||||
|
||||
const headers = findHeaders();
|
||||
await headers.at(0).trigger('click');
|
||||
await headers.at(0).trigger('click');
|
||||
|
||||
// First and second rows are not swapped
|
||||
expect(cells.at(0).text()).toBe('a');
|
||||
expect(cells.at(7).text()).toBe('b');
|
||||
});
|
||||
|
||||
it('change the busy state in the table', async () => {
|
||||
expect(findTable().attributes('aria-busy')).toBe('true');
|
||||
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(findTable().attributes('aria-busy')).toBe('false');
|
||||
});
|
||||
|
||||
describe('when a new token is created', () => {
|
||||
it('replaces the window history', async () => {
|
||||
const replaceStateSpy = jest.spyOn(window.history, 'replaceState');
|
||||
await triggerSuccess();
|
||||
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '?page=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('does not show pagination component', async () => {
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(findPagination().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('when number of tokens exceeds the first page', () => {
|
||||
beforeEach(() => {
|
||||
const accessTokens = Array(21).fill(defaultActiveAccessTokens[0]);
|
||||
|
||||
const headers = {
|
||||
'X-Page': 1,
|
||||
'X-Per-Page': 20,
|
||||
'X-Total': accessTokens.length,
|
||||
};
|
||||
mockAxios.onGet().reply(
|
||||
HTTP_STATUS_OK,
|
||||
[
|
||||
{
|
||||
active_access_tokens: accessTokens,
|
||||
total: accessTokens.length,
|
||||
},
|
||||
],
|
||||
headers,
|
||||
);
|
||||
createComponent({ initialActiveAccessTokens: accessTokens, backendPagination: true });
|
||||
});
|
||||
|
||||
it('shows the pagination component', async () => {
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(findPagination().exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('when clicked on the second page', () => {
|
||||
it('replace the window history', async () => {
|
||||
await axios.waitForAll();
|
||||
|
||||
const replaceStateSpy = jest.spyOn(window.history, 'replaceState');
|
||||
await findPagination().vm.$emit('input', 2);
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '?page=2');
|
||||
});
|
||||
|
||||
it('scrolls to the top', async () => {
|
||||
await axios.waitForAll();
|
||||
|
||||
const scrollToSpy = jest.spyOn(window, 'scrollTo');
|
||||
await findPagination().vm.$emit('input', 2);
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(findPagination().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -909,26 +909,6 @@ describe('Api', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pipelineJobs', () => {
|
||||
it.each([undefined, {}, { foo: true }])(
|
||||
'fetches the jobs for a given pipeline given %p params',
|
||||
async (params) => {
|
||||
const projectId = 123;
|
||||
const pipelineId = 456;
|
||||
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
|
||||
const payload = [
|
||||
{
|
||||
name: 'test',
|
||||
},
|
||||
];
|
||||
mock.onGet(expectedUrl, { params }).reply(HTTP_STATUS_OK, payload);
|
||||
|
||||
const { data } = await Api.pipelineJobs(projectId, pipelineId, params);
|
||||
expect(data).toEqual(payload);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('createBranch', () => {
|
||||
it('creates new branch', () => {
|
||||
const ref = 'main';
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ RSpec.describe Types::MemberInterface do
|
|||
access_level
|
||||
created_by
|
||||
created_at
|
||||
last_activity_on
|
||||
updated_at
|
||||
expires_at
|
||||
user
|
||||
|
|
@ -18,6 +19,8 @@ RSpec.describe Types::MemberInterface do
|
|||
expect(described_class).to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
|
||||
it { expect(described_class.fields['lastActivityOn']).to have_graphql_type(Types::TimeType) }
|
||||
|
||||
describe '.resolve_type' do
|
||||
subject { described_class.resolve_type(object, {}) }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::BackgroundMigration::BackfillMembersRequestAcceptedAt, schema: 20240920083708, feature_category: :groups_and_projects do
|
||||
let!(:namespace) { table(:namespaces).create!({ name: "test-1", path: "test-1", owner_id: 1 }) }
|
||||
let!(:member) { table(:members) }
|
||||
let!(:member_data) do
|
||||
{
|
||||
access_level: ::Gitlab::Access::MAINTAINER,
|
||||
member_namespace_id: namespace.id,
|
||||
notification_level: 3,
|
||||
source_type: "Namespace",
|
||||
source_id: 22,
|
||||
created_at: "2024-09-14 06:06:16.649264"
|
||||
}
|
||||
end
|
||||
|
||||
let!(:member1) { member.create!(member_data) }
|
||||
let!(:member2) { member.create!(member_data) }
|
||||
let!(:member3) { member.create!(member_data.merge(requested_at: Time.current)) }
|
||||
let!(:member4) { member.create!(member_data.merge(invite_token: 'token')) }
|
||||
let!(:member5) { member.create!(member_data.merge(request_accepted_at: Time.current)) }
|
||||
let!(:member6) { member.create!(member_data.merge(invite_accepted_at: Time.current)) }
|
||||
|
||||
subject(:migration) do
|
||||
described_class.new(
|
||||
start_id: member1.id,
|
||||
end_id: member6.id,
|
||||
batch_table: :members,
|
||||
batch_column: :id,
|
||||
sub_batch_size: 100,
|
||||
pause_ms: 0,
|
||||
connection: ApplicationRecord.connection
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when `requested_at`, `invite_token`, `invite_accepted_at` and `request_accepted_at` are set to nil' do
|
||||
it 'backfills `request_accepted_at` column to `created_at` for eligible members' do
|
||||
expect { migration.perform }
|
||||
.to change { member1.reload.request_accepted_at }.from(nil).to(member1.created_at)
|
||||
.and change { member2.reload.request_accepted_at }.from(nil).to(member2.created_at)
|
||||
.and not_change { member3.reload.request_accepted_at }
|
||||
.and not_change { member4.reload.request_accepted_at }
|
||||
.and not_change { member5.reload.request_accepted_at }
|
||||
.and not_change { member6.reload.request_accepted_at }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require_migration!
|
||||
|
||||
RSpec.describe QueueBackfillMembersRequestAcceptedAt, feature_category: :groups_and_projects do
|
||||
let!(:batched_migration) { described_class::MIGRATION }
|
||||
|
||||
it 'schedules a new batched migration' do
|
||||
reversible_migration do |migration|
|
||||
migration.before -> {
|
||||
expect(batched_migration).not_to have_scheduled_batched_migration
|
||||
}
|
||||
|
||||
migration.after -> {
|
||||
expect(batched_migration).to have_scheduled_batched_migration(
|
||||
table_name: :members,
|
||||
column_name: :id,
|
||||
interval: described_class::DELAY_INTERVAL,
|
||||
batch_size: described_class::BATCH_SIZE,
|
||||
sub_batch_size: described_class::SUB_BATCH_SIZE
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -51,6 +51,7 @@ RSpec.describe Admin::ImpersonationTokensController, :enable_admin_mode, feature
|
|||
describe "#create", :with_current_organization do
|
||||
it_behaves_like "#create access token" do
|
||||
let(:url) { admin_user_impersonation_tokens_path(user_id: user.username) }
|
||||
let(:token_attributes) { attributes_for(:personal_access_token, impersonation: true) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ RSpec.describe 'GroupMember', feature_category: :groups_and_projects do
|
|||
integerValue
|
||||
stringValue
|
||||
}
|
||||
lastActivityOn
|
||||
group {
|
||||
id
|
||||
}
|
||||
|
|
@ -30,4 +31,6 @@ RSpec.describe 'GroupMember', feature_category: :groups_and_projects do
|
|||
|
||||
it_behaves_like 'a working graphql query'
|
||||
it_behaves_like 'a working membership object query'
|
||||
|
||||
it { expect(graphql_data.dig('user', 'groupMemberships', 'nodes', 0, 'lastActivityOn')).to be_present }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ RSpec.describe 'ProjectMember', feature_category: :groups_and_projects do
|
|||
integerValue
|
||||
stringValue
|
||||
}
|
||||
lastActivityOn
|
||||
project {
|
||||
id
|
||||
}
|
||||
|
|
@ -30,4 +31,6 @@ RSpec.describe 'ProjectMember', feature_category: :groups_and_projects do
|
|||
|
||||
it_behaves_like 'a working graphql query'
|
||||
it_behaves_like 'a working membership object query'
|
||||
|
||||
it { expect(graphql_data.dig('user', 'projectMemberships', 'nodes', 0, 'lastActivityOn')).to be_present }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ RSpec.shared_examples '#create access token' do
|
|||
|
||||
parsed_body = Gitlab::Json.parse(response.body)
|
||||
expect(parsed_body['new_token']).not_to be_blank
|
||||
expect(parsed_body['active_access_tokens'].length).to be > 0
|
||||
expect(parsed_body['total']).to be > 0
|
||||
expect(parsed_body['errors']).to be_blank
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ RSpec.shared_examples 'POST resource access tokens available' do
|
|||
|
||||
parsed_body = Gitlab::Json.parse(response.body)
|
||||
expect(parsed_body['new_token']).not_to be_blank
|
||||
expect(parsed_body['active_access_tokens'].length).to be > 0
|
||||
expect(parsed_body['total']).to be > 0
|
||||
expect(parsed_body['errors']).to be_blank
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue