Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
346c2ebb5a
commit
042cd704b8
|
@ -525,6 +525,8 @@
|
||||||
|
|
||||||
.qa:rules:package-and-qa:
|
.qa:rules:package-and-qa:
|
||||||
rules:
|
rules:
|
||||||
|
- <<: *if-not-ee
|
||||||
|
when: never
|
||||||
- <<: *if-dot-com-gitlab-org-and-security-merge-request
|
- <<: *if-dot-com-gitlab-org-and-security-merge-request
|
||||||
changes: *ci-qa-patterns
|
changes: *ci-qa-patterns
|
||||||
allow_failure: true
|
allow_failure: true
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui';
|
||||||
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
|
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
|
||||||
import Mousetrap from 'mousetrap';
|
import Mousetrap from 'mousetrap';
|
||||||
import { mapState, mapGetters, mapActions } from 'vuex';
|
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||||
|
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
|
||||||
import api from '~/api';
|
import api from '~/api';
|
||||||
import {
|
import {
|
||||||
keysFor,
|
keysFor,
|
||||||
|
@ -17,7 +18,6 @@ import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
|
||||||
import { updateHistory } from '~/lib/utils/url_utility';
|
import { updateHistory } from '~/lib/utils/url_utility';
|
||||||
import { __ } from '~/locale';
|
import { __ } from '~/locale';
|
||||||
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
|
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
|
||||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
|
||||||
|
|
||||||
import notesEventHub from '../../notes/event_hub';
|
import notesEventHub from '../../notes/event_hub';
|
||||||
import {
|
import {
|
||||||
|
@ -69,8 +69,9 @@ export default {
|
||||||
PanelResizer,
|
PanelResizer,
|
||||||
GlPagination,
|
GlPagination,
|
||||||
GlSprintf,
|
GlSprintf,
|
||||||
|
DynamicScroller,
|
||||||
|
DynamicScrollerItem,
|
||||||
},
|
},
|
||||||
mixins: [glFeatureFlagsMixin()],
|
|
||||||
alerts: {
|
alerts: {
|
||||||
ALERT_OVERFLOW_HIDDEN,
|
ALERT_OVERFLOW_HIDDEN,
|
||||||
ALERT_MERGE_CONFLICT,
|
ALERT_MERGE_CONFLICT,
|
||||||
|
@ -196,7 +197,12 @@ export default {
|
||||||
'renderTreeList',
|
'renderTreeList',
|
||||||
'showWhitespace',
|
'showWhitespace',
|
||||||
]),
|
]),
|
||||||
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
|
...mapGetters('diffs', [
|
||||||
|
'whichCollapsedTypes',
|
||||||
|
'isParallelView',
|
||||||
|
'currentDiffIndex',
|
||||||
|
'isVirtualScrollingEnabled',
|
||||||
|
]),
|
||||||
...mapGetters('batchComments', ['draftsCount']),
|
...mapGetters('batchComments', ['draftsCount']),
|
||||||
...mapGetters(['isNotesFetched', 'getNoteableData']),
|
...mapGetters(['isNotesFetched', 'getNoteableData']),
|
||||||
diffs() {
|
diffs() {
|
||||||
|
@ -561,17 +567,41 @@ export default {
|
||||||
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
|
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
|
||||||
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
|
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
|
||||||
<template v-else-if="renderDiffFiles">
|
<template v-else-if="renderDiffFiles">
|
||||||
<diff-file
|
<dynamic-scroller
|
||||||
v-for="(file, index) in diffs"
|
v-if="isVirtualScrollingEnabled"
|
||||||
:key="file.newPath"
|
:items="diffs"
|
||||||
:file="file"
|
:min-item-size="70"
|
||||||
:reviewed="fileReviews[file.id]"
|
:buffer="1000"
|
||||||
:is-first-file="index === 0"
|
:use-transform="false"
|
||||||
:is-last-file="index === diffFilesLength - 1"
|
page-mode
|
||||||
:help-page-path="helpPagePath"
|
>
|
||||||
:can-current-user-fork="canCurrentUserFork"
|
<template #default="{ item, index, active }">
|
||||||
:view-diffs-file-by-file="viewDiffsFileByFile"
|
<dynamic-scroller-item :item="item" :active="active">
|
||||||
/>
|
<diff-file
|
||||||
|
:file="item"
|
||||||
|
:reviewed="fileReviews[item.id]"
|
||||||
|
:is-first-file="index === 0"
|
||||||
|
:is-last-file="index === diffFilesLength - 1"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
|
:can-current-user-fork="canCurrentUserFork"
|
||||||
|
:view-diffs-file-by-file="viewDiffsFileByFile"
|
||||||
|
/>
|
||||||
|
</dynamic-scroller-item>
|
||||||
|
</template>
|
||||||
|
</dynamic-scroller>
|
||||||
|
<template v-else>
|
||||||
|
<diff-file
|
||||||
|
v-for="(file, index) in diffs"
|
||||||
|
:key="file.new_path"
|
||||||
|
:file="file"
|
||||||
|
:reviewed="fileReviews[file.id]"
|
||||||
|
:is-first-file="index === 0"
|
||||||
|
:is-last-file="index === diffFilesLength - 1"
|
||||||
|
:help-page-path="helpPagePath"
|
||||||
|
:can-current-user-fork="canCurrentUserFork"
|
||||||
|
:view-diffs-file-by-file="viewDiffsFileByFile"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<div
|
<div
|
||||||
v-if="showFileByFileNavigation"
|
v-if="showFileByFileNavigation"
|
||||||
data-testid="file-by-file-navigation"
|
data-testid="file-by-file-navigation"
|
||||||
|
|
|
@ -49,9 +49,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState('diffs', ['projectPath']),
|
||||||
projectPath: (state) => state.diffs.projectPath,
|
|
||||||
}),
|
|
||||||
...mapGetters('diffs', [
|
...mapGetters('diffs', [
|
||||||
'isInlineView',
|
'isInlineView',
|
||||||
'isParallelView',
|
'isParallelView',
|
||||||
|
|
|
@ -83,7 +83,7 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']),
|
...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']),
|
||||||
...mapGetters(['isNotesFetched']),
|
...mapGetters(['isNotesFetched']),
|
||||||
...mapGetters('diffs', ['getDiffFileDiscussions']),
|
...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']),
|
||||||
viewBlobHref() {
|
viewBlobHref() {
|
||||||
return escape(this.file.view_path);
|
return escape(this.file.view_path);
|
||||||
},
|
},
|
||||||
|
@ -290,6 +290,7 @@ export default {
|
||||||
'is-active': currentDiffFileId === file.file_hash,
|
'is-active': currentDiffFileId === file.file_hash,
|
||||||
'comments-disabled': Boolean(file.brokenSymlink),
|
'comments-disabled': Boolean(file.brokenSymlink),
|
||||||
'has-body': showBody,
|
'has-body': showBody,
|
||||||
|
'is-virtual-scrolling': isVirtualScrollingEnabled,
|
||||||
}"
|
}"
|
||||||
:data-path="file.new_path"
|
:data-path="file.new_path"
|
||||||
class="diff-file file-holder gl-border-none"
|
class="diff-file file-holder gl-border-none"
|
||||||
|
|
|
@ -170,3 +170,6 @@ export function suggestionCommitMessage(state, _, rootState) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isVirtualScrollingEnabled = (state) =>
|
||||||
|
!state.viewDiffsFileByFile && window.gon?.features?.diffsVirtualScrolling;
|
||||||
|
|
|
@ -729,7 +729,7 @@ table.code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.files {
|
.files {
|
||||||
.diff-file:last-child {
|
.diff-file:not(.is-virtual-scrolling):last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,10 @@
|
||||||
.diff-files-holder {
|
.diff-files-holder {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
.vue-recycle-scroller__item-wrapper {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.with-system-header {
|
.with-system-header {
|
||||||
|
|
|
@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
|
||||||
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
|
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
|
||||||
push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
|
push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
|
||||||
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
|
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
|
||||||
|
push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml)
|
||||||
|
|
||||||
# Usage data feature flags
|
# Usage data feature flags
|
||||||
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
|
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
|
||||||
|
|
|
@ -128,8 +128,41 @@ class Project < ApplicationRecord
|
||||||
after_initialize :use_hashed_storage
|
after_initialize :use_hashed_storage
|
||||||
after_create :check_repository_absence!
|
after_create :check_repository_absence!
|
||||||
|
|
||||||
acts_as_ordered_taggable
|
acts_as_ordered_taggable_on :topics
|
||||||
alias_method :topics, :tag_list
|
# The 'tag_list' alias and the 'has_many' associations are required during the 'tags -> topics' migration
|
||||||
|
# TODO: eliminate 'tag_list', 'topic_taggings' and 'tags' in the further process of the migration
|
||||||
|
# https://gitlab.com/gitlab-org/gitlab/-/issues/331081
|
||||||
|
alias_attribute :tag_list, :topic_list
|
||||||
|
has_many :topic_taggings, -> { includes(:tag).order("#{ActsAsTaggableOn::Tagging.table_name}.id") },
|
||||||
|
as: :taggable,
|
||||||
|
class_name: 'ActsAsTaggableOn::Tagging',
|
||||||
|
after_add: :dirtify_tag_list,
|
||||||
|
after_remove: :dirtify_tag_list
|
||||||
|
has_many :topics, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") },
|
||||||
|
class_name: 'ActsAsTaggableOn::Tag',
|
||||||
|
through: :topic_taggings,
|
||||||
|
source: :tag
|
||||||
|
has_many :tags, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") },
|
||||||
|
class_name: 'ActsAsTaggableOn::Tag',
|
||||||
|
through: :topic_taggings,
|
||||||
|
source: :tag
|
||||||
|
|
||||||
|
# Overwriting 'topic_list' and 'topic_list=' is necessary to ensure functionality during the background migration [1].
|
||||||
|
# [1] https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61237
|
||||||
|
# TODO: remove 'topic_list' and 'topic_list=' once the background migration is complete
|
||||||
|
# https://gitlab.com/gitlab-org/gitlab/-/issues/331081
|
||||||
|
def topic_list
|
||||||
|
# Return both old topics (context 'tags') and new topics (context 'topics')
|
||||||
|
tag_list_on('tags') + tag_list_on('topics')
|
||||||
|
end
|
||||||
|
|
||||||
|
def topic_list=(new_tags)
|
||||||
|
# Old topics with context 'tags' are added as new topics with context 'topics'
|
||||||
|
super(new_tags)
|
||||||
|
|
||||||
|
# Remove old topics with context 'tags'
|
||||||
|
set_tag_list_on('tags', '')
|
||||||
|
end
|
||||||
|
|
||||||
attr_accessor :old_path_with_namespace
|
attr_accessor :old_path_with_namespace
|
||||||
attr_accessor :template_name
|
attr_accessor :template_name
|
||||||
|
|
|
@ -401,16 +401,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
|
||||||
end
|
end
|
||||||
|
|
||||||
def topics_to_show
|
def topics_to_show
|
||||||
project.topics.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
|
project.topic_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def topics_not_shown
|
def topics_not_shown
|
||||||
project.topics - topics_to_show
|
project.topic_list - topics_to_show
|
||||||
end
|
end
|
||||||
|
|
||||||
def count_of_extra_topics_not_shown
|
def count_of_extra_topics_not_shown
|
||||||
if project.topics.count > MAX_TOPICS_TO_SHOW
|
if project.topic_list.count > MAX_TOPICS_TO_SHOW
|
||||||
project.topics.count - MAX_TOPICS_TO_SHOW
|
project.topic_list.count - MAX_TOPICS_TO_SHOW
|
||||||
else
|
else
|
||||||
0
|
0
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Migrate 'tags' to 'topics' for project in the database context
|
||||||
|
merge_request: 61237
|
||||||
|
author: Jonas Wälter @wwwjon
|
||||||
|
type: changed
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
name: diffs_virtual_scrolling
|
||||||
|
introduced_by_url:
|
||||||
|
rollout_issue_url:
|
||||||
|
milestone: '13.12'
|
||||||
|
type: development
|
||||||
|
group: group::code review
|
||||||
|
default_enabled: false
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddTemporaryIndexForProjectTopicsToTaggings < ActiveRecord::Migration[6.0]
|
||||||
|
include Gitlab::Database::MigrationHelpers
|
||||||
|
|
||||||
|
INDEX_NAME = 'tmp_index_taggings_on_id_where_taggable_type_project_and_tags'
|
||||||
|
INDEX_CONDITION = "taggable_type = 'Project' AND context = 'tags'"
|
||||||
|
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
# this index is used in 20210511095658_schedule_migrate_project_taggings_context_from_tags_to_topics
|
||||||
|
add_concurrent_index :taggings, :id, where: INDEX_CONDITION, name: INDEX_NAME
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_concurrent_index_by_name :taggings, INDEX_NAME
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ScheduleMigrateProjectTaggingsContextFromTagsToTopics < ActiveRecord::Migration[6.0]
|
||||||
|
include Gitlab::Database::MigrationHelpers
|
||||||
|
|
||||||
|
DOWNTIME = false
|
||||||
|
BATCH_SIZE = 30_000
|
||||||
|
DELAY_INTERVAL = 2.minutes
|
||||||
|
MIGRATION = 'MigrateProjectTaggingsContextFromTagsToTopics'
|
||||||
|
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
class Tagging < ActiveRecord::Base
|
||||||
|
include ::EachBatch
|
||||||
|
|
||||||
|
self.table_name = 'taggings'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
queue_background_migration_jobs_by_range_at_intervals(
|
||||||
|
Tagging.where(taggable_type: 'Project', context: 'tags'),
|
||||||
|
MIGRATION,
|
||||||
|
DELAY_INTERVAL,
|
||||||
|
batch_size: BATCH_SIZE
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RemoveTemporaryIndexForProjectTopicsToTaggings < ActiveRecord::Migration[6.0]
|
||||||
|
include Gitlab::Database::MigrationHelpers
|
||||||
|
|
||||||
|
INDEX_NAME = 'tmp_index_taggings_on_id_where_taggable_type_project_and_tags'
|
||||||
|
INDEX_CONDITION = "taggable_type = 'Project' AND context = 'tags'"
|
||||||
|
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
# this index was used in 20210511095658_schedule_migrate_project_taggings_context_from_tags_to_topics
|
||||||
|
remove_concurrent_index_by_name :taggings, INDEX_NAME
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
add_concurrent_index :taggings, :id, where: INDEX_CONDITION, name: INDEX_NAME
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1 @@
|
||||||
|
4d11cdf876786db5e827ea1a50b70e2d5b3814fd7c0b0c083ab61adad9685364
|
|
@ -0,0 +1 @@
|
||||||
|
7387c23bbbc376e26c057179ebe2796be183462acb1fc509d451f0fede13ed93
|
|
@ -0,0 +1 @@
|
||||||
|
ec08c18ac37f2ae7298650df58345755eada20aaa5b7ed3dfd54ee5cea88ebdd
|
|
@ -563,6 +563,7 @@ smartcard
|
||||||
smartcards
|
smartcards
|
||||||
snapshotting
|
snapshotting
|
||||||
Sobelow
|
Sobelow
|
||||||
|
Solargraph
|
||||||
Solarized
|
Solarized
|
||||||
Sourcegraph
|
Sourcegraph
|
||||||
sparkline
|
sparkline
|
||||||
|
|
|
@ -232,7 +232,7 @@ These variables are injected into the pipeline jobs and can access the ECS API.
|
||||||
Change a file in the project and see if it's reflected in the demo application on ECS:
|
Change a file in the project and see if it's reflected in the demo application on ECS:
|
||||||
|
|
||||||
1. Go to **ecs-demo** project on GitLab.
|
1. Go to **ecs-demo** project on GitLab.
|
||||||
1. Open the file at **app > views > welcome > index.html.erb**.
|
1. Open the file at **app > views > welcome > `index.html.erb`**.
|
||||||
1. Click **Edit**.
|
1. Click **Edit**.
|
||||||
1. Change the text to `You're on ECS!`.
|
1. Change the text to `You're on ECS!`.
|
||||||
1. Click **Commit Changes**. This automatically triggers a new pipeline. Wait until it finishes.
|
1. Click **Commit Changes**. This automatically triggers a new pipeline. Wait until it finishes.
|
||||||
|
|
|
@ -116,7 +116,7 @@ Before performing any of these tests, if you have a `k3s` instance running, make
|
||||||
stop it manually before running them. Otherwise, the tests might fail with the message
|
stop it manually before running them. Otherwise, the tests might fail with the message
|
||||||
`failed to remove k3s cluster`.
|
`failed to remove k3s cluster`.
|
||||||
|
|
||||||
You might need to specify the correct Agent image version that matches the `kas` image version. You can use the `GITLAB_AGENTK_VERSION` local env for this.
|
You might need to specify the correct Agent image version that matches the `kas` image version. You can use the `GITLAB_AGENTK_VERSION` local environment for this.
|
||||||
|
|
||||||
### Against `staging`
|
### Against `staging`
|
||||||
|
|
||||||
|
@ -124,7 +124,7 @@ You might need to specify the correct Agent image version that matches the `kas`
|
||||||
[this line](https://gitlab.com/gitlab-org/gitlab/-/blob/5b15540ea78298a106150c3a1d6ed26416109b9d/qa/qa/service/cluster_provider/k3s.rb#L8) and
|
[this line](https://gitlab.com/gitlab-org/gitlab/-/blob/5b15540ea78298a106150c3a1d6ed26416109b9d/qa/qa/service/cluster_provider/k3s.rb#L8) and
|
||||||
[this line](https://gitlab.com/gitlab-org/gitlab/-/blob/5b15540ea78298a106150c3a1d6ed26416109b9d/qa/qa/service/cluster_provider/k3s.rb#L36).
|
[this line](https://gitlab.com/gitlab-org/gitlab/-/blob/5b15540ea78298a106150c3a1d6ed26416109b9d/qa/qa/service/cluster_provider/k3s.rb#L36).
|
||||||
We don't allow local connections on `staging` as they require an admin user.
|
We don't allow local connections on `staging` as they require an admin user.
|
||||||
1. Ensure you don't have an `EE_LICENSE` env var set as this would force an admin login.
|
1. Ensure you don't have an `EE_LICENSE` environment variable set as this would force an admin login.
|
||||||
1. Go to your GDK root folder and `cd gitlab/qa`.
|
1. Go to your GDK root folder and `cd gitlab/qa`.
|
||||||
1. Login with your user in staging and create a group to be used as sandbox.
|
1. Login with your user in staging and create a group to be used as sandbox.
|
||||||
Something like: `username-qa-sandbox`.
|
Something like: `username-qa-sandbox`.
|
||||||
|
|
|
@ -101,7 +101,7 @@ You can check for any offenses locally with `bundle exec rubocop --parallel`.
|
||||||
On the CI, this is automatically checked by the `static-analysis` jobs.
|
On the CI, this is automatically checked by the `static-analysis` jobs.
|
||||||
|
|
||||||
In addition, you can [integrate RuboCop](../developing_with_solargraph.md) into
|
In addition, you can [integrate RuboCop](../developing_with_solargraph.md) into
|
||||||
supported IDEs using the [solargraph](https://github.com/castwide/solargraph) gem.
|
supported IDEs using the [Solargraph](https://github.com/castwide/solargraph) gem.
|
||||||
|
|
||||||
For RuboCop rules that we have not taken a decision on yet, we follow the
|
For RuboCop rules that we have not taken a decision on yet, we follow the
|
||||||
[Ruby Style Guide](https://github.com/rubocop-hq/ruby-style-guide),
|
[Ruby Style Guide](https://github.com/rubocop-hq/ruby-style-guide),
|
||||||
|
|
|
@ -8,21 +8,21 @@ info: To determine the technical writer assigned to the Stage/Group associated w
|
||||||
|
|
||||||
Gemfile packages [Solargraph](https://github.com/castwide/solargraph) language server for additional IntelliSense and code formatting capabilities with editors that support it.
|
Gemfile packages [Solargraph](https://github.com/castwide/solargraph) language server for additional IntelliSense and code formatting capabilities with editors that support it.
|
||||||
|
|
||||||
Example configuration for solargraph can be found in [.solargraph.yml.example](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.solargraph.yml.example) file. Copy the contents of this file to `.solargraph.yml` file for language server to pick this configuration up. Since `.solargraph.yml` configuration file is ignored by Git, it's possible to adjust configuration according to your needs.
|
Example configuration for Solargraph can be found in [.solargraph.yml.example](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.solargraph.yml.example) file. Copy the contents of this file to `.solargraph.yml` file for language server to pick this configuration up. Since `.solargraph.yml` configuration file is ignored by Git, it's possible to adjust configuration according to your needs.
|
||||||
|
|
||||||
Refer to particular IDE plugin documentation on how to integrate it with solargraph language server:
|
Refer to particular IDE plugin documentation on how to integrate it with Solargraph language server:
|
||||||
|
|
||||||
- **Visual Studio Code**
|
- **Visual Studio Code**
|
||||||
- GitHub: [vscode-solargraph](https://github.com/castwide/vscode-solargraph)
|
- GitHub: [`vscode-solargraph`](https://github.com/castwide/vscode-solargraph)
|
||||||
|
|
||||||
- **Atom**
|
- **Atom**
|
||||||
- GitHub: [atom-solargraph](https://github.com/castwide/atom-solargraph)
|
- GitHub: [`atom-solargraph`](https://github.com/castwide/atom-solargraph)
|
||||||
|
|
||||||
- **Vim**
|
- **Vim**
|
||||||
- GitHub: [LanguageClient-neovim](https://github.com/autozimu/LanguageClient-neovim)
|
- GitHub: [`LanguageClient-neovim`](https://github.com/autozimu/LanguageClient-neovim)
|
||||||
|
|
||||||
- **Emacs**
|
- **Emacs**
|
||||||
- GitHub: [emacs-solargraph](https://github.com/guskovd/emacs-solargraph)
|
- GitHub: [`emacs-solargraph`](https://github.com/guskovd/emacs-solargraph)
|
||||||
|
|
||||||
- **Eclipse**
|
- **Eclipse**
|
||||||
- GitHub: [eclipse-solargraph](https://github.com/PyvesB/eclipse-solargraph)
|
- GitHub: [`eclipse-solargraph`](https://github.com/PyvesB/eclipse-solargraph)
|
||||||
|
|
|
@ -59,4 +59,4 @@ feature flags, and there is currently no strong suggestion to use one over the o
|
||||||
|
|
||||||
Historical Context: `Experimentation Module` was built iteratively with the needs that
|
Historical Context: `Experimentation Module` was built iteratively with the needs that
|
||||||
appeared while implementing Growth sub-department experiments, while GLEX was built
|
appeared while implementing Growth sub-department experiments, while GLEX was built
|
||||||
with the learnings of the team and an easier to use API.
|
with the findings of the team and an easier to use API.
|
||||||
|
|
|
@ -782,7 +782,7 @@ While the Apollo client has support for simple polling, for performance reasons,
|
||||||
|
|
||||||
Once the backend is set up, there are a few changes to make on the frontend.
|
Once the backend is set up, there are a few changes to make on the frontend.
|
||||||
|
|
||||||
First, get your resource Etag path from the backend. In the example of the pipelines graph, this is called the `graphql_resource_etag`. This will be used to create new headers to add to the Apollo context:
|
First, get your resource ETag path from the backend. In the example of the pipelines graph, this is called the `graphql_resource_etag`. This will be used to create new headers to add to the Apollo context:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/* pipelines/components/graph/utils.js */
|
/* pipelines/components/graph/utils.js */
|
||||||
|
@ -817,7 +817,7 @@ apollo: {
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, because Etags depend on the request being a `GET` instead of GraphQL's usual `POST`, but our default link library does not support `GET` we need to let our default Apollo client know to use a different library.
|
Then, because ETags depend on the request being a `GET` instead of GraphQL's usual `POST`, but our default link library does not support `GET` we need to let our default Apollo client know to use a different library.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/* componentMountIndex.js */
|
/* componentMountIndex.js */
|
||||||
|
|
|
@ -115,7 +115,7 @@ operator](https://thoughtbot.com/blog/rubys-pessimistic-operator))
|
||||||
making it possible to upgrade `license_finder` or any other gem to a
|
making it possible to upgrade `license_finder` or any other gem to a
|
||||||
version that depends on `thor 1.2`.
|
version that depends on `thor 1.2`.
|
||||||
|
|
||||||
Simlarly, if `license_finder` had a vulnerability fixed in 6.0.1, we
|
Similarly, if `license_finder` had a vulnerability fixed in 6.0.1, we
|
||||||
should add:
|
should add:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
|
@ -127,7 +127,7 @@ still depend on a newer version of `thor`, such as `6.0.2`, but would
|
||||||
not be able to depend on the vulnerable version `6.0.0`.
|
not be able to depend on the vulnerable version `6.0.0`.
|
||||||
|
|
||||||
A downgrade like that could happen if we introduced a new dependency
|
A downgrade like that could happen if we introduced a new dependency
|
||||||
that also relied on thor but had its version pinned to a vulnerable
|
that also relied on `thor` but had its version pinned to a vulnerable
|
||||||
one. These changes are easy to miss in the `Gemfile.lock`. Pinning the
|
one. These changes are easy to miss in the `Gemfile.lock`. Pinning the
|
||||||
version would result in a conflict that would need to be solved.
|
version would result in a conflict that would need to be solved.
|
||||||
|
|
||||||
|
|
|
@ -501,7 +501,7 @@ up to run `goimports -local gitlab.com/gitlab-org` so that it's applied to every
|
||||||
|
|
||||||
### Analyzer Tests
|
### Analyzer Tests
|
||||||
|
|
||||||
The conventional Secure [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/) has a [`convert` function](https://gitlab.com/gitlab-org/security-products/analyzers/command/-/blob/main/convert.go#L15-17) that converts SAST/DAST scanner reports into [GitLab Security Reports](https://gitlab.com/gitlab-org/security-products/security-report-schemas). When writing tests for the `convert` function, we should make use of [test fixtures](https://dave.cheney.net/2016/05/10/test-fixtures-in-go) using a `testdata` directory at the root of the analyzer's repo. The `testdata` directory should contain two subdirectories: `expect` and `reports`. The `reports` directory should contain sample SAST/DAST scanner reports which are passed into the `convert` function during the test setup. The `expect` directory should contain the expected GitLab Security Report that the `convert` returns. See Secret Detection for an [example](https://gitlab.com/gitlab-org/security-products/analyzers/secrets/-/blob/160424589ef1eed7b91b59484e019095bc7233bd/convert_test.go#L13-66).
|
The conventional Secure [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/) has a [`convert` function](https://gitlab.com/gitlab-org/security-products/analyzers/command/-/blob/main/convert.go#L15-17) that converts SAST/DAST scanner reports into [GitLab Security Reports](https://gitlab.com/gitlab-org/security-products/security-report-schemas). When writing tests for the `convert` function, we should make use of [test fixtures](https://dave.cheney.net/2016/05/10/test-fixtures-in-go) using a `testdata` directory at the root of the analyzer's repository. The `testdata` directory should contain two subdirectories: `expect` and `reports`. The `reports` directory should contain sample SAST/DAST scanner reports which are passed into the `convert` function during the test setup. The `expect` directory should contain the expected GitLab Security Report that the `convert` returns. See Secret Detection for an [example](https://gitlab.com/gitlab-org/security-products/analyzers/secrets/-/blob/160424589ef1eed7b91b59484e019095bc7233bd/convert_test.go#L13-66).
|
||||||
|
|
||||||
If the scanner report is small, less than 35 lines, then feel free to [inline the report](https://gitlab.com/gitlab-org/security-products/analyzers/sobelow/-/blob/8bd2428a/convert/convert_test.go#L13-77) rather than use a `testdata` directory.
|
If the scanner report is small, less than 35 lines, then feel free to [inline the report](https://gitlab.com/gitlab-org/security-products/analyzers/sobelow/-/blob/8bd2428a/convert/convert_test.go#L13-77) rather than use a `testdata` directory.
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ The dashboards for stage groups are at a very early stage. All contributions are
|
||||||
Read more about how we are using error budgets overall in our
|
Read more about how we are using error budgets overall in our
|
||||||
[handbook](https://about.gitlab.com/handbook/engineering/error-budgets/).
|
[handbook](https://about.gitlab.com/handbook/engineering/error-budgets/).
|
||||||
|
|
||||||
By default, the first row of panels on the dashbhoard will show the [error
|
By default, the first row of panels on the dashboard will show the [error
|
||||||
budget for the stage
|
budget for the stage
|
||||||
group](https://about.gitlab.com/handbook/engineering/error-budgets/#budget-spend-by-stage-group). This
|
group](https://about.gitlab.com/handbook/engineering/error-budgets/#budget-spend-by-stage-group). This
|
||||||
row shows how the features owned by
|
row shows how the features owned by
|
||||||
|
|
|
@ -1302,7 +1302,7 @@ A good guideline to follow: the more complex the component you may want to steer
|
||||||
|
|
||||||
- To capture large data structures just to have something
|
- To capture large data structures just to have something
|
||||||
- To just have some kind of test written
|
- To just have some kind of test written
|
||||||
- To capture highly volatile ui elements without stubbing them (Think of GitLab UI version updates)
|
- To capture highly volatile UI elements without stubbing them (Think of GitLab UI version updates)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ Product Intelligence files.
|
||||||
[Metrics Dictionary](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/development/usage_ping/dictionary.md) if it is needed.
|
[Metrics Dictionary](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/development/usage_ping/dictionary.md) if it is needed.
|
||||||
- Add a changelog [according to guidelines](../changelog.md).
|
- Add a changelog [according to guidelines](../changelog.md).
|
||||||
|
|
||||||
##### When adding or modifiying Snowplow events
|
##### When adding or modifying Snowplow events
|
||||||
|
|
||||||
- For frontend events, when relevant, add a screenshot of the event in
|
- For frontend events, when relevant, add a screenshot of the event in
|
||||||
the [testing tool](../snowplow/index.md#developing-and-testing-snowplow) used.
|
the [testing tool](../snowplow/index.md#developing-and-testing-snowplow) used.
|
||||||
|
@ -81,7 +81,7 @@ Any of the Product Intelligence engineers can be assigned for the Product Intell
|
||||||
- Check if a [feature flag is needed](index.md#recommendations).
|
- Check if a [feature flag is needed](index.md#recommendations).
|
||||||
- For tracking with Snowplow:
|
- For tracking with Snowplow:
|
||||||
- Check that the [event taxonomy](../snowplow/index.md#structured-event-taxonomy) is correct.
|
- Check that the [event taxonomy](../snowplow/index.md#structured-event-taxonomy) is correct.
|
||||||
- Check the [usage recomendations](../snowplow/index.md#usage-recommendations).
|
- Check the [usage recommendations](../snowplow/index.md#usage-recommendations).
|
||||||
- Metrics YAML definitions:
|
- Metrics YAML definitions:
|
||||||
- Check the metric `description`.
|
- Check the metric `description`.
|
||||||
- Check the metrics `key_path`.
|
- Check the metrics `key_path`.
|
||||||
|
|
|
@ -378,7 +378,7 @@ low may lead the reindexing process to take a very long time to complete.
|
||||||
|
|
||||||
The best value for this will depend on your cluster size, whether you're willing
|
The best value for this will depend on your cluster size, whether you're willing
|
||||||
to accept some degraded search performance during reindexing, and how important
|
to accept some degraded search performance during reindexing, and how important
|
||||||
it is for the reindex to finish quickly and unpause indexing.
|
it is for the reindex to finish quickly and resume indexing.
|
||||||
|
|
||||||
### Mark the most recent reindex job as failed and resume the indexing
|
### Mark the most recent reindex job as failed and resume the indexing
|
||||||
|
|
||||||
|
|
|
@ -148,7 +148,7 @@ If you upgrade your GitLab instance while the GitLab Runner is processing jobs,
|
||||||
|
|
||||||
As for the artifacts, the GitLab Runner will attempt to upload them three times, after which the job will eventually fail.
|
As for the artifacts, the GitLab Runner will attempt to upload them three times, after which the job will eventually fail.
|
||||||
|
|
||||||
To address the above two scenario's, it is adviced to do the following prior to upgrading:
|
To address the above two scenario's, it is advised to do the following prior to upgrading:
|
||||||
|
|
||||||
1. Plan your maintenance.
|
1. Plan your maintenance.
|
||||||
1. Pause your runners.
|
1. Pause your runners.
|
||||||
|
|
|
@ -50,7 +50,7 @@ The following table shows the supported metrics, at which level they are support
|
||||||
|
|
||||||
| Metric | Level | API version | Chart (UI) version | Comments |
|
| Metric | Level | API version | Chart (UI) version | Comments |
|
||||||
| --------------- | ----------- | --------------- | ---------- | ------- |
|
| --------------- | ----------- | --------------- | ---------- | ------- |
|
||||||
| `deployment_frequency` | Project-level | [13.7+](../../api/dora/metrics.md) | [13.8+](#deployment-frequency-charts) | The [old API endopint](../../api/dora4_project_analytics.md) was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/323713) in 13.10. |
|
| `deployment_frequency` | Project-level | [13.7+](../../api/dora/metrics.md) | [13.8+](#deployment-frequency-charts) | The [old API endpoint](../../api/dora4_project_analytics.md) was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/323713) in 13.10. |
|
||||||
| `deployment_frequency` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | |
|
| `deployment_frequency` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | |
|
||||||
| `lead_time_for_changes` | Project-level | [13.10+](../../api/dora/metrics.md) | [13.11+](#lead-time-charts) | Unit in seconds. Aggregation method is median. |
|
| `lead_time_for_changes` | Project-level | [13.10+](../../api/dora/metrics.md) | [13.11+](#lead-time-charts) | Unit in seconds. Aggregation method is median. |
|
||||||
| `lead_time_for_changes` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | Unit in seconds. Aggregation method is median. |
|
| `lead_time_for_changes` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | Unit in seconds. Aggregation method is median. |
|
||||||
|
|
|
@ -324,7 +324,7 @@ To allowlist specific vulnerabilities, follow these steps:
|
||||||
1. Set `GIT_STRATEGY: fetch` in your `.gitlab-ci.yml` file by following the instructions in
|
1. Set `GIT_STRATEGY: fetch` in your `.gitlab-ci.yml` file by following the instructions in
|
||||||
[overriding the container scanning template](#overriding-the-container-scanning-template).
|
[overriding the container scanning template](#overriding-the-container-scanning-template).
|
||||||
1. Define the allowlisted vulnerabilities in a YAML file named `vulnerability-allowlist.yml`. This must use
|
1. Define the allowlisted vulnerabilities in a YAML file named `vulnerability-allowlist.yml`. This must use
|
||||||
the format described in [vulnerability-allowlist.yml data format](#vulnerability-allowlistyml-data-format).
|
the format described in [`vulnerability-allowlist.yml` data format](#vulnerability-allowlistyml-data-format).
|
||||||
1. Add the `vulnerability-allowlist.yml` file to the root folder of your project's Git repository.
|
1. Add the `vulnerability-allowlist.yml` file to the root folder of your project's Git repository.
|
||||||
|
|
||||||
#### vulnerability-allowlist.yml data format
|
#### vulnerability-allowlist.yml data format
|
||||||
|
@ -365,9 +365,9 @@ This example excludes from `gl-container-scanning-report.json`:
|
||||||
|
|
||||||
You can specify container image in multiple ways:
|
You can specify container image in multiple ways:
|
||||||
|
|
||||||
- as image name only (ie. `centos`).
|
- as image name only (such as `centos`).
|
||||||
- as full image name with registry hostname (ie. `your.private.registry:5000/centos`).
|
- as full image name with registry hostname (such as `your.private.registry:5000/centos`).
|
||||||
- as full image name with registry hostname and sha256 label (ie. `registry.gitlab.com/gitlab-org/security-products/dast/webgoat-8.0@sha256`).
|
- as full image name with registry hostname and sha256 label (such as `registry.gitlab.com/gitlab-org/security-products/dast/webgoat-8.0@sha256`).
|
||||||
|
|
||||||
NOTE:
|
NOTE:
|
||||||
The string after CVE ID (`cups` and `libxml2` in the previous example) is an optional comment format. It has **no impact** on the handling of vulnerabilities. You can include comments to describe the vulnerability.
|
The string after CVE ID (`cups` and `libxml2` in the previous example) is an optional comment format. It has **no impact** on the handling of vulnerabilities. You can include comments to describe the vulnerability.
|
||||||
|
|
|
@ -594,7 +594,7 @@ can be added, removed, and modified by creating a custom configuration.
|
||||||
- Application Information Check
|
- Application Information Check
|
||||||
- Cleartext Authentication Check
|
- Cleartext Authentication Check
|
||||||
- FrameworkDebugModeCheck
|
- FrameworkDebugModeCheck
|
||||||
- Html Injection Check
|
- HTML Injection Check
|
||||||
- Insecure Http Methods Check
|
- Insecure Http Methods Check
|
||||||
- JSON Hijacking Check
|
- JSON Hijacking Check
|
||||||
- JSON Injection Check
|
- JSON Injection Check
|
||||||
|
@ -602,16 +602,16 @@ can be added, removed, and modified by creating a custom configuration.
|
||||||
- Session Cookie Check
|
- Session Cookie Check
|
||||||
- SQL Injection Check
|
- SQL Injection Check
|
||||||
- Token Check
|
- Token Check
|
||||||
- Xml Injection Check
|
- XML Injection Check
|
||||||
|
|
||||||
##### Full
|
##### Full
|
||||||
|
|
||||||
- Application Information Check
|
- Application Information Check
|
||||||
- Cleartext AuthenticationCheck
|
- Cleartext AuthenticationCheck
|
||||||
- Cors Check
|
- CORS Check
|
||||||
- Dns Rebinding Check
|
- DNS Rebinding Check
|
||||||
- Framework Debug Mode Check
|
- Framework Debug Mode Check
|
||||||
- Html Injection Check
|
- HTML Injection Check
|
||||||
- Insecure Http Methods Check
|
- Insecure Http Methods Check
|
||||||
- JSON Hijacking Check
|
- JSON Hijacking Check
|
||||||
- JSON Injection Check
|
- JSON Injection Check
|
||||||
|
@ -620,9 +620,9 @@ can be added, removed, and modified by creating a custom configuration.
|
||||||
- Sensitive Information Check
|
- Sensitive Information Check
|
||||||
- Session Cookie Check
|
- Session Cookie Check
|
||||||
- SQL Injection Check
|
- SQL Injection Check
|
||||||
- Tls Configuration Check
|
- TLS Configuration Check
|
||||||
- Token Check
|
- Token Check
|
||||||
- Xml Injection Check
|
- XML Injection Check
|
||||||
|
|
||||||
### Available CI/CD variables
|
### Available CI/CD variables
|
||||||
|
|
||||||
|
|
|
@ -510,7 +510,7 @@ ensure that it can reach your private repository. Here is an example configurati
|
||||||
|
|
||||||
## Hosting a copy of the gemnasium_db advisory database
|
## Hosting a copy of the gemnasium_db advisory database
|
||||||
|
|
||||||
The [gemnasium_db](https://gitlab.com/gitlab-org/security-products/gemnasium-db) Git repository is
|
The [`gemnasium_db`](https://gitlab.com/gitlab-org/security-products/gemnasium-db) Git repository is
|
||||||
used by `gemnasium`, `gemnasium-maven`, and `gemnasium-python` as the source of vulnerability data.
|
used by `gemnasium`, `gemnasium-maven`, and `gemnasium-python` as the source of vulnerability data.
|
||||||
This repository updates at scan time to fetch the latest advisories. However, due to a restricted
|
This repository updates at scan time to fetch the latest advisories. However, due to a restricted
|
||||||
networking environment, running this update is sometimes not possible. In this case, a user can do
|
networking environment, running this update is sometimes not possible. In this case, a user can do
|
||||||
|
|
|
@ -710,7 +710,7 @@ documentation for instructions.
|
||||||
|
|
||||||
## Running SAST in SELinux
|
## Running SAST in SELinux
|
||||||
|
|
||||||
By default SAST analyzers are supported in GitLab instances hosted on SELinux. Adding a `before_script` in an [overriden SAST job](#overriding-sast-jobs) may not work as runners hosted on SELinux have restricted permissions.
|
By default SAST analyzers are supported in GitLab instances hosted on SELinux. Adding a `before_script` in an [overridden SAST job](#overriding-sast-jobs) may not work as runners hosted on SELinux have restricted permissions.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
|
@ -562,7 +562,7 @@ is unknown to the agent. One approach to fixing it is to present the CA certific
|
||||||
via a Kubernetes `configmap` and mount the file in the agent `/etc/ssl/certs` directory from where it
|
via a Kubernetes `configmap` and mount the file in the agent `/etc/ssl/certs` directory from where it
|
||||||
will be picked up automatically.
|
will be picked up automatically.
|
||||||
|
|
||||||
For example, if your internal CA certifciate is `myCA.pem`:
|
For example, if your internal CA certificate is `myCA.pem`:
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
kubectl -n gitlab-kubernetes-agent create configmap ca-pemstore --from-file=myCA.pem
|
kubectl -n gitlab-kubernetes-agent create configmap ca-pemstore --from-file=myCA.pem
|
||||||
|
|
|
@ -114,7 +114,7 @@ To disable it:
|
||||||
Feature.disable(:group_devops_adoption)
|
Feature.disable(:group_devops_adoption)
|
||||||
```
|
```
|
||||||
|
|
||||||
To reenable it:
|
To re-enable it:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
Feature.enable(:group_devops_adoption)
|
Feature.enable(:group_devops_adoption)
|
||||||
|
|
|
@ -69,22 +69,6 @@ Example response:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example request using a deploy token:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
curl --header "DEPLOY-TOKEN: <deploy_token>" \
|
|
||||||
--upload-file path/to/file.txt \
|
|
||||||
"https://gitlab.example.com/api/v4/projects/24/packages/generic/my_package/0.0.1/file.txt?status=hidden"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message":"201 Created"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download package file
|
## Download package file
|
||||||
|
|
||||||
Download a package file.
|
Download a package file.
|
||||||
|
|
|
@ -86,7 +86,7 @@ Put the following code in the file:
|
||||||
service: gitlab-example
|
service: gitlab-example
|
||||||
provider:
|
provider:
|
||||||
name: aws
|
name: aws
|
||||||
runtime: nodejs10.x
|
runtime: nodejs14.x
|
||||||
|
|
||||||
functions:
|
functions:
|
||||||
hello:
|
hello:
|
||||||
|
|
|
@ -167,7 +167,7 @@ If the key is **publicly accessible**, it will be removed from the project, but
|
||||||
|
|
||||||
If the key is **privately accessible** and only in use by this project, it will deleted.
|
If the key is **privately accessible** and only in use by this project, it will deleted.
|
||||||
|
|
||||||
If the key is **privately accessible** and in use by other projects, it will be removed from the project, but still available under **Privately accesible deploy keys**.
|
If the key is **privately accessible** and in use by other projects, it will be removed from the project, but still available under **Privately accessible deploy keys**.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
|
@ -130,20 +130,12 @@ To pull packages in the GitLab package registry, you must:
|
||||||
1. For the [package type of your choice](../../packages/index.md), follow the
|
1. For the [package type of your choice](../../packages/index.md), follow the
|
||||||
authentication instructions for deploy tokens.
|
authentication instructions for deploy tokens.
|
||||||
|
|
||||||
Example request publishing a generic package using a deploy token:
|
Example request publishing a NuGet package using a deploy token:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
curl --header "DEPLOY-TOKEN: <deploy_token>" \
|
nuget source Add -Name GitLab -Source "https://gitlab.example.com/api/v4/projects/10/packages/nuget/index.json" -UserName deploy-token-username -Password 12345678asdf
|
||||||
--upload-file path/to/file.txt \
|
|
||||||
"https://gitlab.example.com/api/v4/projects/24/packages/generic/my_package/0.0.1/file.txt?status=hidden"
|
|
||||||
```
|
|
||||||
|
|
||||||
Example response:
|
nuget push mypkg.nupkg -Source GitLab
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message":"201 Created"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Push or upload packages
|
### Push or upload packages
|
||||||
|
|
|
@ -15,7 +15,7 @@ Hangouts).
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
To enable this integration, first you need to create a webhook for the room in
|
To enable this integration, first you need to create a webhook for the room in
|
||||||
Google Chat where you want to receive the nofications from your project.
|
Google Chat where you want to receive the notifications from your project.
|
||||||
|
|
||||||
After that, enable the integration in GitLab and choose the events you want to
|
After that, enable the integration in GitLab and choose the events you want to
|
||||||
be notified about in your Google Chat room.
|
be notified about in your Google Chat room.
|
||||||
|
|
|
@ -103,7 +103,7 @@ Sometimes when you have hundreds of branches you may want a more flexible matchi
|
||||||
|
|
||||||
![Before swap revisions](img/swap_revisions_before_v13_12.png)
|
![Before swap revisions](img/swap_revisions_before_v13_12.png)
|
||||||
|
|
||||||
The Swap revisions feature allows you to swap the Source and Target revisions. When the Swap revisions button is clicked, the selected revisions for Source and Targed will be swapped.
|
The Swap revisions feature allows you to swap the Source and Target revisions. When the Swap revisions button is clicked, the selected revisions for Source and Target will be swapped.
|
||||||
|
|
||||||
![After swap revisions](img/swap_revisions_after_v13_12.png)
|
![After swap revisions](img/swap_revisions_after_v13_12.png)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Gitlab
|
||||||
|
module BackgroundMigration
|
||||||
|
# The class to migrate the context of project taggings from `tags` to `topics`
|
||||||
|
class MigrateProjectTaggingsContextFromTagsToTopics
|
||||||
|
# Temporary AR table for taggings
|
||||||
|
class Tagging < ActiveRecord::Base
|
||||||
|
include EachBatch
|
||||||
|
|
||||||
|
self.table_name = 'taggings'
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(start_id, stop_id)
|
||||||
|
Tagging.where(taggable_type: 'Project', context: 'tags', id: start_id..stop_id).each_batch(of: 500) do |relation|
|
||||||
|
relation.update_all(context: 'topics')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -153,6 +153,7 @@ excluded_attributes:
|
||||||
- :bfg_object_map
|
- :bfg_object_map
|
||||||
- :detected_repository_languages
|
- :detected_repository_languages
|
||||||
- :tag_list
|
- :tag_list
|
||||||
|
- :topic_list
|
||||||
- :mirror_user_id
|
- :mirror_user_id
|
||||||
- :mirror_trigger_builds
|
- :mirror_trigger_builds
|
||||||
- :only_mirror_protected_branches
|
- :only_mirror_protected_branches
|
||||||
|
|
|
@ -152,6 +152,7 @@
|
||||||
"prosemirror-model": "^1.13.3",
|
"prosemirror-model": "^1.13.3",
|
||||||
"raphael": "^2.2.7",
|
"raphael": "^2.2.7",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
|
"scrollparent": "^2.0.1",
|
||||||
"select2": "3.5.2-browserify",
|
"select2": "3.5.2-browserify",
|
||||||
"smooshpack": "^0.0.62",
|
"smooshpack": "^0.0.62",
|
||||||
"sortablejs": "^1.10.2",
|
"sortablejs": "^1.10.2",
|
||||||
|
@ -171,6 +172,8 @@
|
||||||
"vue": "^2.6.12",
|
"vue": "^2.6.12",
|
||||||
"vue-apollo": "^3.0.3",
|
"vue-apollo": "^3.0.3",
|
||||||
"vue-loader": "^15.9.6",
|
"vue-loader": "^15.9.6",
|
||||||
|
"vue-observe-visibility": "^1.0.0",
|
||||||
|
"vue-resize": "^1.0.1",
|
||||||
"vue-router": "3.4.9",
|
"vue-router": "3.4.9",
|
||||||
"vue-template-compiler": "^2.6.12",
|
"vue-template-compiler": "^2.6.12",
|
||||||
"vue-virtual-scroll-list": "^1.4.7",
|
"vue-virtual-scroll-list": "^1.4.7",
|
||||||
|
|
|
@ -7,15 +7,12 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
|
||||||
let_it_be(:group) { create(:group, name: 'Owned') }
|
let_it_be(:group) { create(:group, name: 'Owned') }
|
||||||
let_it_be(:project) { create(:project, :repository, namespace: group) }
|
let_it_be(:project) { create(:project, :repository, namespace: group) }
|
||||||
|
|
||||||
let(:user) { create(:user, email: 'user@example.com') }
|
|
||||||
let(:group_invite) { group.group_members.invite.last }
|
let(:group_invite) { group.group_members.invite.last }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_application_setting(require_admin_approval_after_user_signup: false)
|
stub_application_setting(require_admin_approval_after_user_signup: false)
|
||||||
project.add_maintainer(owner)
|
project.add_maintainer(owner)
|
||||||
group.add_owner(owner)
|
group.add_owner(owner)
|
||||||
group.add_developer('user@example.com', owner)
|
|
||||||
group_invite.generate_invite_token!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def confirm_email(new_user)
|
def confirm_email(new_user)
|
||||||
|
@ -45,45 +42,128 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
|
||||||
click_button 'Get started!'
|
click_button 'Get started!'
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when signed out' do
|
context 'when inviting a registered user' do
|
||||||
|
let(:invite_email) { 'user@example.com' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
visit invite_path(group_invite.raw_invite_token)
|
group.add_developer(invite_email, owner)
|
||||||
|
group_invite.generate_invite_token!
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders sign up page with sign up notice' do
|
context 'when signed out' do
|
||||||
expect(current_path).to eq(new_user_registration_path)
|
context 'when analyzing the redirects and forms from invite link click' do
|
||||||
expect(page).to have_content('To accept this invitation, create an account or sign in')
|
before do
|
||||||
end
|
visit invite_path(group_invite.raw_invite_token)
|
||||||
|
end
|
||||||
|
|
||||||
it 'pre-fills the "Username or email" field on the sign in box with the invite_email from the invite' do
|
it 'renders sign up page with sign up notice' do
|
||||||
click_link 'Sign in'
|
expect(current_path).to eq(new_user_registration_path)
|
||||||
|
expect(page).to have_content('To accept this invitation, create an account or sign in')
|
||||||
|
end
|
||||||
|
|
||||||
expect(find_field('Username or email').value).to eq(group_invite.invite_email)
|
it 'pre-fills the "Username or email" field on the sign in box with the invite_email from the invite' do
|
||||||
end
|
click_link 'Sign in'
|
||||||
|
|
||||||
it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do
|
expect(find_field('Username or email').value).to eq(group_invite.invite_email)
|
||||||
expect(find_field('Email').value).to eq(group_invite.invite_email)
|
end
|
||||||
end
|
|
||||||
|
|
||||||
it 'sign in, grants access and redirects to group activity page' do
|
it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do
|
||||||
click_link 'Sign in'
|
expect(find_field('Email').value).to eq(group_invite.invite_email)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
fill_in_sign_in_form(user)
|
context 'when invite is sent before account is created - ldap or social sign in for manual acceptance edge case' do
|
||||||
|
let(:user) { create(:user, email: 'user@example.com') }
|
||||||
|
|
||||||
expect(current_path).to eq(activity_group_path(group))
|
context 'when invite clicked and not signed in' do
|
||||||
end
|
before do
|
||||||
end
|
visit invite_path(group_invite.raw_invite_token)
|
||||||
|
end
|
||||||
|
|
||||||
context 'when signed in as an existing member' do
|
it 'sign in, grants access and redirects to group activity page' do
|
||||||
before do
|
click_link 'Sign in'
|
||||||
sign_in(owner)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'shows message user already a member' do
|
fill_in_sign_in_form(user)
|
||||||
visit invite_path(group_invite.raw_invite_token)
|
|
||||||
|
|
||||||
expect(page).to have_link(owner.name, href: user_url(owner))
|
expect(current_path).to eq(activity_group_path(group))
|
||||||
expect(page).to have_content('However, you are already a member of this group.')
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when signed in and an invite link is clicked' do
|
||||||
|
context 'when an invite email is a secondary email for the user' do
|
||||||
|
let(:invite_email) { 'user_secondary@example.com' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(user)
|
||||||
|
visit invite_path(group_invite.raw_invite_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sends user to the invite url and allows them to decline' do
|
||||||
|
expect(current_path).to eq(invite_path(group_invite.raw_invite_token))
|
||||||
|
expect(page).to have_content("Note that this invitation was sent to #{invite_email}")
|
||||||
|
expect(page).to have_content("but you are signed in as #{user.to_reference} with email #{user.email}")
|
||||||
|
|
||||||
|
click_link('Decline')
|
||||||
|
|
||||||
|
expect(page).to have_content('You have declined the invitation')
|
||||||
|
expect(current_path).to eq(dashboard_projects_path)
|
||||||
|
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sends uer to the invite url and allows them to accept' do
|
||||||
|
expect(current_path).to eq(invite_path(group_invite.raw_invite_token))
|
||||||
|
expect(page).to have_content("Note that this invitation was sent to #{invite_email}")
|
||||||
|
expect(page).to have_content("but you are signed in as #{user.to_reference} with email #{user.email}")
|
||||||
|
|
||||||
|
click_link('Accept invitation')
|
||||||
|
|
||||||
|
expect(page).to have_content('You have been granted')
|
||||||
|
expect(current_path).to eq(activity_group_path(group))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is an existing member' do
|
||||||
|
before do
|
||||||
|
sign_in(owner)
|
||||||
|
visit invite_path(group_invite.raw_invite_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows message user already a member' do
|
||||||
|
expect(current_path).to eq(invite_path(group_invite.raw_invite_token))
|
||||||
|
expect(page).to have_link(owner.name, href: user_url(owner))
|
||||||
|
expect(page).to have_content('However, you are already a member of this group.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when declining the invitation from invitation reminder email' do
|
||||||
|
context 'when signed in' do
|
||||||
|
before do
|
||||||
|
sign_in(user)
|
||||||
|
visit decline_invite_path(group_invite.raw_invite_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'declines application and redirects to dashboard' do
|
||||||
|
expect(current_path).to eq(dashboard_projects_path)
|
||||||
|
expect(page).to have_content('You have declined the invitation to join group Owned.')
|
||||||
|
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when signed out with signup onboarding' do
|
||||||
|
before do
|
||||||
|
visit decline_invite_path(group_invite.raw_invite_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'declines application and redirects to sign in page' do
|
||||||
|
expect(current_path).to eq(decline_invite_path(group_invite.raw_invite_token))
|
||||||
|
expect(page).not_to have_content('You have declined the invitation to join')
|
||||||
|
expect(page).to have_content('You successfully declined the invitation')
|
||||||
|
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -243,63 +323,13 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when declining the invitation' do
|
context 'when declining the invitation from invitation reminder email' do
|
||||||
context 'as an existing user' do
|
it 'declines application and shows a decline page' do
|
||||||
let(:group_invite) { create(:group_member, user: user, group: group, created_by: owner) }
|
visit decline_invite_path(group_invite.raw_invite_token)
|
||||||
|
|
||||||
context 'when signed in' do
|
expect(current_path).to eq(decline_invite_path(group_invite.raw_invite_token))
|
||||||
before do
|
expect(page).to have_content('You successfully declined the invitation')
|
||||||
sign_in(user)
|
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
||||||
visit decline_invite_path(group_invite.raw_invite_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'declines application and redirects to dashboard' do
|
|
||||||
expect(current_path).to eq(dashboard_projects_path)
|
|
||||||
expect(page).to have_content('You have declined the invitation to join group Owned.')
|
|
||||||
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when signed out' do
|
|
||||||
before do
|
|
||||||
visit decline_invite_path(group_invite.raw_invite_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'declines application and redirects to sign in page' do
|
|
||||||
expect(current_path).to eq(new_user_session_path)
|
|
||||||
expect(page).to have_content('You have declined the invitation to join group Owned.')
|
|
||||||
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'as a non-existing user' do
|
|
||||||
before do
|
|
||||||
visit decline_invite_path(group_invite.raw_invite_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'declines application and shows a decline page' do
|
|
||||||
expect(current_path).to eq(decline_invite_path(group_invite.raw_invite_token))
|
|
||||||
expect(page).to have_content('You successfully declined the invitation')
|
|
||||||
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when accepting the invitation as an existing user' do
|
|
||||||
before do
|
|
||||||
sign_in(user)
|
|
||||||
visit invite_path(group_invite.raw_invite_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'grants access and redirects to the group activity page' do
|
|
||||||
expect(group.users.include?(user)).to be false
|
|
||||||
|
|
||||||
page.click_link 'Accept invitation'
|
|
||||||
|
|
||||||
expect(current_path).to eq(activity_group_path(group))
|
|
||||||
expect(page).to have_content('You have been granted Owner access to group Owned.')
|
|
||||||
expect(group.users.include?(user)).to be true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -139,7 +139,7 @@ RSpec.describe ProjectsFinder do
|
||||||
|
|
||||||
describe 'filter by tags' do
|
describe 'filter by tags' do
|
||||||
before do
|
before do
|
||||||
public_project.tag_list.add('foo')
|
public_project.tag_list = 'foo'
|
||||||
public_project.save!
|
public_project.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
RSpec.describe Gitlab::BackgroundMigration::MigrateProjectTaggingsContextFromTagsToTopics, schema: 20210511095658 do
|
||||||
|
it 'correctly migrates project taggings context from tags to topics' do
|
||||||
|
taggings = table(:taggings)
|
||||||
|
|
||||||
|
project_old_tagging_1 = taggings.create!(taggable_type: 'Project', context: 'tags')
|
||||||
|
project_new_tagging_1 = taggings.create!(taggable_type: 'Project', context: 'topics')
|
||||||
|
project_other_context_tagging_1 = taggings.create!(taggable_type: 'Project', context: 'other')
|
||||||
|
project_old_tagging_2 = taggings.create!(taggable_type: 'Project', context: 'tags')
|
||||||
|
project_old_tagging_3 = taggings.create!(taggable_type: 'Project', context: 'tags')
|
||||||
|
|
||||||
|
subject.perform(project_old_tagging_1.id, project_old_tagging_2.id)
|
||||||
|
|
||||||
|
project_old_tagging_1.reload
|
||||||
|
project_new_tagging_1.reload
|
||||||
|
project_other_context_tagging_1.reload
|
||||||
|
project_old_tagging_2.reload
|
||||||
|
project_old_tagging_3.reload
|
||||||
|
|
||||||
|
expect(project_old_tagging_1.context).to eq('topics')
|
||||||
|
expect(project_new_tagging_1.context).to eq('topics')
|
||||||
|
expect(project_other_context_tagging_1.context).to eq('other')
|
||||||
|
expect(project_old_tagging_2.context).to eq('topics')
|
||||||
|
expect(project_old_tagging_3.context).to eq('tags')
|
||||||
|
end
|
||||||
|
end
|
|
@ -343,8 +343,9 @@ project:
|
||||||
- external_approval_rules
|
- external_approval_rules
|
||||||
- taggings
|
- taggings
|
||||||
- base_tags
|
- base_tags
|
||||||
- tag_taggings
|
|
||||||
- tags
|
- tags
|
||||||
|
- topic_taggings
|
||||||
|
- topics
|
||||||
- chat_services
|
- chat_services
|
||||||
- cluster
|
- cluster
|
||||||
- clusters
|
- clusters
|
||||||
|
|
|
@ -6964,6 +6964,55 @@ RSpec.describe Project, factory_default: :keep do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'topics' do
|
||||||
|
let_it_be(:project) { create(:project, tag_list: 'topic1, topic2, topic3') }
|
||||||
|
|
||||||
|
it 'topic_list returns correct string array' do
|
||||||
|
expect(project.topic_list).to match_array(%w[topic1 topic2 topic3])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'topics returns correct tag records' do
|
||||||
|
expect(project.topics.first.class.name).to eq('ActsAsTaggableOn::Tag')
|
||||||
|
expect(project.topics.map(&:name)).to match_array(%w[topic1 topic2 topic3])
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'aliases' do
|
||||||
|
it 'tag_list returns correct string array' do
|
||||||
|
expect(project.tag_list).to match_array(%w[topic1 topic2 topic3])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'tags returns correct tag records' do
|
||||||
|
expect(project.tags.first.class.name).to eq('ActsAsTaggableOn::Tag')
|
||||||
|
expect(project.tags.map(&:name)).to match_array(%w[topic1 topic2 topic3])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'intermediate state during background migration' do
|
||||||
|
before do
|
||||||
|
project.taggings.first.update!(context: 'tags')
|
||||||
|
project.instance_variable_set("@tag_list", nil)
|
||||||
|
project.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'tag_list returns string array including old and new topics' do
|
||||||
|
expect(project.tag_list).to match_array(%w[topic1 topic2 topic3])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'tags returns old and new tag records' do
|
||||||
|
expect(project.tags.first.class.name).to eq('ActsAsTaggableOn::Tag')
|
||||||
|
expect(project.tags.map(&:name)).to match_array(%w[topic1 topic2 topic3])
|
||||||
|
expect(project.taggings.map(&:context)).to match_array(%w[tags topics topics])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'update tag_list adds new topics and removes old topics' do
|
||||||
|
project.update!(tag_list: 'topic1, topic2, topic3, topic4')
|
||||||
|
|
||||||
|
expect(project.tags.map(&:name)).to match_array(%w[topic1 topic2 topic3 topic4])
|
||||||
|
expect(project.taggings.map(&:context)).to match_array(%w[topics topics topics topics])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def finish_job(export_job)
|
def finish_job(export_job)
|
||||||
export_job.start
|
export_job.start
|
||||||
export_job.finish
|
export_job.finish
|
||||||
|
|
|
@ -41,6 +41,7 @@ itself: # project
|
||||||
- reset_approvals_on_push
|
- reset_approvals_on_push
|
||||||
- runners_token_encrypted
|
- runners_token_encrypted
|
||||||
- storage_version
|
- storage_version
|
||||||
|
- topic_list
|
||||||
- updated_at
|
- updated_at
|
||||||
remapped_attributes:
|
remapped_attributes:
|
||||||
avatar: avatar_url
|
avatar: avatar_url
|
||||||
|
@ -67,6 +68,7 @@ itself: # project
|
||||||
- readme_url
|
- readme_url
|
||||||
- shared_with_groups
|
- shared_with_groups
|
||||||
- ssh_url_to_repo
|
- ssh_url_to_repo
|
||||||
|
- tag_list
|
||||||
- web_url
|
- web_url
|
||||||
|
|
||||||
build_auto_devops: # auto_devops
|
build_auto_devops: # auto_devops
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
|
||||||
RSpec.describe Groups::AutocompleteSourcesController do
|
RSpec.describe 'groups autocomplete' do
|
||||||
let_it_be(:user) { create(:user) }
|
let_it_be(:user) { create(:user) }
|
||||||
let_it_be_with_reload(:group) { create(:group, :private) }
|
let_it_be_with_reload(:group) { create(:group, :private) }
|
||||||
|
|
||||||
|
@ -35,9 +35,8 @@ RSpec.describe Groups::AutocompleteSourcesController do
|
||||||
with_them do
|
with_them do
|
||||||
it 'returns the correct response', :aggregate_failures do
|
it 'returns the correct response', :aggregate_failures do
|
||||||
issues = Array(expected).flat_map { |sym| public_send(sym) }
|
issues = Array(expected).flat_map { |sym| public_send(sym) }
|
||||||
params = { group_id: group, issue_types: issue_types }.compact
|
|
||||||
|
|
||||||
get :issues, params: params
|
get issues_group_autocomplete_sources_path(group, issue_types: issue_types)
|
||||||
|
|
||||||
expect(response).to have_gitlab_http_status(:ok)
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
expect(json_response).to be_an(Array)
|
expect(json_response).to be_an(Array)
|
||||||
|
@ -57,7 +56,7 @@ RSpec.describe Groups::AutocompleteSourcesController do
|
||||||
create(:milestone, group: sub_group)
|
create(:milestone, group: sub_group)
|
||||||
group_milestone = create(:milestone, group: group)
|
group_milestone = create(:milestone, group: group)
|
||||||
|
|
||||||
get :milestones, params: { group_id: group }
|
get milestones_group_autocomplete_sources_path(group)
|
||||||
|
|
||||||
expect(response).to have_gitlab_http_status(:ok)
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
expect(json_response.count).to eq(1)
|
expect(json_response.count).to eq(1)
|
|
@ -268,6 +268,7 @@ RSpec.configure do |config|
|
||||||
stub_feature_flags(file_identifier_hash: false)
|
stub_feature_flags(file_identifier_hash: false)
|
||||||
|
|
||||||
stub_feature_flags(unified_diff_components: false)
|
stub_feature_flags(unified_diff_components: false)
|
||||||
|
stub_feature_flags(diffs_virtual_scrolling: false)
|
||||||
|
|
||||||
# The following `vue_issues_list`/`vue_issuables_list` stubs can be removed
|
# The following `vue_issues_list`/`vue_issuables_list` stubs can be removed
|
||||||
# once the Vue issues page has feature parity with the current Haml page
|
# once the Vue issues page has feature parity with the current Haml page
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "vue-virtual-scroller",
|
||||||
|
"description": "Smooth scrolling for any amount of data",
|
||||||
|
"version": "1.0.10",
|
||||||
|
"author": {
|
||||||
|
"name": "Guillaume Chau",
|
||||||
|
"email": "guillaume.b.chau@gmail.com"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"vue",
|
||||||
|
"vuejs",
|
||||||
|
"plugin"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/Akryum/vue-virtual-scroller.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/Akryum/vue-virtual-scroller/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/Akryum/vue-virtual-scroller#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"scrollparent": "^2.0.1",
|
||||||
|
"vue-observe-visibility": "^0.4.4",
|
||||||
|
"vue-resize": "^0.4.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^2.6.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"> 1%",
|
||||||
|
"last 2 versions",
|
||||||
|
"not ie <= 8"
|
||||||
|
]
|
||||||
|
}
|
212
vendor/assets/javascripts/vue-virtual-scroller/src/components/DynamicScroller.vue
vendored
Normal file
212
vendor/assets/javascripts/vue-virtual-scroller/src/components/DynamicScroller.vue
vendored
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
<template>
|
||||||
|
<RecycleScroller
|
||||||
|
ref="scroller"
|
||||||
|
:items="itemsWithSize"
|
||||||
|
:min-item-size="minItemSize"
|
||||||
|
:direction="direction"
|
||||||
|
key-field="id"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@resize="onScrollerResize"
|
||||||
|
@visible="onScrollerVisible"
|
||||||
|
v-on="listeners"
|
||||||
|
>
|
||||||
|
<template slot-scope="{ item: itemWithSize, index, active }">
|
||||||
|
<slot
|
||||||
|
v-bind="{
|
||||||
|
item: itemWithSize.item,
|
||||||
|
index,
|
||||||
|
active,
|
||||||
|
itemWithSize
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template slot="before">
|
||||||
|
<slot name="before" />
|
||||||
|
</template>
|
||||||
|
<template slot="after">
|
||||||
|
<slot name="after" />
|
||||||
|
</template>
|
||||||
|
</RecycleScroller>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import RecycleScroller from './RecycleScroller.vue'
|
||||||
|
import { props, simpleArray } from './common'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DynamicScroller',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecycleScroller,
|
||||||
|
},
|
||||||
|
|
||||||
|
inheritAttrs: false,
|
||||||
|
|
||||||
|
provide () {
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
this.$_resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target) {
|
||||||
|
const event = new CustomEvent(
|
||||||
|
'resize',
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
contentRect: entry.contentRect,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.target.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vscrollData: this.vscrollData,
|
||||||
|
vscrollParent: this,
|
||||||
|
vscrollResizeObserver: this.$_resizeObserver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
|
||||||
|
minItemSize: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
vscrollData: {
|
||||||
|
active: true,
|
||||||
|
sizes: {},
|
||||||
|
validSizes: {},
|
||||||
|
keyField: this.keyField,
|
||||||
|
simpleArray: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
simpleArray,
|
||||||
|
|
||||||
|
itemsWithSize () {
|
||||||
|
const result = []
|
||||||
|
const { items, keyField, simpleArray } = this
|
||||||
|
const sizes = this.vscrollData.sizes
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i]
|
||||||
|
const id = simpleArray ? i : item[keyField]
|
||||||
|
let size = sizes[id]
|
||||||
|
if (typeof size === 'undefined' && !this.$_undefinedMap[id]) {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
item,
|
||||||
|
id,
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
listeners () {
|
||||||
|
const listeners = {}
|
||||||
|
for (const key in this.$listeners) {
|
||||||
|
if (key !== 'resize' && key !== 'visible') {
|
||||||
|
listeners[key] = this.$listeners[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listeners
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
items () {
|
||||||
|
this.forceUpdate(false)
|
||||||
|
},
|
||||||
|
|
||||||
|
simpleArray: {
|
||||||
|
handler (value) {
|
||||||
|
this.vscrollData.simpleArray = value
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
direction (value) {
|
||||||
|
this.forceUpdate(true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created () {
|
||||||
|
this.$_updates = []
|
||||||
|
this.$_undefinedSizes = 0
|
||||||
|
this.$_undefinedMap = {}
|
||||||
|
},
|
||||||
|
|
||||||
|
activated () {
|
||||||
|
this.vscrollData.active = true
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivated () {
|
||||||
|
this.vscrollData.active = false
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onScrollerResize () {
|
||||||
|
const scroller = this.$refs.scroller
|
||||||
|
if (scroller) {
|
||||||
|
this.forceUpdate()
|
||||||
|
}
|
||||||
|
this.$emit('resize')
|
||||||
|
},
|
||||||
|
|
||||||
|
onScrollerVisible () {
|
||||||
|
this.$emit('vscroll:update', { force: false })
|
||||||
|
this.$emit('visible')
|
||||||
|
},
|
||||||
|
|
||||||
|
forceUpdate (clear = true) {
|
||||||
|
if (clear || this.simpleArray) {
|
||||||
|
this.vscrollData.validSizes = {}
|
||||||
|
}
|
||||||
|
this.$emit('vscroll:update', { force: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToItem (index) {
|
||||||
|
const scroller = this.$refs.scroller
|
||||||
|
if (scroller) scroller.scrollToItem(index)
|
||||||
|
},
|
||||||
|
|
||||||
|
getItemSize (item, index = undefined) {
|
||||||
|
const id = this.simpleArray ? (index != null ? index : this.items.indexOf(item)) : item[this.keyField]
|
||||||
|
return this.vscrollData.sizes[id] || 0
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom () {
|
||||||
|
if (this.$_scrollingToBottom) return
|
||||||
|
this.$_scrollingToBottom = true
|
||||||
|
const el = this.$el
|
||||||
|
// Item is inserted to the DOM
|
||||||
|
this.$nextTick(() => {
|
||||||
|
el.scrollTop = el.scrollHeight + 5000
|
||||||
|
// Item sizes are computed
|
||||||
|
const cb = () => {
|
||||||
|
el.scrollTop = el.scrollHeight + 5000
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = el.scrollHeight + 5000
|
||||||
|
if (this.$_undefinedSizes === 0) {
|
||||||
|
this.$_scrollingToBottom = false
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(cb)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
requestAnimationFrame(cb)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
218
vendor/assets/javascripts/vue-virtual-scroller/src/components/DynamicScrollerItem.vue
vendored
Normal file
218
vendor/assets/javascripts/vue-virtual-scroller/src/components/DynamicScrollerItem.vue
vendored
Normal file
|
@ -0,0 +1,218 @@
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'DynamicScrollerItem',
|
||||||
|
|
||||||
|
inject: [
|
||||||
|
'vscrollData',
|
||||||
|
'vscrollParent',
|
||||||
|
'vscrollResizeObserver',
|
||||||
|
],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
// eslint-disable-next-line vue/require-prop-types
|
||||||
|
item: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
watchData: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the view is actively used to display an item.
|
||||||
|
*/
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
|
||||||
|
sizeDependencies: {
|
||||||
|
type: [Array, Object],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
emitResize: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
tag: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
id () {
|
||||||
|
return this.vscrollData.simpleArray ? this.index : this.item[this.vscrollData.keyField]
|
||||||
|
},
|
||||||
|
|
||||||
|
size () {
|
||||||
|
return (this.vscrollData.validSizes[this.id] && this.vscrollData.sizes[this.id]) || 0
|
||||||
|
},
|
||||||
|
|
||||||
|
finalActive () {
|
||||||
|
return this.active && this.vscrollData.active
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
watchData: 'updateWatchData',
|
||||||
|
|
||||||
|
id () {
|
||||||
|
if (!this.size) {
|
||||||
|
this.onDataUpdate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
finalActive (value) {
|
||||||
|
if (!this.size) {
|
||||||
|
if (value) {
|
||||||
|
if (!this.vscrollParent.$_undefinedMap[this.id]) {
|
||||||
|
this.vscrollParent.$_undefinedSizes++
|
||||||
|
this.vscrollParent.$_undefinedMap[this.id] = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.vscrollParent.$_undefinedMap[this.id]) {
|
||||||
|
this.vscrollParent.$_undefinedSizes--
|
||||||
|
this.vscrollParent.$_undefinedMap[this.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.vscrollResizeObserver) {
|
||||||
|
if (value) {
|
||||||
|
this.observeSize()
|
||||||
|
} else {
|
||||||
|
this.unobserveSize()
|
||||||
|
}
|
||||||
|
} else if (value && this.$_pendingVScrollUpdate === this.id) {
|
||||||
|
this.updateSize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created () {
|
||||||
|
if (this.$isServer) return
|
||||||
|
|
||||||
|
this.$_forceNextVScrollUpdate = null
|
||||||
|
this.updateWatchData()
|
||||||
|
|
||||||
|
if (!this.vscrollResizeObserver) {
|
||||||
|
for (const k in this.sizeDependencies) {
|
||||||
|
this.$watch(() => this.sizeDependencies[k], this.onDataUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.vscrollParent.$on('vscroll:update', this.onVscrollUpdate)
|
||||||
|
this.vscrollParent.$on('vscroll:update-size', this.onVscrollUpdateSize)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted () {
|
||||||
|
if (this.vscrollData.active) {
|
||||||
|
this.updateSize()
|
||||||
|
this.observeSize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy () {
|
||||||
|
this.vscrollParent.$off('vscroll:update', this.onVscrollUpdate)
|
||||||
|
this.vscrollParent.$off('vscroll:update-size', this.onVscrollUpdateSize)
|
||||||
|
this.unobserveSize()
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
updateSize () {
|
||||||
|
if (this.finalActive) {
|
||||||
|
if (this.$_pendingSizeUpdate !== this.id) {
|
||||||
|
this.$_pendingSizeUpdate = this.id
|
||||||
|
this.$_forceNextVScrollUpdate = null
|
||||||
|
this.$_pendingVScrollUpdate = null
|
||||||
|
this.computeSize(this.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.$_forceNextVScrollUpdate = this.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWatchData () {
|
||||||
|
if (this.watchData) {
|
||||||
|
this.$_watchData = this.$watch('data', () => {
|
||||||
|
this.onDataUpdate()
|
||||||
|
}, {
|
||||||
|
deep: true,
|
||||||
|
})
|
||||||
|
} else if (this.$_watchData) {
|
||||||
|
this.$_watchData()
|
||||||
|
this.$_watchData = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onVscrollUpdate ({ force }) {
|
||||||
|
// If not active, sechedule a size update when it becomes active
|
||||||
|
if (!this.finalActive && force) {
|
||||||
|
this.$_pendingVScrollUpdate = this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.$_forceNextVScrollUpdate === this.id || force || !this.size) {
|
||||||
|
this.updateSize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDataUpdate () {
|
||||||
|
this.updateSize()
|
||||||
|
},
|
||||||
|
|
||||||
|
computeSize (id) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.id === id) {
|
||||||
|
const width = this.$el.offsetWidth
|
||||||
|
const height = this.$el.offsetHeight
|
||||||
|
this.applySize(width, height)
|
||||||
|
}
|
||||||
|
this.$_pendingSizeUpdate = null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
applySize (width, height) {
|
||||||
|
const size = Math.round(this.vscrollParent.direction === 'vertical' ? height : width)
|
||||||
|
if (size && this.size !== size) {
|
||||||
|
if (this.vscrollParent.$_undefinedMap[this.id]) {
|
||||||
|
this.vscrollParent.$_undefinedSizes--
|
||||||
|
this.vscrollParent.$_undefinedMap[this.id] = undefined
|
||||||
|
}
|
||||||
|
this.$set(this.vscrollData.sizes, this.id, size)
|
||||||
|
this.$set(this.vscrollData.validSizes, this.id, true)
|
||||||
|
if (this.emitResize) this.$emit('resize', this.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
observeSize () {
|
||||||
|
if (!this.vscrollResizeObserver) return
|
||||||
|
this.vscrollResizeObserver.observe(this.$el.parentNode)
|
||||||
|
this.$el.parentNode.addEventListener('resize', this.onResize)
|
||||||
|
},
|
||||||
|
|
||||||
|
unobserveSize () {
|
||||||
|
if (!this.vscrollResizeObserver) return
|
||||||
|
this.vscrollResizeObserver.unobserve(this.$el.parentNode)
|
||||||
|
this.$el.parentNode.removeEventListener('resize', this.onResize)
|
||||||
|
},
|
||||||
|
|
||||||
|
onResize (event) {
|
||||||
|
const { width, height } = event.detail.contentRect
|
||||||
|
this.applySize(width, height)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
render (h) {
|
||||||
|
return h(this.tag, this.$slots.default)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
657
vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
vendored
Normal file
657
vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
vendored
Normal file
|
@ -0,0 +1,657 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-observe-visibility="handleVisibilityChange"
|
||||||
|
class="vue-recycle-scroller"
|
||||||
|
:class="{
|
||||||
|
ready,
|
||||||
|
'page-mode': pageMode,
|
||||||
|
[`direction-${direction}`]: true,
|
||||||
|
}"
|
||||||
|
@scroll.passive="handleScroll"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="$slots.before"
|
||||||
|
class="vue-recycle-scroller__slot"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="before"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="wrapper"
|
||||||
|
:style="{ [direction === 'vertical' ? 'minHeight' : 'minWidth']: totalSize + 'px' }"
|
||||||
|
class="vue-recycle-scroller__item-wrapper"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="view of pool"
|
||||||
|
:key="view.nr.id"
|
||||||
|
:style="ready ? {
|
||||||
|
transform: useTransform ? `translate${direction === 'vertical' ? 'Y' : 'X'}(${view.position}px)` : null,
|
||||||
|
top: !useTransform && direction === 'vertical' ? `${view.position}px` : null,
|
||||||
|
left: !useTransform && direction !== 'vertical' ? `${view.position}px` : null,
|
||||||
|
} : null"
|
||||||
|
class="vue-recycle-scroller__item-view"
|
||||||
|
:class="{ hover: hoverKey === view.nr.key }"
|
||||||
|
@mouseenter="hoverKey = view.nr.key"
|
||||||
|
@mouseleave="hoverKey = null"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:item="view.item"
|
||||||
|
:index="view.nr.index"
|
||||||
|
:active="view.nr.used"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="$slots.after"
|
||||||
|
class="vue-recycle-scroller__slot"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="after"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResizeObserver @notify="handleResize" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ResizeObserver } from 'vue-resize'
|
||||||
|
import { ObserveVisibility } from 'vue-observe-visibility'
|
||||||
|
import ScrollParent from 'scrollparent'
|
||||||
|
import config from '../config'
|
||||||
|
import { props, simpleArray } from './common'
|
||||||
|
import { supportsPassive } from '../utils'
|
||||||
|
|
||||||
|
let uid = 0
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'RecycleScroller',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
ResizeObserver,
|
||||||
|
},
|
||||||
|
|
||||||
|
directives: {
|
||||||
|
ObserveVisibility,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
|
||||||
|
itemSize: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
minItemSize: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
sizeField: {
|
||||||
|
type: String,
|
||||||
|
default: 'size',
|
||||||
|
},
|
||||||
|
|
||||||
|
typeField: {
|
||||||
|
type: String,
|
||||||
|
default: 'type',
|
||||||
|
},
|
||||||
|
|
||||||
|
buffer: {
|
||||||
|
type: Number,
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
|
||||||
|
pageMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
prerender: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
emitUpdate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
useTransform: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
pool: [],
|
||||||
|
totalSize: 0,
|
||||||
|
ready: false,
|
||||||
|
hoverKey: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
sizes () {
|
||||||
|
if (this.itemSize === null) {
|
||||||
|
const sizes = {
|
||||||
|
'-1': { accumulator: 0 },
|
||||||
|
}
|
||||||
|
const items = this.items
|
||||||
|
const field = this.sizeField
|
||||||
|
const minItemSize = this.minItemSize
|
||||||
|
let computedMinSize = 10000
|
||||||
|
let accumulator = 0
|
||||||
|
let current
|
||||||
|
for (let i = 0, l = items.length; i < l; i++) {
|
||||||
|
current = items[i][field] || minItemSize
|
||||||
|
if (current < computedMinSize) {
|
||||||
|
computedMinSize = current
|
||||||
|
}
|
||||||
|
accumulator += current
|
||||||
|
sizes[i] = { accumulator, size: current }
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line
|
||||||
|
this.$_computedMinItemSize = computedMinSize
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
|
||||||
|
simpleArray,
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
items () {
|
||||||
|
this.updateVisibleItems(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
pageMode () {
|
||||||
|
this.applyPageMode()
|
||||||
|
this.updateVisibleItems(false)
|
||||||
|
},
|
||||||
|
|
||||||
|
sizes: {
|
||||||
|
handler () {
|
||||||
|
this.updateVisibleItems(false)
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created () {
|
||||||
|
this.$_startIndex = 0
|
||||||
|
this.$_endIndex = 0
|
||||||
|
this.$_views = new Map()
|
||||||
|
this.$_unusedViews = new Map()
|
||||||
|
this.$_scrollDirty = false
|
||||||
|
this.$_lastUpdateScrollPosition = 0
|
||||||
|
|
||||||
|
// In SSR mode, we also prerender the same number of item for the first render
|
||||||
|
// to avoir mismatch between server and client templates
|
||||||
|
if (this.prerender) {
|
||||||
|
this.$_prerender = true
|
||||||
|
this.updateVisibleItems(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted () {
|
||||||
|
this.applyPageMode()
|
||||||
|
this.$nextTick(() => {
|
||||||
|
// In SSR mode, render the real number of visible items
|
||||||
|
this.$_prerender = false
|
||||||
|
this.updateVisibleItems(true)
|
||||||
|
this.ready = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy () {
|
||||||
|
this.removeListeners()
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
addView (pool, index, item, key, type) {
|
||||||
|
const view = {
|
||||||
|
item,
|
||||||
|
position: 0,
|
||||||
|
}
|
||||||
|
const nonReactive = {
|
||||||
|
id: uid++,
|
||||||
|
index,
|
||||||
|
used: true,
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
Object.defineProperty(view, 'nr', {
|
||||||
|
configurable: false,
|
||||||
|
value: nonReactive,
|
||||||
|
})
|
||||||
|
pool.push(view)
|
||||||
|
return view
|
||||||
|
},
|
||||||
|
|
||||||
|
unuseView (view, fake = false) {
|
||||||
|
const unusedViews = this.$_unusedViews
|
||||||
|
const type = view.nr.type
|
||||||
|
let unusedPool = unusedViews.get(type)
|
||||||
|
if (!unusedPool) {
|
||||||
|
unusedPool = []
|
||||||
|
unusedViews.set(type, unusedPool)
|
||||||
|
}
|
||||||
|
unusedPool.push(view)
|
||||||
|
if (!fake) {
|
||||||
|
view.nr.used = false
|
||||||
|
view.position = -9999
|
||||||
|
this.$_views.delete(view.nr.key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleResize () {
|
||||||
|
this.$emit('resize')
|
||||||
|
if (this.ready) this.updateVisibleItems(false)
|
||||||
|
},
|
||||||
|
|
||||||
|
handleScroll (event) {
|
||||||
|
if (!this.$_scrollDirty) {
|
||||||
|
this.$_scrollDirty = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.$_scrollDirty = false
|
||||||
|
const { continuous } = this.updateVisibleItems(false, true)
|
||||||
|
|
||||||
|
// It seems sometimes chrome doesn't fire scroll event :/
|
||||||
|
// When non continous scrolling is ending, we force a refresh
|
||||||
|
if (!continuous) {
|
||||||
|
clearTimeout(this.$_refreshTimout)
|
||||||
|
this.$_refreshTimout = setTimeout(this.handleScroll, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleVisibilityChange (isVisible, entry) {
|
||||||
|
if (this.ready) {
|
||||||
|
if (isVisible || entry.boundingClientRect.width !== 0 || entry.boundingClientRect.height !== 0) {
|
||||||
|
this.$emit('visible')
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.updateVisibleItems(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$emit('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateVisibleItems (checkItem, checkPositionDiff = false) {
|
||||||
|
const itemSize = this.itemSize
|
||||||
|
const minItemSize = this.$_computedMinItemSize
|
||||||
|
const typeField = this.typeField
|
||||||
|
const keyField = this.simpleArray ? null : this.keyField
|
||||||
|
const items = this.items
|
||||||
|
const count = items.length
|
||||||
|
const sizes = this.sizes
|
||||||
|
const views = this.$_views
|
||||||
|
const unusedViews = this.$_unusedViews
|
||||||
|
const pool = this.pool
|
||||||
|
let startIndex, endIndex
|
||||||
|
let totalSize
|
||||||
|
|
||||||
|
if (!count) {
|
||||||
|
startIndex = endIndex = totalSize = 0
|
||||||
|
} else if (this.$_prerender) {
|
||||||
|
startIndex = 0
|
||||||
|
endIndex = this.prerender
|
||||||
|
totalSize = null
|
||||||
|
} else {
|
||||||
|
const scroll = this.getScroll()
|
||||||
|
|
||||||
|
// Skip update if use hasn't scrolled enough
|
||||||
|
if (checkPositionDiff) {
|
||||||
|
let positionDiff = scroll.start - this.$_lastUpdateScrollPosition
|
||||||
|
if (positionDiff < 0) positionDiff = -positionDiff
|
||||||
|
if ((itemSize === null && positionDiff < minItemSize) || positionDiff < itemSize) {
|
||||||
|
return {
|
||||||
|
continuous: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$_lastUpdateScrollPosition = scroll.start
|
||||||
|
|
||||||
|
const buffer = this.buffer
|
||||||
|
scroll.start -= buffer
|
||||||
|
scroll.end += buffer
|
||||||
|
|
||||||
|
// Variable size mode
|
||||||
|
if (itemSize === null) {
|
||||||
|
let h
|
||||||
|
let a = 0
|
||||||
|
let b = count - 1
|
||||||
|
let i = ~~(count / 2)
|
||||||
|
let oldI
|
||||||
|
|
||||||
|
// Searching for startIndex
|
||||||
|
do {
|
||||||
|
oldI = i
|
||||||
|
h = sizes[i].accumulator
|
||||||
|
if (h < scroll.start) {
|
||||||
|
a = i
|
||||||
|
} else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
|
||||||
|
b = i
|
||||||
|
}
|
||||||
|
i = ~~((a + b) / 2)
|
||||||
|
} while (i !== oldI)
|
||||||
|
i < 0 && (i = 0)
|
||||||
|
startIndex = i
|
||||||
|
|
||||||
|
// For container style
|
||||||
|
totalSize = sizes[count - 1].accumulator
|
||||||
|
|
||||||
|
// Searching for endIndex
|
||||||
|
for (endIndex = i; endIndex < count && sizes[endIndex].accumulator < scroll.end; endIndex++);
|
||||||
|
if (endIndex === -1) {
|
||||||
|
endIndex = items.length - 1
|
||||||
|
} else {
|
||||||
|
endIndex++
|
||||||
|
// Bounds
|
||||||
|
endIndex > count && (endIndex = count)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fixed size mode
|
||||||
|
startIndex = ~~(scroll.start / itemSize)
|
||||||
|
endIndex = Math.ceil(scroll.end / itemSize)
|
||||||
|
|
||||||
|
// Bounds
|
||||||
|
startIndex < 0 && (startIndex = 0)
|
||||||
|
endIndex > count && (endIndex = count)
|
||||||
|
|
||||||
|
totalSize = count * itemSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endIndex - startIndex > config.itemsLimit) {
|
||||||
|
this.itemsLimitError()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.totalSize = totalSize
|
||||||
|
|
||||||
|
let view
|
||||||
|
|
||||||
|
const continuous = startIndex <= this.$_endIndex && endIndex >= this.$_startIndex
|
||||||
|
|
||||||
|
if (this.$_continuous !== continuous) {
|
||||||
|
if (continuous) {
|
||||||
|
views.clear()
|
||||||
|
unusedViews.clear()
|
||||||
|
for (let i = 0, l = pool.length; i < l; i++) {
|
||||||
|
view = pool[i]
|
||||||
|
this.unuseView(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$_continuous = continuous
|
||||||
|
} else if (continuous) {
|
||||||
|
for (let i = 0, l = pool.length; i < l; i++) {
|
||||||
|
view = pool[i]
|
||||||
|
if (view.nr.used) {
|
||||||
|
// Update view item index
|
||||||
|
if (checkItem) {
|
||||||
|
view.nr.index = items.findIndex(
|
||||||
|
item => keyField ? item[keyField] === view.item[keyField] : item === view.item,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if index is still in visible range
|
||||||
|
if (
|
||||||
|
view.nr.index === -1 ||
|
||||||
|
view.nr.index < startIndex ||
|
||||||
|
view.nr.index >= endIndex
|
||||||
|
) {
|
||||||
|
this.unuseView(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unusedIndex = continuous ? null : new Map()
|
||||||
|
|
||||||
|
let item, type, unusedPool
|
||||||
|
let v
|
||||||
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
|
item = items[i]
|
||||||
|
const key = keyField ? item[keyField] : item
|
||||||
|
if (key == null) {
|
||||||
|
throw new Error(`Key is ${key} on item (keyField is '${keyField}')`)
|
||||||
|
}
|
||||||
|
view = views.get(key)
|
||||||
|
|
||||||
|
if (!itemSize && !sizes[i].size) {
|
||||||
|
if (view) this.unuseView(view)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// No view assigned to item
|
||||||
|
if (!view) {
|
||||||
|
type = item[typeField]
|
||||||
|
unusedPool = unusedViews.get(type)
|
||||||
|
|
||||||
|
if (continuous) {
|
||||||
|
// Reuse existing view
|
||||||
|
if (unusedPool && unusedPool.length) {
|
||||||
|
view = unusedPool.pop()
|
||||||
|
view.item = item
|
||||||
|
view.nr.used = true
|
||||||
|
view.nr.index = i
|
||||||
|
view.nr.key = key
|
||||||
|
view.nr.type = type
|
||||||
|
} else {
|
||||||
|
view = this.addView(pool, i, item, key, type)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use existing view
|
||||||
|
// We don't care if they are already used
|
||||||
|
// because we are not in continous scrolling
|
||||||
|
v = unusedIndex.get(type) || 0
|
||||||
|
|
||||||
|
if (!unusedPool || v >= unusedPool.length) {
|
||||||
|
view = this.addView(pool, i, item, key, type)
|
||||||
|
this.unuseView(view, true)
|
||||||
|
unusedPool = unusedViews.get(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
view = unusedPool[v]
|
||||||
|
view.item = item
|
||||||
|
view.nr.used = true
|
||||||
|
view.nr.index = i
|
||||||
|
view.nr.key = key
|
||||||
|
view.nr.type = type
|
||||||
|
unusedIndex.set(type, v + 1)
|
||||||
|
v++
|
||||||
|
}
|
||||||
|
views.set(key, view)
|
||||||
|
} else {
|
||||||
|
view.nr.used = true
|
||||||
|
view.item = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
if (itemSize === null) {
|
||||||
|
view.position = sizes[i - 1].accumulator
|
||||||
|
} else {
|
||||||
|
view.position = i * itemSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$_startIndex = startIndex
|
||||||
|
this.$_endIndex = endIndex
|
||||||
|
|
||||||
|
if (this.emitUpdate) this.$emit('update', startIndex, endIndex)
|
||||||
|
|
||||||
|
// After the user has finished scrolling
|
||||||
|
// Sort views so text selection is correct
|
||||||
|
clearTimeout(this.$_sortTimer)
|
||||||
|
this.$_sortTimer = setTimeout(this.sortViews, 300)
|
||||||
|
|
||||||
|
return {
|
||||||
|
continuous,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getListenerTarget () {
|
||||||
|
let target = ScrollParent(this.$el)
|
||||||
|
// Fix global scroll target for Chrome and Safari
|
||||||
|
if (window.document && (target === window.document.documentElement || target === window.document.body)) {
|
||||||
|
target = window
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
},
|
||||||
|
|
||||||
|
getScroll () {
|
||||||
|
const { $el: el, direction } = this
|
||||||
|
const isVertical = direction === 'vertical'
|
||||||
|
let scrollState
|
||||||
|
|
||||||
|
if (this.pageMode) {
|
||||||
|
const bounds = el.getBoundingClientRect()
|
||||||
|
const boundsSize = isVertical ? bounds.height : bounds.width
|
||||||
|
let start = -(isVertical ? bounds.top : bounds.left)
|
||||||
|
let size = isVertical ? window.innerHeight : window.innerWidth
|
||||||
|
if (start < 0) {
|
||||||
|
size += start
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if (start + size > boundsSize) {
|
||||||
|
size = boundsSize - start
|
||||||
|
}
|
||||||
|
scrollState = {
|
||||||
|
start,
|
||||||
|
end: start + size,
|
||||||
|
}
|
||||||
|
} else if (isVertical) {
|
||||||
|
scrollState = {
|
||||||
|
start: el.scrollTop,
|
||||||
|
end: el.scrollTop + el.clientHeight,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollState = {
|
||||||
|
start: el.scrollLeft,
|
||||||
|
end: el.scrollLeft + el.clientWidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrollState
|
||||||
|
},
|
||||||
|
|
||||||
|
applyPageMode () {
|
||||||
|
if (this.pageMode) {
|
||||||
|
this.addListeners()
|
||||||
|
} else {
|
||||||
|
this.removeListeners()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addListeners () {
|
||||||
|
this.listenerTarget = this.getListenerTarget()
|
||||||
|
this.listenerTarget.addEventListener('scroll', this.handleScroll, supportsPassive ? {
|
||||||
|
passive: true,
|
||||||
|
} : false)
|
||||||
|
this.listenerTarget.addEventListener('resize', this.handleResize)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeListeners () {
|
||||||
|
if (!this.listenerTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listenerTarget.removeEventListener('scroll', this.handleScroll)
|
||||||
|
this.listenerTarget.removeEventListener('resize', this.handleResize)
|
||||||
|
|
||||||
|
this.listenerTarget = null
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToItem (index) {
|
||||||
|
let scroll
|
||||||
|
if (this.itemSize === null) {
|
||||||
|
scroll = index > 0 ? this.sizes[index - 1].accumulator : 0
|
||||||
|
} else {
|
||||||
|
scroll = index * this.itemSize
|
||||||
|
}
|
||||||
|
this.scrollToPosition(scroll)
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToPosition (position) {
|
||||||
|
if (this.direction === 'vertical') {
|
||||||
|
this.$el.scrollTop = position
|
||||||
|
} else {
|
||||||
|
this.$el.scrollLeft = position
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
itemsLimitError () {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('It seems the scroller element isn\'t scrolling, so it tries to render all the items at once.', 'Scroller:', this.$el)
|
||||||
|
console.log('Make sure the scroller has a fixed height (or width) and \'overflow-y\' (or \'overflow-x\') set to \'auto\' so it can scroll correctly and only render the items visible in the scroll viewport.')
|
||||||
|
})
|
||||||
|
throw new Error('Rendered items limit reached')
|
||||||
|
},
|
||||||
|
|
||||||
|
sortViews () {
|
||||||
|
this.pool.sort((viewA, viewB) => viewA.nr.index - viewB.nr.index)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vue-recycle-scroller {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.direction-vertical:not(.page-mode) {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.direction-horizontal:not(.page-mode) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.direction-horizontal {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller__slot {
|
||||||
|
flex: auto 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller__item-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.ready .vue-recycle-scroller__item-view {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.direction-vertical .vue-recycle-scroller__item-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.direction-horizontal .vue-recycle-scroller__item-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.ready.direction-vertical .vue-recycle-scroller__item-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-recycle-scroller.ready.direction-horizontal .vue-recycle-scroller__item-view {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,21 @@
|
||||||
|
export const props = {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
keyField: {
|
||||||
|
type: String,
|
||||||
|
default: 'id',
|
||||||
|
},
|
||||||
|
|
||||||
|
direction: {
|
||||||
|
type: String,
|
||||||
|
default: 'vertical',
|
||||||
|
validator: (value) => ['vertical', 'horizontal'].includes(value),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simpleArray () {
|
||||||
|
return this.items.length && typeof this.items[0] !== 'object'
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
itemsLimit: 1000,
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* See https://gitlab.com/gitlab-org/gitlab/-/issues/331267 for more information on this vendored
|
||||||
|
* dependency
|
||||||
|
*/
|
||||||
|
|
||||||
|
import config from './config'
|
||||||
|
|
||||||
|
import RecycleScroller from './components/RecycleScroller.vue'
|
||||||
|
import DynamicScroller from './components/DynamicScroller.vue'
|
||||||
|
import DynamicScrollerItem from './components/DynamicScrollerItem.vue'
|
||||||
|
|
||||||
|
export { default as IdState } from './mixins/IdState'
|
||||||
|
|
||||||
|
export {
|
||||||
|
RecycleScroller,
|
||||||
|
DynamicScroller,
|
||||||
|
DynamicScrollerItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerComponents (Vue, prefix) {
|
||||||
|
Vue.component(`${prefix}recycle-scroller`, RecycleScroller)
|
||||||
|
Vue.component(`${prefix}RecycleScroller`, RecycleScroller)
|
||||||
|
Vue.component(`${prefix}dynamic-scroller`, DynamicScroller)
|
||||||
|
Vue.component(`${prefix}DynamicScroller`, DynamicScroller)
|
||||||
|
Vue.component(`${prefix}dynamic-scroller-item`, DynamicScrollerItem)
|
||||||
|
Vue.component(`${prefix}DynamicScrollerItem`, DynamicScrollerItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
install (Vue, options) {
|
||||||
|
const finalOptions = Object.assign({}, {
|
||||||
|
installComponents: true,
|
||||||
|
componentsPrefix: '',
|
||||||
|
}, options)
|
||||||
|
|
||||||
|
for (const key in finalOptions) {
|
||||||
|
if (typeof finalOptions[key] !== 'undefined') {
|
||||||
|
config[key] = finalOptions[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalOptions.installComponents) {
|
||||||
|
registerComponents(Vue, finalOptions.componentsPrefix)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
||||||
|
// Auto-install
|
||||||
|
let GlobalVue = null
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
GlobalVue = window.Vue
|
||||||
|
} else if (typeof global !== 'undefined') {
|
||||||
|
GlobalVue = global.Vue
|
||||||
|
}
|
||||||
|
if (GlobalVue) {
|
||||||
|
GlobalVue.use(plugin)
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export default function ({
|
||||||
|
idProp = vm => vm.item.id,
|
||||||
|
} = {}) {
|
||||||
|
const store = {}
|
||||||
|
const vm = new Vue({
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// @vue/component
|
||||||
|
return {
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
idState: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created () {
|
||||||
|
this.$_id = null
|
||||||
|
if (typeof idProp === 'function') {
|
||||||
|
this.$_getId = () => idProp.call(this, this)
|
||||||
|
} else {
|
||||||
|
this.$_getId = () => this[idProp]
|
||||||
|
}
|
||||||
|
this.$watch(this.$_getId, {
|
||||||
|
handler (value) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$_id = value
|
||||||
|
})
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
this.$_updateIdState()
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeUpdate () {
|
||||||
|
this.$_updateIdState()
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Initialize an idState
|
||||||
|
* @param {number|string} id Unique id for the data
|
||||||
|
*/
|
||||||
|
$_idStateInit (id) {
|
||||||
|
const factory = this.$options.idState
|
||||||
|
if (typeof factory === 'function') {
|
||||||
|
const data = factory.call(this, this)
|
||||||
|
vm.$set(store, id, data)
|
||||||
|
this.$_id = id
|
||||||
|
return data
|
||||||
|
} else {
|
||||||
|
throw new Error('[mixin IdState] Missing `idState` function on component definition.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure idState is created and up-to-date
|
||||||
|
*/
|
||||||
|
$_updateIdState () {
|
||||||
|
const id = this.$_getId()
|
||||||
|
if (id == null) {
|
||||||
|
console.warn(`No id found for IdState with idProp: '${idProp}'.`)
|
||||||
|
}
|
||||||
|
if (id !== this.$_id) {
|
||||||
|
if (!store[id]) {
|
||||||
|
this.$_idStateInit(id)
|
||||||
|
}
|
||||||
|
this.idState = store[id]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
export let supportsPassive = false
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
supportsPassive = false
|
||||||
|
try {
|
||||||
|
var opts = Object.defineProperty({}, 'passive', {
|
||||||
|
get () {
|
||||||
|
supportsPassive = true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
window.addEventListener('test', null, opts)
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
25
yarn.lock
25
yarn.lock
|
@ -784,10 +784,10 @@
|
||||||
core-js-pure "^3.0.0"
|
core-js-pure "^3.0.0"
|
||||||
regenerator-runtime "^0.13.4"
|
regenerator-runtime "^0.13.4"
|
||||||
|
|
||||||
"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.13.10", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
||||||
version "7.11.2"
|
version "7.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6"
|
||||||
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
|
integrity sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==
|
||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.13.4"
|
regenerator-runtime "^0.13.4"
|
||||||
|
|
||||||
|
@ -10359,6 +10359,11 @@ schema-utils@^3.0.0:
|
||||||
ajv "^6.12.5"
|
ajv "^6.12.5"
|
||||||
ajv-keywords "^3.5.2"
|
ajv-keywords "^3.5.2"
|
||||||
|
|
||||||
|
scrollparent@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/scrollparent/-/scrollparent-2.0.1.tgz#715d5b9cc57760fb22bdccc3befb5bfe06b1a317"
|
||||||
|
integrity sha1-cV1bnMV3YPsivczDvvtb/gaxoxc=
|
||||||
|
|
||||||
select-hose@^2.0.0:
|
select-hose@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
|
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
|
||||||
|
@ -12126,6 +12131,18 @@ vue-loader@^15.9.6:
|
||||||
vue-hot-reload-api "^2.3.0"
|
vue-hot-reload-api "^2.3.0"
|
||||||
vue-style-loader "^4.1.0"
|
vue-style-loader "^4.1.0"
|
||||||
|
|
||||||
|
vue-observe-visibility@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-1.0.0.tgz#17cf1b2caf74022f0f3c95371468ddf2b9573152"
|
||||||
|
integrity sha512-s5TFh3s3h3Mhd3jaz3zGzkVHKHnc/0C/gNr30olO99+yw2hl3WBhK3ng3/f9OF+qkW4+l7GkmwfAzDAcY3lCFg==
|
||||||
|
|
||||||
|
vue-resize@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-1.0.1.tgz#c120bed4e09938771d622614f57dbcf58a5147ee"
|
||||||
|
integrity sha512-z5M7lJs0QluJnaoMFTIeGx6dIkYxOwHThlZDeQnWZBizKblb99GSejPnK37ZbNE/rVwDcYcHY+Io+AxdpY952w==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.13.10"
|
||||||
|
|
||||||
vue-router@3.4.9:
|
vue-router@3.4.9:
|
||||||
version "3.4.9"
|
version "3.4.9"
|
||||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.4.9.tgz#c016f42030ae2932f14e4748b39a1d9a0e250e66"
|
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.4.9.tgz#c016f42030ae2932f14e4748b39a1d9a0e250e66"
|
||||||
|
|
Loading…
Reference in New Issue